/* ============================================================
   Flash Reading Stage — 视觉舞台样式 (T3a/T3b/T3c)
   仅 index.html (flash) 引入; pro 不受影响
   约定: 舞台 #flash-reading-stage 加 .active 显示;
         子组件用 .in / .active / .done / .fr-card-* 状态类触发
   ============================================================ */

/* ---------- 舞台容器 ---------- */
.fr-stage{
  position:fixed;
  top:0;left:0;right:0;bottom:0;
  z-index:999999; /* 低于 pay-overlay(9999999), 高于扇形牌(30000) */
  /* 压暗层在"已选中的那排牌"位置挖一个椭圆透光洞。
     原因：三张已选中的牌被 _arrangePickedCards 移到 document.body(z-index 9500~9502)，
     位于舞台之外，会被这层背景压暗；挖洞后牌与丝线都处于暗层之上，
     形成"第一张牌点亮第二张"的观感，而下方扇形牌与背景仍然压暗、保住舞台对比。
     椭圆中心 50%/18% 对齐牌的落位(_arrangePickedCards 用 top:innerHeight*0.18)。

     ⚠⚠ 2026-10-09：纵向半径从 **30% 放大到 44%**（用户反馈"第二三张卡牌旋转出来时
     牌后面的黑色背景框加深了，跟第一张牌的透明度一样就行"）。
     根因：**放大后的读牌牌**不是"那排小牌" —— 它从顶部 `vh*8.5%`(≈72px) 一直排到 ≈400px，
     而 30% 半径的透明核心只到 `153 + 256*0.72 ≈ 337px` ✗
     → 放大牌的下半部分掉进 `.38~.68` 的压暗带 → 牌背后出现一片"黑色方框" ✗
     （卡1 因为落位时光爆把这一带打亮，看着不明显；卡2/3 旋转时没有这层遮盖就露出来了 ✗）
     ⇒ 透明核心必须**完整盖住放大牌的整个高度**（含**下边两个角** ✗ —— 角是最坏点：
       横向偏移 `dx≈0.365` 已经吃掉一部分半径 ✗）：
       需 `sqrt(0.365² + (245/ry)²) ≤ 0.72` → `ry ≥ 395px` = **46.3%**，取 **48%** 留余量 ✓
       （实测 44% 时下边角 alpha=0.089 ✗ → 48% 后应 ≤0.02 ✓）
     ⚠ 横向保持 68% 不动（横向本来就够宽，且改它会削弱左右两侧的舞台压暗对比 ✗）

     ⚠⚠⚠ 2026-10-09 第七次（用户连报："又回到最初的状态了第一张和后面的效果不一样，
     你对比一下第一和第二张的，全部改成按第一张的效果"）：
     把纵向半径放大到 48% **仍然没解决** ✗ —— 因为**真凶不是半径不够，而是颜色太黑** ✗✗：
       · `rgba(10,4,24,.62~.68)` 是**近黑紫**。放大/旋转中的牌只要有一角踩进这条 `.38→.68`
         的带里，贴出来的就是一块**纯黑的圆角暗板** —— 半径再大也只是把边界往外推，
         牌更大/转角度更刁时又会踩进去 ✗（这就是"改了又回来"的原因）。
       · 卡1 之所以看不到：它落位时有 `.fr-card-veil`（不透明暖白径向，`z-index:3`）盖住，
         而卡2/3 走 `keepVeil=true` **没有 veil** → 暗带直接贴在牌面上 ✗。
     ⇒ **定稿修法（一次做对）**：**不删暗角、也不再加半径**，而是把"黑"整体换成**极淡紫薄纱**：
       ① 色相 `rgba(10,4,24,*)` → **`rgba(122,66,180,*)`**（与牌后 bg2.webp 紫星空同色系，
          压暗变成"氛围"而不是"黑框"✓）
       ② 椭圆中心 `50% 18% → 50% 32%`（牌放大后的**视觉中心**，不再只管小牌落位点）
       ③ 纵向半径 `48% → 62%`（覆盖放大牌全高 + 旋转路径）
       ④ 峰值 alpha 全线压低：`.38→.14`、`.62→.20`、`.68→.26`
       ⇒ 全路径 alpha ≤ ~0.14，只有最下缘留 **~0.26 的柔和 vignette**（保留"舞台聚焦"的设计意图 ✓）
       实测牌位/旋转路径上的可见度：见 `.dbg/verify_stage_vignette.js` ✓
     ⚠ 这是**唯一**改动点：三张牌的 `.fr-card` / `.fr-card-flying` / veil 一律不动 ✓
        （"全部改成按第一张"= 让卡2/3 的**背景也始终可见**，而不是"给卡2/3 加块东西" ✓）*/
  background:radial-gradient(ellipse 68% 62% at 50% 32%,
    rgba(122,66,180,0) 0%,
    rgba(122,66,180,0) 72%,
    rgba(122,66,180,.14) 84%,
    rgba(122,66,180,.20) 94%,
    rgba(122,66,180,.26) 100%);
  overflow:hidden;
  opacity:0;
  transition:opacity .6s ease;
  font-family:-apple-system,sans-serif;
}
.fr-stage.active{
  display:block !important;
  opacity:1;
}
/* ---------- 氛围光斑 (flash 动风格) ---------- */
.fr-ambient{position:absolute;top:0;left:0;right:0;bottom:0;pointer-events:none;z-index:0;}
.fr-orb{position:absolute;border-radius:50%;will-change:transform;}
/* ⚠ 单线程栅格化设备（实测 OPPO X7：chrome://gpu 显示 Multiple Raster Threads = Disabled ✗）
   上，`blur(55px)` 于 480px 元素 = 巨大纹理 + 单线程重栅格化 → 掉帧 ✗
   而 .fr-orb 的 background **本来就是径向渐变**（已经很柔 ✓），55px 模糊几乎没贡献 →
   去掉模糊，观感几乎无变化 ✓（这是本次逐项优化的第 1 项 ✓）*/
.fr-orb1{width:min(64vw,480px);height:min(64vw,480px);left:-16vw;top:6vh;opacity:.55;background:radial-gradient(circle,rgba(180,130,255,.4) 0%,rgba(180,130,255,.12) 45%,transparent 68%);animation:frOrbDrift1 9s ease-in-out infinite;}
.fr-orb2{width:min(56vw,420px);height:min(56vw,420px);right:-14vw;top:36vh;opacity:.5;background:radial-gradient(circle,rgba(255,215,120,.3) 0%,rgba(255,215,120,.1) 45%,transparent 68%);animation:frOrbDrift2 12s ease-in-out infinite;}
@keyframes frOrbDrift1{0%,100%{transform:translate(0,0) scale(1)}50%{transform:translate(5vw,4vh) scale(1.18)}}
@keyframes frOrbDrift2{0%,100%{transform:translate(0,0) scale(1)}50%{transform:translate(-4vw,-5vh) scale(1.12)}}
.fr-spark{position:absolute;bottom:-4vh;border-radius:50%;background:#ffe9a8;box-shadow:0 0 10px rgba(255,225,150,.9);opacity:0;animation:frSparkRise linear infinite;will-change:transform,opacity;}
@keyframes frSparkRise{0%{transform:translate(0,0) scale(.4);opacity:0}10%{opacity:.85}80%{opacity:.4}100%{transform:translate(var(--sx,0vw),-90vh) scale(1);opacity:0}}

/* ---------- T3a: 牌放大/缩小/归位 ---------- */
.fr-card-zone{
  position:absolute;
  top:0;left:0;right:0;bottom:0;
  display:flex;
  align-items:flex-start;
  justify-content:center;
  padding-top:20vh;
  pointer-events:none;
  z-index:1;
}
.fr-card{
  position:relative;
  /* --nms：牌名/关键词的额外缩放系数。牌在舞台里时是 1；
     缩回扇位后由 JS（frCardShrink 落地）设成「扇牌宽 ÷ 舞台牌宽」，
     让文字跟着小牌一起变小，而不是保持原尺寸撑破小牌。 */
  --nms:1;
  /* ⚠ 这个只是**兜底**值：真正的高度由 JS `frFitCardToViewport()` 按
     「可视高度 − 对话框实际高度 − 边距」算出来并写成 inline height ✓
     （原值没算对话框高度 ✗ → 矮屏上牌被对话框盖住 ✗）*/
  height:min(58vh, 83vw);
  /* ⚠ 牌图真实尺寸是 755×1260（h/w=1.6689），原来写 60/85(=1.4167) +
     object-fit:cover → 图片被放大到覆盖，**上下各裁掉约 15%** ✗（用户："比例不对，上下显示不全"）。
     改成与图片一致的比例，图片就不再被裁 ✓ */
  aspect-ratio:755/1260;
  border-radius:12px;
  opacity:0;
  transform:scale(.55) translateY(6vh);
  transition:transform .9s cubic-bezier(.22,1,.36,1), opacity .7s ease, box-shadow .8s ease;
  box-shadow:0 0 40px rgba(180,130,255,.35);
  will-change:transform,opacity;
  perspective:1200px;
  /* 极低透明度的填充（视觉上等于透明）：只为让"牌这一层"永远有可栅格化的内容，
     避免 GPU 合成路径在图层内容为空时把它画成黑矩形（见 .fr-card-inner 的说明）。 */
  background:rgba(22,10,40,.004);
}
.fr-card-inner{
  position:relative;
  width:100%;
  height:100%;
  transform-style:preserve-3d;
  transition:transform 1.1s cubic-bezier(.45,0,.2,1);
  will-change:transform;
  /* ⚠ 给"负责旋转的这一层"一个不透明底色。
     两张 img 都是 backface-visibility:hidden —— 旋转到 90°/270° 附近时**两张同时消失**，
     该图层内容为空；GPU 合成路径会把这块已提升的图层画成**黑矩形**（软件渲染看不出来，所以只在真机复现）。
     有底色后图层永远有内容，不会再出现"空图层被画黑"。
     圆角与 img 的 12px 对齐，正常角度下被牌面完全盖住、不影响观感。
     ★★ 2026-10-09：底色由**深紫近黑 `#160a28`** 改成**与 `.fr-card-veil` 同调的暖白**。
        原深色底在"侧转 edge-on"时露出来就是**一整块黑** ✗ ——
        卡1 有 `.fr-card-veil` 盖着看不出来 ✓，卡2/卡3 走 keepVeil 没有 veil ✗
        → 用户：「第一张旋转出来时牌底下是透明的占位，第二和第三张是黑色的」✓
        实测（`.dbg/probe_det90.js`）：同角度下深底取色 [42,35,22]（黑）、
        暖白底取色 [255,247,225]（与卡1 一致）✓
     ⚠ 只改**颜色/渐变**，不删这层底色 —— 删了会回到"空图层被 GPU 画黑"的老问题 ✗ */
  border-radius:12px;
  background:radial-gradient(circle at 50% 42%,
    rgba(255,252,240,.85) 0%, rgba(255,240,196,.68) 34%,
    rgba(255,220,145,.5) 62%, rgba(224,186,255,.38) 100%);
}
.fr-card img{
  position:absolute;
  top:0;left:0;
  width:100%;
  height:100%;
  object-fit:cover;
  border-radius:12px;
  display:block;
  backface-visibility:hidden;
  -webkit-backface-visibility:hidden;
}
.fr-card-face{
  transform:rotateY(180deg);
}
.fr-card.fr-flipped .fr-card-inner{
  transform:rotateY(180deg);
}
/* 牌**放大落定**后，边缘有一道光绕着走。
   做法：一个略大于牌面的 ::after，用 conic-gradient 画"一段高光"，
   再用双层 mask（content-box + 整体，composite:exclude）把中间挖空 → 只剩一圈**环**；
   环的起始角度由 @property 注册的 --fr-edge-angle 平滑旋转 → 光就沿着牌边跑 ✓。
   （自定义属性要注册才能插值，和 --fr-grain 同一个道理。） */
@property --fr-edge-angle{syntax:'<angle>';inherits:false;initial-value:0deg}
.fr-card-in::after{
  content:'';
  position:absolute;
  inset:-3.5px;                     /* 环落在牌面**边缘之外**一点，不压牌画 */
  border-radius:inherit;            /* 跟着牌的圆角走 */
  padding:2.6px;                    /* 环的厚度 */
  background:conic-gradient(from var(--fr-edge-angle),
    transparent 0deg,
    rgba(255,232,170,.18) 32deg,
    rgba(255,238,190,.55) 62deg,
    rgba(255,255,250,1)   90deg,
    rgba(255,238,190,.55) 118deg,
    rgba(255,232,170,.18) 150deg,
    transparent 186deg,
    transparent 360deg);
  -webkit-mask:linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite:xor;
  mask:linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  mask-composite:exclude;
  animation:frEdgeRun 3.8s linear infinite;
  filter:drop-shadow(0 0 6px rgba(255,236,180,.75));   /* 让光环从牌自带的金边里跳出来 */
  pointer-events:none;
  z-index:4;                        /* 低于关键词(5)/牌名(6)，只绕边、不抢内容 */
}
@keyframes frEdgeRun{to{--fr-edge-angle:360deg}}

.fr-card-in{
  opacity:1;
  transform:scale(1) translateY(0);
  box-shadow:0 0 60px rgba(180,130,255,.5), 0 0 120px rgba(180,130,255,.2);
  /* 放大落位后**不再浮动**（原来挂了一条 frCardFloat 无限上下浮动，用户要求去掉）。
     牌就静静停在中间，让关键词的"砸下来"成为唯一的动效。 */
}
.fr-card-flying{
  box-shadow:0 0 55px rgba(255,215,0,.4), 0 0 120px rgba(180,130,255,.5);
}
/* ⚠⚠⚠ 2026-10-09 ★★★ 黑框真凶（第三次修正，前两次都错了）★★★
   ── 现象：卡2/3 旋转入场时，牌位处出现**一整块圆角矩形的暗块**（用户连报 3 次，
      截图圈出：比周围背景明显更暗的圆角方框）；卡1 同样旋转却看不到 ✗
   ── 实测定位（`.dbg/probe_blackab.js`，冻结 WAAPI 到 edge-on rot≈87° 逐条 A/B）：
        基线（当前代码）        盒内 L=55.9   盒外 L=133.8   ✗ 盒内暗一大截
        #fr-card{background:暖白} 盒内 L=154.0  盒外 L=166.9   ✓✓ 几乎追平（根因确认）
        #fr-card{background:#fffbef} 106.7 / 93.9  ✓
        #fr-card-inner{background:#fffbef} 106.7 / 93.9  ✓
        .fr-stage{background:none} 33.2 / 37.7  （整体变暗，无关）
        backface-visibility:visible 19.3 / 19.9 （无关）
        will-change:auto          19.3 / 27.0   （**无关** ✗ —— 之前白改了）
   ── 机理：`.fr-card` 基础态是 `background:rgba(22,10,40,.004)`（"用 0.4% 不透明度的近黑紫
      当占位、让图层永远有内容"，见 `.fr-card` 的注释）。转到 edge-on 时牌面塌成一条线、
      `.fr-card-inner` 的暖白渐变宽度≈0 看不见了，**这一层近黑的底色就填满了整个圆角矩形** ✗
      → GPU 把它合成出来就是"一块圆角暗板" ✓
      卡1 有 `.fr-card-veil`（**不透明**的暖白径向渐变，`z-index:3`）正好盖住它 → 看不出来 ✓；
      卡2/3 走 `keepVeil=true` **没有 veil** → 暗底裸露 ✗✗
   ── 修法（★ 2026-10-09 第四次修正，用户要求**改成透明**）：
      用户原话：「卡牌旋转出来时底下的占位底色变成了金色，改成透明吧」
      —— 第三次我改成了**暖白→金**渐变（`.56` 那档明显发金 ✗），用户看到的就是它。
      实测（`.dbg/probe_veilshot.js`，冻结 spinAnim 到各帧逐张截图）：
        金色渐变      → 牌位透出一块**暖金色**圆角矩形 ✗（`fx_shot/vf_cur_022.png`）
        纯 transparent → 金色消失、透出**舞台薄纱星空**、**且无黑块** ✓（`vf_trans_022.png`）
      ⇒ 为什么透明**不会**回到"黑块"：牌身后有 `.fr-stage` 的**光晕/薄纱**在，
        透明后透出的是它（不是空的合成图层）→ 与"卡1 透明占位"观感**完全一致** ✓
      定稿（用户选定）：**视觉等价的极低透明填充**，而不是纯 `transparent` ✓ ——
        保留"这一层永远有内容"的保险（`rgba(...,.004)`），但色调由**近黑紫**换成**暖白**，
        即便极端机型上这层被单独合成，也只会是"看不见的暖白"而**不会**是黑块 ✓
      ⚠ 只覆盖"飞行"这一段：落地后 `.fr-card-flying` 被移除 → 自动回到原底色 ✓
      ⚠ 不要删 `.fr-card` 的基础底色 —— 它防的是"空图层被 GPU 画黑"的老问题 ✓
      ⚠ 也不能只改 `.fr-card-inner` 的背景：edge-on 时 inner 宽度≈0、**盖不住** `.fr-card` 那层 ✗
      ⚠ `.fr-card-veil`（卡1 的光遮盖）**完全不受影响** —— 它是另一个元素 ✓ */
.fr-card.fr-card-flying{
  /* 暖白 + 0.4% 不透明度 ≈ 肉眼完全透明，但保证合成图层永远有内容（防黑块保险）✓ */
  background:radial-gradient(circle at 50% 42%,
    rgba(255,252,240,.004) 0%, rgba(255,240,196,.004) 40%,
    rgba(240,220,255,.004) 100%) !important;
}
/* 保留 will-change 覆盖（虽非黑框主因，但对"提层未重栅格化"仍有正面意义，
   且同样需要高特异度 + !important 才不被后面的通用规则推翻 ✗） */
.fr-card.fr-card-flying,
.fr-card.fr-card-flying .fr-card-inner{will-change:auto !important;}
.fr-card-veil{
  position:absolute;
  top:0;left:0;
  width:100%;height:100%;
  border-radius:12px;
  background:radial-gradient(circle at 50% 42%, rgba(255,252,240,.85) 0%, rgba(255,240,196,.68) 34%, rgba(255,220,145,.5) 62%, rgba(224,186,255,.38) 100%);
  -webkit-backdrop-filter:blur(9px) saturate(1.45) brightness(1.12);
  backdrop-filter:blur(9px) saturate(1.45) brightness(1.12);
  box-shadow:inset 0 0 40px rgba(255,255,255,.6);
  opacity:1;
  z-index:3;
  pointer-events:none;
}
/* ★★ 2026-10-09：卡2/3 专用的"软 veil"标记类。
   样式**完全复用** `.fr-card-veil`（上面那条），这里不改任何视觉 ✗ ——
   它只做语义/调试标记：`frFlyCardFromFan(keepVeil=true)` 也会建 veil（修"牌后圆角暗板"），
   但在**落地时**由 JS 单独淡出移除（卡1 的 veil 走 frLightBurst 消费，卡2 的走 260ms 淡出）。
   ⚠ 不要在这里改颜色/透明/滤镜 —— 卡2 观感必须与卡1 **逐像素一致** ✓
   ⚠ 不要设 `display:none` 或 `opacity:0` —— veil 一旦"没内容"，GPU 又会把牌位画成黑块 ✗✗ */
.fr-card-veil-soft{}
.fr-card-small{
  opacity:.85;
  transform:scale(.3) translateY(-24vh);
  box-shadow:0 0 24px rgba(180,130,255,.3);
}

/* ---------- 牌名（叠在牌面内、贴顶） ---------- */
/* 原来挂在牌外面(top:-1.6vh)，牌缩回扇位后名字还在原位、看着像"飘着的标签"。
   现在贴进牌面顶部并乘 --nms，跟着牌一起缩。 */
.fr-card-name{
  position:absolute;
  /* 同 .fr-card-kw：按"舞台牌宽"排版（100%/--nms）再整体缩 --nms 倍，
     这样牌名在扇牌上也保持正常横排比例，而不是被窄宽度挤成一小块 */
  top:0;left:50%;right:auto;
  width:calc(100% / var(--nms,1));
  transform-origin:top center;
  transform:translateX(-50%) scale(calc(var(--nms,1) * .72));
  opacity:0;
  z-index:6;
  padding:.9vh 3% 1vh;
  text-align:center;                 /* 原来靠 translate(-50%) 居中，改成贴满牌宽后要显式居中 */
  font-family:"Noto Serif TC","Songti TC","STSong","SimSun",serif;
  font-size:clamp(11px,2.5vh,15px);
  font-weight:700;
  letter-spacing:.24em;
  text-indent:.24em;
  color:#fff3cf;
  text-shadow:0 0 12px rgba(255,215,0,.85),0 0 34px rgba(180,130,255,.55);
  background:linear-gradient(180deg,rgba(24,6,38,.88) 0%,rgba(24,6,38,.58) 64%,rgba(24,6,38,0) 100%);
  border-radius:12px 12px 0 0;
  /* ★ 允许折两行（用户要求）—— 英文长名优先折行、尽量**不缩字号** ✓
     原来这里是 nowrap + overflow:hidden：英文长名直接被裁掉 ✗
     ⚠ 中文牌名很短、一行放得下 → 不会折行，观感不变 ✓ */
  white-space:normal;
  line-height:1.28;                  /* 显式给行高：两行的高度才可预测（frFitCardName 按行数判定）✓ */
  /* ⚠ 这里**不用** `overflow-wrap:break-word` ✗ —— 它会把单词从中间劈开，
     与用户"一个单词没必要分行"的规则冲突（关键词那边已因此踩过坑 ✗）
     牌名的长度问题交给 frFitCardName 处理：**先在词边界折两行，再缩字号** ✓ */
  overflow:hidden;
  pointer-events:none;
  /* ⚠ 用户反馈：牌缩小时"字缩小的特效很卡" ✗
     根因：width:calc(100% / --nms) 是**瞬间**变的，而 transform:scale(--nms) 却在做 0.65s 过渡 ✗
     → 宽高不同步（宽先跳、缩放后到），观感就是卡 ✗
     修法：文字**不再做缩放过渡**，直接跟随（opacity 过渡保留 ✓）*/
  transition:opacity .55s cubic-bezier(.22,1,.36,1);
}
.fr-card-name.in{opacity:1;transform:translateX(-50%) scale(var(--nms,1));}
/* ★ 英文牌名改用**拉丁字距**（用户：以英文版效果优先，主市场美国）——
   `.24em` 是中文式的宽字距：中文牌名只有 2~4 字，靠宽字距撑开才好看 ✓；
   但拉丁文正常只要 0.01~0.05em ✗ —— `.24em` 会把英文名拉得很散，而且更容易排不下 ✗
   → 英文下收到 `.08em`（仍舒展，但不是中式宽距）✓
   ⚠ `text-indent` 必须与 `letter-spacing` 保持一致：居中文本末尾多出一个字距，
     等量的首行缩进正好把视觉中心补回来 ✓
   顺带好处：字距变小 → 需要折行/缩字号的英文名更少 ✓ */
html[lang="en"] .fr-card-name{letter-spacing:.08em; text-indent:.08em;}
/* 牌名原来有一条"高光斜扫过"的闪光（.fr-card-name::after + frNameShine）——
   用户要求去掉：牌名静静亮着就好，不要闪。 */
/* 对话里的"可点关键词"：沿用按键外观，但换成**适配深色底**的配色 ——
   .emotion-kw 原定义在 flash-fast.css，是浅色主题的紫色（rgb(124,58,237)），
   放进深色对话框里几乎看不见 ✗。这里按同样的"按键"语义重描一套。
   （点击逻辑不变：没付试探款 → 弹第一次支付；付过试探款 → 弹第二次支付页） */
.fr-cap-line .emotion-kw{
  display:inline-block;
  /* ⚠ 关键词是**一个词组整体**，任何情况下都不许被浏览器从中间折行 ✗
     （2026-10-08）原来 JS 的 frWrapAtPunct 会把 `<span>`/`</span>` 分到两行 →
     浏览器自动给半开标签补闭合 → 关键词后半截跑出标签外、
     后面的普通文字反被吸进标签里 → 屏幕上出现"句子里的 The 变成了高亮关键词" ✗✗
     JS 侧已改成"整块不可拆"（正解）；这里再加一道 CSS 保险，
     即使将来别处漏了，也顶多是"整块被挤到下一行"，绝不会劈开 ✓ */
  white-space:nowrap;
  color:#ffeaa6;
  font-weight:700;
  background:linear-gradient(180deg, rgba(255,215,0,.20), rgba(255,176,60,.12));
  padding:1px 8px;
  margin:0 1px;
  border-radius:6px;
  border:1px solid rgba(255,215,0,.45);
  box-shadow:0 0 10px rgba(255,200,90,.22);
  cursor:pointer;
  transition:background .25s ease, box-shadow .25s ease, transform .15s ease;
}
.fr-cap-line .emotion-kw:hover,.fr-cap-line .emotion-kw:active{
  background:linear-gradient(180deg, rgba(255,215,0,.34), rgba(255,176,60,.20));
  box-shadow:0 0 16px rgba(255,200,90,.48);
  transform:translateY(-1px);
}
/* ★★ 2026-10-09 破折号组 `——` 不许被浏览器从中间折行 ✗✗
   用户截图：白板「倒吊人正位——此刻的你」被切成
     「倒吊人正位—」/「—此刻的你」——**破折号劈成上下两半** ✗✗
   JS 侧 `frWrapAtPunct` 已把连续的 `—` 合并成**一个不可拆单元**（正解）✓；
   这里再加一道 CSS 保险：万一 JS 换行后某行仍偏宽、浏览器二次折行，
   也让整个破折号组**整体**被挤到下一行，绝不劈开 ✓
   ⚠ `：` 全角冒号前的 `——` 同理受益 ✓
   ⚠ 不能用 `white-space:nowrap` 包整行（会把长句锁死、溢出）✗ → 只包破折号组 ✓ */
.fr-cap-line{word-break:keep-all;overflow-wrap:normal;}
.fr-cap-line .fr-dash{white-space:nowrap;}

/* 三条路线面板（.fr-pro-rec）是贴底浮层，会盖住对话框最后几行（正好是刚写进去的「最終評定」）✗。
   ⚠ 不能靠"抬高对话框"解决 —— 对话框上方就是牌，抬高会顶到牌 ✗。
   正解：给对话框**内部**加底部留白（= 面板高度），这样内容滚到底时
   最后几行会停在留白之上，正好露在面板上方 ✓。 */
/* ⚠ 用"内留白"是错的：对话框本身只有约 106px 高，而面板约 230px ——
   留白比框还高，一滚到底就只剩留白、内容全被推出去 ✗。
   正解：把**整个对话框抬到面板之上**（面板 bottom:5vh + 自身高度 ≈ 17vh → 抬到 22vh）。 */
.fr-caption.pro-rec-on{bottom:22vh !important}

/* ---------- 三步回顾之后的"最后机会"画面 ----------
   参考"舞台上一束金光打下来"的观感：放射光线 + 中央光柱 + 底部光晕 + 文案。
   纯 CSS 画（不引图片），这样轻量、也不会因为图片加载慢而闪。 */
.fr-finale{
  position:absolute;
  top:0;left:0;right:0;bottom:0;
  display:flex;
  align-items:center;
  justify-content:center;
  overflow:hidden;
  opacity:0;
  visibility:hidden;
  pointer-events:none;
  transition:opacity .9s ease, visibility 0s linear .9s;
  z-index:6;   /* 高于各 .fr-popup-state，避免它们透上来 */
}
.fr-finale.on{opacity:1;visibility:visible;transition:opacity .9s ease, visibility 0s}
/* 放射光线：整片慢慢转，像灯光扫过 */
.fr-finale-rays{
  position:absolute;
  left:50%;top:46%;
  /* ★★ 性能（2026-10-09 第六次·用户反馈"光柱画面出现时有点卡"）──────────────────
     原来 **190vmax** —— 手机上 393x852 视口里实测 **2111x2111px = 12.7 倍视口面积** ✗✗，
     而且带 `filter:blur(2px)` + `conic-gradient` + **44s 无限旋转**：
     即"**面积巨大 + 每帧都在重栅格化**"（见本项目已沉淀的性能判据）。
     受控实测（.dbg/probe_finale_size.js，6x CPU 降速 + 单线程栅格化，取中位）：
        190vmax + blur(2px) → 帧率 32.1fps   RasterTask 总 **463ms**
        130vmax + blur(2px) → 帧率 39.2fps   RasterTask 总 **136ms**  ← **-71%** ✓
     ⇒ 改成 **130vmax**。观感为什么不变：放射光只要**盖满视口**即可，
       130vmax = 852*1.3 ≈ 1108px 半径 554px，从中心到最远角(√(393²+852²)/2≈470px)
       仍留有 84px 余量 → 四角全被覆盖 ✓；190vmax 那部分本来就落在屏幕外、根本没被看见 ✗。
     ⚠ 几何必须**联动改**：width/height 与 margin 的负值必须一致，否则圆心会跑 ✗。

     ⚠⚠⚠ 2026-10-09 第十一次·**上述 ①~⑧ 帧率表已被证伪，勿再引用** ✗✗
     （`.dbg/probe_finale_full.js` 复跑同一脚本，数字**完全对不上**：
        ① 全开 31.9 → **27.3**；② 关 rays 41.2 → **26.5**（比基准还低 ✗）；⑧ 全关 47.3 → **30.0**
        ⇒ "关掉更多反而更慢"在物理上不可能 ⇒ 那张表测的是**页面自身生命周期噪声**，不是被测元素 ✗）
     复测方法（三版递进，`.dbg/probe_blur_ab2/4/5.js` + `.dbg/probe_finale_ab3/5.js` + `.dbg/probe_finale_cost.js`）：
       · 必须**交替采样**（每轮两档各测一次，比**成对差**），不能"整档测完再测下一档" ✗
       · 必须**先等页面稳定 8s**；实测页面会在 ~40-50fps（Raster≈800ms）与 ~19fps（Raster≈85ms）
         **两个状态之间自行跳变** ✗ —— 同状态连测 4 次：Paint 281~291ms 稳定，但
         Raster **803~937ms**、fps **49.6~54.6** 剧烈波动 ⇒ 噪声底就有 ±5fps
       · 结论（交替 5~6 轮，看中位成对差）：
           rays 去 blur     中位 Δfps **+0.4**（6 轮中 3 轮为正 = 抛硬币）✗ 无显著增益
           rays 关旋转      中位 ΔRaster **+3ms** ✗ 噪声内
           rays **完全移除**  中位 ΔRaster **-0ms** ✗✗ 整层删掉都不影响耗时
           beam+glow 停呼吸  中位 ΔRaster **+2ms** ✗ 噪声内
         且**整层 `.fr-finale` 隐藏**的中位 ΔPaint 反而是 **+12ms（变慢）** ⇒ 该指标由画面外的东西主导 ✗
     附带发现（`.dbg/probe_raysvis.js`）：rays 在这张画面里**几乎不可见** ——
       有 rays vs 完全没有 rays：均值 |ΔL|=**0.13**、最大 9.5、Δ≥5 仅 **0.01%**
       （被 `.fr-finale-beam` / `.fr-finale-glow` 完全盖住）✓
     ⇒ ⚠ **本画面无法在无头环境复现"卡顿"，也找不到任何单项可测的开销** —— 保持现状，
       **不要再基于那张假表做优化** ✗；若真机仍卡，需**真机 profiling**（用户要求每轮只动一个变量）✓

     ★ 本次唯一采纳的改动：**去掉 rays 的 `filter:blur(2px)`** ——
       ⚠ **不是**因为"+41%"（已证伪 ✗），而是因为它是**彻底的零视觉改动**且**删掉一个全屏 filter pass**：
       逐像素实测（`.dbg/probe_blur_clean.js`：CSS 层 `*{animation-play-state:paused;transition:none}`
       + 隐藏 `#pay-overlay` 后**页面完全凝固**，对照空测 max ΔL=**0.0**）：
           全视口 83922 个采样点，blur on/off 的 |ΔL| **均值/中位/p99.9/最大 全部 = 0.0** ✗✗ 逐像素完全一致
       —— 即"2px 模糊放在 1108px 的图层上"在最终合成里**一个像素都没改变** ✓
       ⇒ 无风险删掉；对低端 GPU 的实际收益**无法在无头环境量化**，属保守清理 ✓
       ⚠ 旋转**保留**（这是设计的一部分，属**观感改动**，不在"零视觉"授权范围内 ✗ → 只交给
         `html.low-perf` 降级去做 ✓）*/
  /* ★★ 2026-10-09 第十二次·**安卓"只渲染出半截"的真因**（K-2，用户澄清：不是卡，是画不全）✗✗
     ── 现象：光柱画面在安卓上**有时只画出半截**（用户截图：上半截亮、下半截黑 / 或整体只有一块）。
     ── 真因（`.dbg/diag_finale_layers.js` 五机型扫描）：
        本层的 CSS 尺寸是 `130vmax` —— **`vmax` 不含 DPR** ✗，
        而它带 `conic-gradient` + `44s 无限旋转` → 被**提升为独立合成层**，
        于是"设备像素尺寸 = CSS 尺寸 × devicePixelRatio"被整体光栅化成**一张巨纹理**：
              360×640 @3   → 2496×2496  (6.2 Mpx)
              393×852 @2   → 2215×2215  (4.9 Mpx)
              360×780 @3   → 3042×3042  (9.3 Mpx)
              412×915 @2.6 → 3122×3122  (9.8 Mpx)
        安卓 GPU 对**单个合成层的纹理边长与层内存有硬上限**（常见 2048/4096，层内存 100~200MB），
        超限时 Chrome **不会报错，而是静默丢 tile** ✗✗ → 屏幕上就是"只渲染出半截" ✓
        （无头桌面 Chrome 上限宽松，所以一直复现不出来 ✓）
     ── 修法：把本层尺寸**按设备像素封顶**，绝不超 2048 设备像素/边 ✓
        JS 侧算出 `--fr-ray-css`（见 `frFitFinaleRays()`），CSS 这里 `min()` 兜底：
          取"够盖满视口"的直径与"2048/dpr 上限"的**较小者** ✓
        为什么观感不变：放射光**只要盖满视口即可**，其余部分本来就在屏幕外看不见 ✗；
        `cover` 判定 = 直径 ≥ 视口对角线 ×1.15；且实测 rays 被 beam/glow 完全盖住、
        逐点 |ΔL| 均值 0.13 ✓ —— 所以"封顶"是**零视觉改动** ✓
     ⚠ 几何联动：width/height 与 margin 负值必须一致（圆心才会在 50%/46%）✓
     ⚠ 不能写 `contain:paint`（会裁剪光柱的溢出面）✗ */
  width:min(130vmax, var(--fr-ray-css, 130vmax));
  height:min(130vmax, var(--fr-ray-css, 130vmax));
  margin:calc(min(130vmax, var(--fr-ray-css, 130vmax)) / -2) 0 0 calc(min(130vmax, var(--fr-ray-css, 130vmax)) / -2);
  background:conic-gradient(from 0deg,
    rgba(255,224,150,.16) 0deg, transparent 7deg, rgba(255,214,130,.10) 13deg, transparent 21deg,
    rgba(255,224,150,.14) 30deg, transparent 38deg, rgba(255,206,120,.09) 46deg, transparent 55deg,
    rgba(255,224,150,.15) 66deg, transparent 74deg, rgba(255,214,130,.10) 82deg, transparent 92deg,
    rgba(255,224,150,.13) 104deg, transparent 112deg, rgba(255,206,120,.09) 120deg, transparent 130deg,
    rgba(255,224,150,.15) 142deg, transparent 150deg, rgba(255,214,130,.10) 158deg, transparent 168deg,
    rgba(255,224,150,.13) 180deg, transparent 188deg, rgba(255,206,120,.09) 196deg, transparent 206deg,
    rgba(255,224,150,.14) 218deg, transparent 226deg, rgba(255,214,130,.10) 234deg, transparent 244deg,
    rgba(255,224,150,.13) 256deg, transparent 264deg, rgba(255,206,120,.09) 272deg, transparent 282deg,
    rgba(255,224,150,.15) 294deg, transparent 302deg, rgba(255,214,130,.10) 310deg, transparent 320deg,
    rgba(255,224,150,.13) 332deg, transparent 340deg, rgba(255,206,120,.09) 348deg, transparent 360deg);
  animation:frFinaleSpin 44s linear infinite;
  /* ⚠ 这里**故意不写 filter**（原 `filter:blur(2px)` 已移除）——
     不要"顺手加回去" ✗：逐像素实测证明它**在最终合成里完全不产生任何差异**（83922 点全 0.0）✓，
     留着只是白白多一次全屏 filter pass ✓ */
}
@keyframes frFinaleSpin{to{transform:rotate(360deg)}}
/* 中央光柱：从上方打下来 */
.fr-finale-beam{
  position:absolute;
  left:50%;top:-14%;
  /* ★ 性能：改成**预先烘焙好的图**（image/finale-beam.webp，31KB）。
     原来这里是 linear-gradient + filter:blur(16px) —— 大面积模糊图层每帧都要重新栅格化，很吃绘制；
     现在把"渐变+模糊"的结果离线渲染成一张图，运行时只做一次图片合成（动画是纯 transform，走合成器）✓。
     几何与原样式**逐像素对齐**：图的宽度 = 原宽 320 + 两侧各 48px 的模糊余量 → 盒子同步加宽 96px；
     高度方向没留余量（渐变是纯纵向的，纵向模糊不可见）→ top/height 保持不变。 */
  width:calc(min(74vw, 320px) + 96px);
  height:118%;
  transform:translateX(-50%);
  background:url('../image/finale-beam.webp') center/100% 100% no-repeat;
  animation:frFinaleBeam 3.8s ease-in-out infinite;
}
@keyframes frFinaleBeam{
  0%,100%{opacity:.82;transform:translateX(-50%) scaleX(1)}
  50%    {opacity:1;  transform:translateX(-50%) scaleX(1.12)}
}
/* 底部光晕：舞台地面被照亮 */
.fr-finale-glow{
  position:absolute;
  left:50%;
  /* ★ 性能：同光柱，改成预烘焙图（image/finale-glow.webp）。
     几何对齐：图的尺寸 = 原 520x375 + 四周各 30px 模糊余量 → 盒子加宽/加高 60px，
     同时把 bottom 再下移 30px，让**可见光晕**的位置与原来完全一致（推导见下）。
     原：光晕顶边 = 100vh − (−6%) − 44% = 100vh − 38%
     现：盒子 bottom = −6% − 30px、height = 44% + 60px → 盒顶 = 100vh + 6% + 30px − 44% − 60px，
         再往下 30px（图内留白）= 100vh − 38% ✓ 完全一致 */
  bottom:calc(-6% - 30px);
  width:calc(min(120vw, 520px) + 60px);
  height:calc(44% + 60px);
  transform:translateX(-50%);
  background:url('../image/finale-glow.webp') center/100% 100% no-repeat;
  animation:frFinaleBeam 4.6s ease-in-out infinite;
}
.fr-finale-text{
  position:relative;
  z-index:2;
  max-width:82vw;
  text-align:center;
  font-family:"Noto Serif TC","Songti TC","STSong","SimSun",serif;
  font-size:clamp(21px, 5.4vh, 33px);
  font-weight:700;
  line-height:1.62;
  letter-spacing:.03em;
  color:#fffbe8;
  text-shadow:0 0 22px rgba(255,222,140,.85), 0 0 52px rgba(255,196,80,.55), 0 2px 14px rgba(0,0,0,.7);
  opacity:0;
  transform:translateY(14px);
  transition:opacity .9s ease .25s, transform .9s cubic-bezier(.22,1,.36,1) .25s;
}
.fr-finale.on .fr-finale-text{opacity:1;transform:none}
/* 最后画面的每一行：按标点切好后逐行排 */
/* 逐行块：message / silhouette / finale 三处共用 */
.fr-wline,.fr-popup-text-line,.fr-sil-line,.fr-finale-line{display:block}
/* ★ 防"孤字行"：分装时若放宽了每行字数，就用 --fr-fit 按比例缩字号
   （用户要求：宁可缩小一点点也不要一个字占一行 ✓；1em = 父级字号 ✓）*/
.fr-wline,.fr-popup-text-line,.fr-sil-line,.fr-finale-line,.fr-cap-line{font-size:calc(1em * var(--fr-fit,1))}
.fr-finale-line+.fr-finale-line{margin-top:.12em}

/* ============================================================================
   性能优化（2026-09-29）：**零视觉改动**，只让浏览器少干活
   背景：手机 + 4× CPU 降速实测，后半段 p50 掉到 33~50ms（20~30fps）；
        TaskDuration 里约 52% 花在"绘制/合成"、21% 样式重算。
   思路：
     ① 做 transform/opacity 动画的元素**提前提层**（will-change）→ 走合成器，不再每帧重绘；
     ② 容器加 contain: layout → 限制布局影响范围（⚠ 不用 contain:paint，那会把牌的光晕裁掉 ✗）。
   注意：will-change 只给"真的会动"的元素，滥加反而更慢。
   ============================================================================ */
.fr-card,                     /* 放大/缩回/飞行：scale + translateY */
.fr-finale-rays,              /* 44s 旋转的放射光 */
.fr-finale-beam,              /* 呼吸 scaleX 的光柱（带 blur，提层后 blur 只栅格化一次）*/
.fr-finale-glow,              /* 同上 */
.fr-popup-state,              /* 弹窗三态之间的 transform/opacity 过渡 */
.fr-scatter-card,             /* 扇形牌 */
/* 注：.fr-thread 不加 will-change —— 它自身不做 transform，加了只占显存、没有收益 ✗ */
{ will-change: transform, opacity; }

.fr-stage,                    /* 舞台：内部再变化不外溢 */
.fr-caption,                  /* 对话框：内容频繁追加 */
.fr-popup                     /* 弹窗层 */
{ contain: layout; }

/* 三个路线选项按钮：**缩小后放进对话框里**（原来是一个贴底浮层 .fr-pro-rec，已弃用） */
.fr-cap-relrow{
  display:flex;
  flex-wrap:wrap;
  gap:8px;
  justify-content:center;
  margin:8px 0 2px;
}
.fr-cap-rel{
  flex:0 0 auto;
  padding:6px 14px;
  border-radius:16px;
  border:1px solid rgba(255,215,0,.45);
  background:linear-gradient(180deg, rgba(255,215,0,.18), rgba(255,176,60,.10));
  color:#ffeaa6;
  font-family:inherit;
  font-size:13px;
  font-weight:700;
  letter-spacing:.02em;
  cursor:pointer;
  box-shadow:0 0 10px rgba(255,200,90,.22);
  transition:background .25s ease, box-shadow .25s ease, transform .15s ease;
}
.fr-cap-rel:hover,.fr-cap-rel:active{
  background:linear-gradient(180deg, rgba(255,215,0,.32), rgba(255,176,60,.18));
  box-shadow:0 0 16px rgba(255,200,90,.45);
  transform:translateY(-1px);
}

/* 对话框里的行动按钮（旧版"馬上進入深度羈絆解讀 →"那种） */
.fr-cap-cta{
  display:inline-block;
  margin:4px auto 2px;
  padding:9px 20px;
  border-radius:20px;
  border:1px solid rgba(255,215,0,.5);
  background:linear-gradient(180deg, rgba(255,215,0,.24), rgba(255,176,60,.14));
  color:#ffeaa6;
  font-family:inherit;
  font-size:14px;
  font-weight:700;
  letter-spacing:.02em;
  cursor:pointer;
  box-shadow:0 0 14px rgba(255,200,90,.28);
  transition:background .25s ease, box-shadow .25s ease, transform .15s ease;
}
.fr-cap-cta:hover,.fr-cap-cta:active{
  background:linear-gradient(180deg, rgba(255,215,0,.38), rgba(255,176,60,.22));
  box-shadow:0 0 20px rgba(255,200,90,.5);
  transform:translateY(-1px);
}

/* 卡1 与卡2 两段对话之间的分隔线 ——
   两段文字是连着往上滚的，中间不加一道线会糊成一片、分不清哪句属于哪张牌。 */
.fr-cap-sep{
  height:1px;
  margin:12px 2px 14px;
  background:linear-gradient(90deg, transparent, rgba(233,200,119,.5), transparent);
  opacity:0;
  transition:opacity .6s ease;
}
.fr-cap-sep.in{opacity:1}

/* ---------- 开奖光斑 (第一张牌落定瞬间) ---------- */
.fr-burst{
  position:absolute;
  width:0;height:0;
  pointer-events:none;
  z-index:8;
}
.fr-burst-flash{
  position:absolute;
  left:0;top:0;
  width:30px;height:30px;
  margin:-15px 0 0 -15px;
  border-radius:50%;
  background:radial-gradient(circle, #ffffff 0%, #ffe9a8 30%, rgba(255,215,0,.7) 52%, transparent 72%);
  animation:fr-burst-flash 1.05s cubic-bezier(.2,.7,.3,1) forwards;
}
@keyframes fr-burst-flash{
  0%{transform:scale(0);opacity:1}
  30%{transform:scale(7.5);opacity:1}
  100%{transform:scale(13);opacity:0}
}
.fr-burst-ring{
  position:absolute;
  left:0;top:0;
  width:26px;height:26px;
  margin:-13px 0 0 -13px;
  border-radius:50%;
  border:3px solid rgba(255,240,190,.95);
  box-shadow:0 0 18px rgba(255,220,130,.8), inset 0 0 12px rgba(255,220,130,.5);
  animation:fr-burst-ring 1.1s cubic-bezier(.16,.8,.3,1) forwards;
}
@keyframes fr-burst-ring{
  0%{transform:scale(0);opacity:1}
  18%{opacity:1}
  100%{transform:scale(24);opacity:0}
}
.fr-burst-ring2{
  border-color:rgba(210,180,255,.85);
  box-shadow:0 0 16px rgba(200,160,255,.6);
  opacity:0;
  animation:fr-burst-ring 1.25s cubic-bezier(.16,.8,.3,1) .14s both;
}
.fr-burst-p{
  position:absolute;
  left:0;top:0;
  border-radius:50%;
  box-shadow:0 0 14px rgba(255,235,180,.95);
  will-change:transform,opacity;
}
.fr-burst-screen{
  position:fixed;
  top:0;left:0;right:0;bottom:0;
  pointer-events:none;
  z-index:9999999;
  animation:fr-burst-screen .7s ease-out forwards;
}
@keyframes fr-burst-screen{
  0%{opacity:0}
  22%{opacity:.82}
  100%{opacity:0}
}
.fr-burst-bloom{
  position:absolute;
  left:0;top:0;
  width:360px;height:360px;
  margin:-180px 0 0 -180px;
  border-radius:50%;
  background:radial-gradient(circle, rgba(255,240,200,.8) 0%, rgba(255,224,150,.4) 30%, rgba(200,160,255,.2) 55%, transparent 72%);
  animation:fr-burst-bloom 1.25s cubic-bezier(.2,.7,.3,1) forwards;
}
@keyframes fr-burst-bloom{
  0%{transform:scale(.2);opacity:0}
  35%{opacity:.95}
  100%{transform:scale(1.6);opacity:0}
}
.fr-card-burst{
  animation:fr-card-burst .9s cubic-bezier(.2,.7,.3,1);
}
@keyframes fr-card-burst{
  0%{filter:brightness(1.55) saturate(1.2);box-shadow:0 0 70px rgba(255,220,130,.9), 0 0 160px rgba(255,190,80,.5)}
  100%{filter:brightness(1);box-shadow:0 0 30px rgba(180,130,255,.2)}
}
/* T3c: 第三张悬念 — 抖动+轻抬+聚光+能量聚集 */
.fr-card-suspense{
  opacity:1;
  transform:scale(1) translateY(0);
  /* 原来是 `animation:fr-suspense 1.8s ease-in-out infinite`（一直轻微上下浮动）——
     用户要求"三张牌都出来后不要跳动"，所以去掉动画，牌稳稳停住；
     光晕（下面的 box-shadow 与 ::after）保留。@keyframes fr-suspense 留着备用。 */
  box-shadow:0 0 80px rgba(255,215,0,.45), 0 0 150px rgba(180,130,255,.4);
}
.fr-card-suspense::after{
  content:'';
  position:absolute;
  top:-18%;left:-30%;right:-30%;bottom:-18%;
  background:radial-gradient(ellipse 60% 55% at 50% 50%, rgba(255,215,0,.14) 0%, rgba(180,130,255,.08) 45%, transparent 70%);
  border-radius:50%;
  pointer-events:none;
  animation:fr-spot-pulse 1.8s ease-in-out infinite;
}
@keyframes fr-suspense{
  0%,100%{transform:scale(1) translateY(0) rotate(0deg)}
  20%{transform:scale(1.02) translateY(-1.2vh) rotate(-1.1deg)}
  40%{transform:scale(1.02) translateY(-1.8vh) rotate(1.1deg)}
  60%{transform:scale(1.03) translateY(-1.2vh) rotate(-.7deg)}
  80%{transform:scale(1.02) translateY(-1.6vh) rotate(.5deg)}
}
@keyframes fr-spot-pulse{
  0%,100%{opacity:.55;transform:scale(.96)}
  50%{opacity:1;transform:scale(1.05)}
}

/* ---------- T3a: Keyword Burst（关键词面板，叠在牌面内、贴底） ---------- */
/* 原来挂在舞台级单例层 #fr-keyword-layer 里、排在牌下方（padding-top 按牌高推算）。
   现在改为每张牌内部各建一个 .fr-card-kw，跟着牌一起缩放 ——
   缩回扇位后关键词仍然留在小牌上，而不是被清空/留在原位。 */
/* ★★ 2026-10-09 定稿：把 `--nms` 注册成**可插值的自定义属性**。
   背景（用户）：「牌出来后回到原处时，牌上的字**不用二次缩放特效**，直接跟着牌一起缩回去就好」。
   根因：关键词面板挂在牌内、有自己的 `transform:scale(--nms)` + `font-size:calc(...*--nms)`，
   而 `--nms` 在**落地瞬间**从 1 直接跳到 0.4（离散赋值 ✗）→ 文字"啪"地第二次缩小 ✗。
   ⇒ 注册成 `<number>` 后它就能被 WAAPI/CSS 平滑插值 ✓：
     shrink 动画里让 `--nms` 与牌的位置/缩放**同一条时间轴、同一条缓动**跑完，
     文字就"跟着牌一起缩"，全程只有一次连续缩小 ✓（无 @property 的老浏览器退化为跳变，
     但那时牌本身也在同一帧落位，观感影响很小 ✓）*/
@property --nms{syntax:'<number>';inherits:true;initial-value:1}

.fr-card-kw{
  position:absolute;
  /* ⚠ 关键：**布局宽度按"舞台牌宽"算**（100% / --nms），再整体缩 --nms 倍。
     关键词字号是 vh 单位，若直接用扇牌那 78px 的宽度排版，"難以選擇"这种词会折行、
     看起来像竖排；而 --nms 的 transform 只是把"已经折行的排版"整体缩小，救不回来。
     按 100%/--nms 排版后：文字用的是舞台牌宽（一行放得下）→ 再缩 --nms 倍，
     视觉宽度正好等于当前牌宽。舞台牌上 --nms=1，等于原来的写法。 */
  left:50%;right:auto;top:0;bottom:0;
  width:calc(100% / var(--nms,1));
  display:flex;
  flex-direction:column;
  align-items:center;
  justify-content:center;          /* 关键词组垂直居中 = "显示在卡牌中间" */
  gap:1.1vh;
  padding:0 6%;
  border-radius:12px;
  /* 不压暗牌画。可读性完全交给文字自己的"两层对比色硬边框"（见下）。 */
  background:none;
  transform-origin:center center;
  transform:translateX(-50%) scale(var(--nms,1));
  pointer-events:none;
  z-index:5;
}
/* ---------- 关键词外观：字面流动反光 + 两层对比色硬边框 ---------- */
/* 边框用 0 偏移的 text-shadow 实现，只在**实色字**上安全：
   填充不透明、盖在阴影之上，所以两层环只会露在字形外面，不会吃掉笔画。
   反过来，若字面用 background-clip:text（透明填充），0 偏移阴影会盖住被裁到字形里的背景、
   整片涂黑 —— 所以这里坚持用实色填充，反光另外用 ::after 叠一层。 */
.fr-card-kw .fr-keyword{
  position:relative;
  --kw-fill:#1b0a2c;     /* 字面：深色（压在亮牌画上，靠外圈把字"托"出来） */
  --kw-ring1:#ffcf3d;    /* 内圈：对比色·金 */
  --kw-ring2:#ffffff;    /* 外圈：粗白圈（r2-r1 就是白圈厚度） */
  --kw-r1:1.6px;         /* 内圈半径 */
  --kw-r2:4.6px;         /* 外圈半径 */
  color:var(--kw-fill);
  -webkit-text-fill-color:var(--kw-fill);
  /* 内圈（金）用 8 向 0 偏移 text-shadow。半径小（1.2~1.8px），
     8 向的"扇贝形"起伏只有 r*(1-cos22.5°)≈0.1px，看不出来。
     外圈（白）**不能**也用 text-shadow：半径 4~5px 时扇贝起伏约 0.4px，
     边缘会明显不平滑 —— 改成 ::before 上的真实描边（见下）。 */
  text-shadow:
    calc(var(--kw-r1) * -1) 0 0 var(--kw-ring1),
    var(--kw-r1) 0 0 var(--kw-ring1),
    0 calc(var(--kw-r1) * -1) 0 var(--kw-ring1),
    0 var(--kw-r1) 0 var(--kw-ring1),
    /* ★ 性能：原来还有 4 条**对角**阴影（±0.71r），现按用户确认去掉 → 8 层减到 4 层。
       金色内圈的转角会略微方一点点（外圈白边走的是 -webkit-text-stroke 矢量描边，不受影响）。 */
    /* 外围光晕（"光线围绕"）：写在**最后** → 画在最底层，
       所以是字形外面一圈柔和的光，不会盖住字面、也不会盖住那两层硬边框 */
    0 0 14px rgba(255,214,120,.78),
    0 0 30px rgba(255,196,96,.52),
    0 0 58px rgba(255,178,72,.28);
}
/* 外圈（白）：复制一份文字、只留描边，放在字面**背后**。
   -webkit-text-stroke 是矢量描边 → 边缘绝对平滑（不像 8 向阴影有扇贝起伏）。
   描边以字形轮廓为中心，向内的一半被不透明的深色字面完全盖住，
   所以只会露出向外的 --kw-r2（= 描边宽的一半），可见白圈厚度正好是 r2 − r1。 */
.fr-card-kw .fr-keyword::before{
  content:attr(data-text);
  position:absolute; left:0; top:0; width:100%;
  z-index:-1;                       /* 落在字面与金色内圈之下 */
  text-shadow:none;                 /* 不继承内圈的 8 条阴影，否则金圈会画两遍 */
  color:transparent;
  -webkit-text-fill-color:transparent;
  -webkit-text-stroke:calc(var(--kw-r2) * 2) var(--kw-ring2);
  pointer-events:none;
}
/* 字面流动反光：把一段白色高光带裁进字形，沿字面扫过。
   绝对定位 + 无 z-index → 画在文字与两层边框之上。 */
.fr-card-kw .fr-keyword::after{
  content:attr(data-text);
  position:absolute; left:0; top:0; width:100%;
  text-shadow:none;
  background-image:linear-gradient(100deg,
    rgba(255,255,255,0)   40%,
    rgba(255,255,255,.55) 46%,
    rgba(255,255,255,.98) 50%,
    rgba(255,255,255,.55) 54%,
    rgba(255,255,255,0)   60%);
  background-size:300% 100%;
  background-repeat:no-repeat;
  -webkit-background-clip:text; background-clip:text;
  color:transparent; -webkit-text-fill-color:transparent;
  animation:frKwShine 3.2s linear infinite;
  pointer-events:none;
}
@keyframes frKwShine{
  0%{background-position:160% 0}
  100%{background-position:-160% 0}
}
.fr-keyword{
  opacity:0;
  transform:scale(1.7);
  filter:blur(14px);
  white-space:nowrap;      /* 关键词**不换行**；超宽时由 frFitKeyword() 缩字号 */
  transition:opacity .8s cubic-bezier(.22,1,.36,1), transform .8s cubic-bezier(.22,1,.36,1), filter .8s ease;
  text-align:center;
  line-height:1.25;
}
.fr-keyword.in{
  opacity:1;
  transform:scale(1);
  filter:blur(0);
}
/* 关键词出场：**像从镜头外砸下来** —— 由大到小（3.6 倍 → 1 倍）、带着虚化和高亮冲进来，
   砸到牌面的一刻"震一下"（左右衰减抖动），最后落定。
   刻意与下方对话的"逐行从下往上淡入"区分开 —— 关键词是要被强调的内容，两者不该用同一种出现方式。
   用 keyframes 而不是 transition：要的是"砸中 → 回震"这一段，transition 只能单调趋近、做不出来。 */
.fr-card-kw .fr-keyword.in{
  animation:frKwSlam 1s cubic-bezier(.22,1.05,.3,1) both;
}
/* ★★★ 2026-10-09 ★★★ 「牌缩回原位时，牌面关键词的特效」—— 用户要求**彻底取消**（连报三次）
   ── 用户原话：「我只是要**取消卡牌缩小回到原位时牌面关键词的特效**，其他都不用改」
   ── 真凶：上面那条 `animation:frKwSlam` 挂在 `.in` 类上；`frCardShrink._land()` 把整个
      面板 `appendChild` 到扇牌（**换父节点**）——
      ⚠⚠ **元素换父节点时，CSS animation 会被浏览器重新从头播放** ✗
      ⇒ 三个关键词在**同一毫秒**一起重播一遍"砸下来 + 震动" ✗✗
   ── 取证（`.dbg/probe_kw_replay.js`，监听 `animationstart`）：
        入场：868412 tag / 869634 main / 870838 sub   ← 逐个砸，正确 ✓
        缩回：890316 tag+main+sub **同一毫秒三个一起** ✗✗ ← 就是要取消的那个特效
   ── 修法：`_land()` 搬家前给面板打上 `.fr-kw-onfan`，这里让它的关键词**永久无动画** ✓
      ⇒ 换父节点时无动画可重播 ✓；搬到扇牌后也**永远不需要**再砸一次 ✓
      ⚠ **只关 `animation`**，不碰 opacity/transform/描边 → 静态外观与原来**完全一致** ✓
      ⚠ 选择器特异性：`.fr-kw-onfan .fr-keyword.in`（0-3-0）> `.fr-card-kw .fr-keyword.in`（0-2-0）✓
      ⚠ `html.low-perf` 那条 `frKwSlamLite`（0-3-0）也要一起压掉 → 补一条 0-3-0 的 lang/low 覆盖 ✓ */
.fr-card-kw.fr-kw-onfan .fr-keyword,
.fr-card-kw.fr-kw-onfan .fr-keyword.in,
html.low-perf .fr-card-kw.fr-kw-onfan .fr-keyword.in{
  animation:none !important;
}
@keyframes frKwSlam{
  0%  {opacity:0;transform:translateY(-26%) scale(3.6) rotate(-7deg);filter:blur(20px) brightness(2.2)}
  26% {opacity:1;transform:translateY(-6%) scale(1.9) rotate(-2.5deg);filter:blur(7px) brightness(1.6)}
  42% {opacity:1;transform:translateY(0) scale(1.05) rotate(1.4deg);filter:blur(0) brightness(1.4)}
  /* ↓ 落地缓冲：**轻轻震一下就停** —— 像东西压到平面上的那一下回弹。
     振幅 2 → 1.4 → 0.8 → 0.35px、旋转 ±0.5° → ±0.06°，且 80% 就基本停住，
     不再像之前那样 ±5px / ±1.2° 连抖五下。 */
  52% {transform:translateY(0) translateX(-2px)   scale(.995)   rotate(-.5deg)}
  62% {transform:translateY(0) translateX(1.4px)  scale(1.0025) rotate(.3deg)}
  71% {transform:translateY(0) translateX(-.8px)  scale(.9992)  rotate(-.15deg)}
  80% {transform:translateY(0) translateX(.35px)  scale(1.0006) rotate(.06deg)}
  100%{opacity:1;transform:translateY(0) translateX(0) scale(1) rotate(0deg);filter:blur(0) brightness(1)}
}
.fr-kw-tag{
  /* 字号拉大到接近主关键词；字距相应收到 .3em，否则 187px 牌宽放不下 */
  font-size:clamp(16px, 2.8vh, 26px);
  letter-spacing:.3em;
  text-transform:uppercase;
  --kw-r1:1.4px; --kw-r2:4.2px;
  transition:opacity .7s cubic-bezier(.22,1,.36,1),transform .7s cubic-bezier(.22,1,.36,1),filter .7s ease,letter-spacing .9s ease;
}
.fr-kw-tag.in{letter-spacing:.35em;}
.fr-kw-main{
  font-family:"Noto Serif TC","Songti TC","STSong","SimSun",serif;
  /* 三行字号拉近：主关键词与另两行的比例从 2.25 压到 ≈1.45，三行都变大 */
  font-size:clamp(26px, 4.1vh, 38px);
  font-weight:900;
  letter-spacing:.1em;
  /* 与另两行同一套"深色字面 + 金内圈 + 粗白外圈"，只是字号最大 → 边框最粗 */
  --kw-r1:1.8px; --kw-r2:5.4px;
}
.fr-kw-main{transition:opacity .9s cubic-bezier(.22,1,.36,1),transform .9s cubic-bezier(.3,1.65,.5,1),filter .9s ease;}
.fr-kw-sub{
  font-family:"Noto Serif TC","Songti TC","STSong","SimSun",serif;
  font-size:clamp(19px, 3.4vh, 32px);
  letter-spacing:.16em;
  --kw-r1:1.6px; --kw-r2:4.6px;
  transition:opacity .8s cubic-bezier(.22,1,.36,1),transform .8s cubic-bezier(.3,1.4,.5,1),filter .8s ease;
}
.fr-kw-task{
  font-size:clamp(12px, 2.2vh, 18px);
  font-style:italic;
  --kw-r1:1.2px; --kw-r2:3.6px;
  transition:opacity .7s cubic-bezier(.22,1,.36,1),transform .7s cubic-bezier(.22,1,.36,1);
}
.fr-kw-label{
  font-size:clamp(8px,1.5vh,10px);
  letter-spacing:.4em;
  padding-left:.4em;
  --kw-r1:1.1px; --kw-r2:3.2px;
  transform:translateY(-9px) scale(.92);
  transition:opacity .7s cubic-bezier(.22,1,.36,1),transform .7s cubic-bezier(.3,1.4,.5,1),filter .7s ease;
}
.fr-kw-label.in{transform:translateY(0) scale(1);}
/* ⚠⚠ 既有 bug 修复：**各档位的环半径从来没生效过** ✗
   它们原本写在 `.fr-kw-main{--kw-r1:1.8px;--kw-r2:5.4px}`（优先级 0-1-0）上，
   而基础规则 `.fr-card-kw .fr-keyword{--kw-r1:1.6px;--kw-r2:4.6px}`（0-2-0）**整体把它压掉** ✗
   → 所有档位实际都在用 1.6/4.6（main 本应 1.8/5.4、tag 本应 1.4/4.2、task 本应 1.2/3.6 ✗）
   这里用更高优先级（0-3-0）**原值重述一遍**，让分档按设计生效 ✓
   （数值与各档位原本写的一字不差，只是把优先级补上；比直接改原选择器更稳 ——
     万一有 `.fr-keyword` 不在 `.fr-card-kw` 里的用法，也不会被影响 ✓）
   效果：环厚与字号成正比 → 小档位（tag/task/label）不再"环比笔画粗" ✓ */
.fr-card-kw .fr-kw-main {--kw-r1:1.8px; --kw-r2:5.4px}
.fr-card-kw .fr-kw-sub  {--kw-r1:1.6px; --kw-r2:4.6px}
.fr-card-kw .fr-kw-tag  {--kw-r1:1.4px; --kw-r2:4.2px}
.fr-card-kw .fr-kw-task {--kw-r1:1.2px; --kw-r2:3.6px}
.fr-card-kw .fr-kw-label{--kw-r1:1.1px; --kw-r2:3.2px}
/* ★★ 2026-10-09 新增：关键词**整组等比缩放**（`--kwfit`，默认 1）——
   背景（用户）：「第二张缩回去后关键词又多了个缩放的动画」——
   根因不是"多了一次动画"，而是**卡2/卡3 有 4 个关键词、卡1 只有 3 个** ✗：
     `.fr-card-kw` 是 `top:0;bottom:0` + `justify-content:center` 的 flex 列，
     各关键词字号是 `vh`/`px`（**跟视口走，不跟面板走**）→
     舞台牌(195px 宽)上 4 条塞得下 ✓；缩回扇牌(--nms 0.4 → 面板布局 195×~137)后
     4 条总高 ≈156px > 137px → **竖向溢出**，看起来就是"关键词在扇牌上又胀了一次" ✗
     （卡1 只有 3 条 ≈121px < 137px → 正好装下 ✓，所以用户看到"卡1 正常、卡2 多一下" ✓）
   做法：给面板一个 `--kwfit` 倍率，**所有档位的字号与环半径都乘它** ✓；
   JS 侧 `frFitKeywordPanel()` 量"内容总高 vs 面板可用高"，超了就整体缩到刚好装下 ✓
   ⚠ 用**字号**而不是 `transform:scale` —— 面板自身的 transform 已承担 `--nms`，
     再叠一层 scale 会与 `translateX(-50%)` 的定位耦合，容易算错 ✓ */
/* ★★ 2026-10-09 定稿：字号同时乘 **`--nms`** 与 `--kwfit` —— 这是"三张牌扇面视觉一致"的关键。
   背景（用户）：「第一张牌是直接连同关键词一起缩小回去，第二张缩回去后关键词又多了个缩放的
   动画，去掉第二张牌缩小后关键词的缩放动画」。
   根因：面板 `.fr-card-kw` 用 `transform:scale(--nms)` 缩小，**但字号是 `vh` 单位** ✗ ——
     字号**不随 `--nms` 变小**，面板只是把"舞台牌尺寸的排版"整个视觉缩小。
     ⇒ 扇面上（`--nms`≈0.4）：面板可视高只有舞台的 4/10，而**关键词组的布局高度没变** ✗
       → 卡1 的 3 条勉强塞下、卡2/卡3 的 4 条（还有更长的词）就"胀出小牌"✗（用户看到的那一下）
   正解：**字号也乘 `--nms`** → 布局字号与 transform **完全同步** ✓
     → 扇牌上的排版比例与舞台牌**一模一样**（只是整体小 0.4 倍）✓
     → 舞台上放得下 ⇒ 扇面上一定放得下 ✓ 三张牌视觉**统一** ✓ 且**没有额外动画** ✓
   `--kwfit` 退化为"极端视口下的最后保险"（正常恒为 1 ✓）。
   ⚠ 已有的 `html[lang="en"]` 环半径分档选择器优先级更高（0-4-1），这里只改字号，不冲突 ✓ */
/* ⚠⚠⚠ 2026-10-09 ★★★ 第四次修正（"牌缩回时关键词还多缩一下"的真凶）★★★
   ── 现象（用户，连报两次）：「卡牌缩回去时牌表面的关键词**不用做缩放特效**，
      直接跟随牌缩小就好」+ 截图圈出扇牌上那三个词
   ── 真凶：`frFitKeyword()` 为了把长词塞进牌宽，会写 **inline `d.style.fontSize='32.29px'`**
       —— 一个**固定 px 值** ✗✗
       inline 优先级高于这条 CSS 规则 → 字号**完全脱离 `var(--nms)`/`var(--kwfit)` 这条链** ✗
       ⇒ 舞台牌量出的"缩字结果"被**冻结**、牌缩回时完全不参与重算 ✗
   ── 修法：JS 改写**倍率** `--kwf1`（相对基准的缩放因子），字号链上再加一环 `* var(--kwf1,1)` ✓
      ⇒ `--nms`/`--kwfit` 一变，字号立刻跟着重算，与牌严格同链 ✓
   ⚠⚠⚠ **字号绝不能乘 `--nms`**（我去掉了原来那个 `var(--nms,1)` ✗）——
      面板的设计是 `width:calc(100%/--nms)`（按舞台牌宽排版）
        + `transform:scale(--nms)`（缩回当前牌宽）
      ⇒ **视觉缩放完全由 transform 承担** ✓；字号只负责"横向塞进牌宽" ✓
      若字号**再乘一次 `--nms`** → 与 transform 双重缩放 ✗✗
      实测（`.dbg/probe_shrink_kw.js`，冻结 950ms shrink 逐帧采）：
        字号乘 --nms：扇牌上关键词 rect 高只剩 **4~7px** ✗✗（小到读不出，用户说的"缩没了"）
        字号不乘：      rect 高 **8.5px** ✓（与原观感一致、可读）
   ⚠ 三档字号（基础 / 平板 / `html[lang="en"]`）都要带 `--kwf1`，漏一处那档就"不跟随"✗ */
.fr-card-kw .fr-kw-main {font-size:calc(clamp(26px, 4.1vh, 38px) * var(--kwfit,1) * var(--kwf1,1))}
.fr-card-kw .fr-kw-sub  {font-size:calc(clamp(19px, 3.4vh, 32px) * var(--kwfit,1) * var(--kwf1,1))}
.fr-card-kw .fr-kw-tag  {font-size:calc(clamp(16px, 2.8vh, 26px) * var(--kwfit,1) * var(--kwf1,1))}
.fr-card-kw .fr-kw-task {font-size:calc(clamp(12px, 2.2vh, 18px) * var(--kwfit,1) * var(--kwf1,1))}
.fr-card-kw .fr-kw-label{font-size:calc(clamp(8px,1.5vh,10px) * var(--kwfit,1) * var(--kwf1,1))}
/* 环半径同理：JS 也不再写 inline px 了（改成写 `--kwf1` 倍率）→ 这里统一乘 `--kwfit` * `--kwf1` ✓
   ⚠ 半径**不乘 `--nms`** —— 它是跟着**字号**算的，而字号已含 `--nms` ✓（乘两次会太细 ✗） */
.fr-card-kw .fr-keyword{
  --kw-r1:calc(1.6px * var(--kwfit,1) * var(--kwf1,1));
  --kw-r2:calc(4.6px * var(--kwfit,1) * var(--kwf1,1));
}
.fr-card-kw .fr-kw-main {--kw-r1:calc(1.8px * var(--kwfit,1) * var(--kwf1,1)); --kw-r2:calc(5.4px * var(--kwfit,1) * var(--kwf1,1))}
.fr-card-kw .fr-kw-sub  {--kw-r1:calc(1.6px * var(--kwfit,1) * var(--kwf1,1)); --kw-r2:calc(4.6px * var(--kwfit,1) * var(--kwf1,1))}
.fr-card-kw .fr-kw-tag  {--kw-r1:calc(1.4px * var(--kwfit,1) * var(--kwf1,1)); --kw-r2:calc(4.2px * var(--kwfit,1) * var(--kwf1,1))}
.fr-card-kw .fr-kw-task {--kw-r1:calc(1.2px * var(--kwfit,1) * var(--kwf1,1)); --kw-r2:calc(3.6px * var(--kwfit,1) * var(--kwf1,1))}
.fr-card-kw .fr-kw-label{--kw-r1:calc(1.1px * var(--kwfit,1) * var(--kwf1,1)); --kw-r2:calc(3.2px * var(--kwfit,1) * var(--kwf1,1))}
/* 组内间距也跟 `--nms`/`--kwfit` 走（原本 `1.1vh` 固定 → 缩小后间距会显得过大 ✗）✓ */
.fr-card-kw{gap:calc(1.1vh * var(--nms,1) * var(--kwfit,1));}
/* ★★ 2026-10-09：扇牌上的关键词**最多 3 条**（`frCardShrink._land` 给第 4 条起加这个类）——
   背景（用户）：「第一张牌是直接连同关键词一起缩小回去，第二张缩回去后关键词又多了个缩放的
   动画，去掉第二张牌缩小后关键词的缩放动画」。
   根因：卡1 出 3 个关键词、卡2/卡3 出 4 个 ✗；扇牌面板可视高仅 ~55px，
   4 条（尤其卡3 的长词）**缩到 `--kwfit` 下限 0.3 仍溢出 6px** ✗✗ —— 再缩就看不见了 ✗
   → 与其把字缩到读不出，不如**在扇牌上隐藏优先级最低的那条**（第 4 条 = `fr-kw-task`）✓，
     三张牌的扇面视觉就**完全一致**，也不再需要任何"额外的缩放动画" ✓
   ⚠ 只影响**扇牌上**的（JS 只在 `frCardShrink._land` 里加这个类）；
     舞台牌（`--nms`=1）上 4 条**照常全显示** ✓ */
.fr-card-kw .fr-keyword.fr-kw-fanhint{display:none !important;}
/* ★ 英文关键词：两层环要**收细**（用户反馈："英文的字，中间还是很细"）——
   根因不是描边顺序（`.fr-keyword::before` 本来就是 z-index:-1 的外轮廓 ✓），
   而是**环的粗细是照中文调的**：
     · 中文 900 字重的横竖笔画很粗（34.9px 时约 3.5~4px）→ 环 5.4px 还压得住 ✓
     · 拉丁衬线字的竖笔细得多（英文主关键词被 frFitKeyword 缩到 **24.7px**，竖笔仅约 2~2.5px）✗
       → 而白环可见厚 3.6px + 金环 1.8px = **5.4px，比笔画还粗一倍** ✗ → 字面只剩细细一条 ✗
   → 英文下把环整体收到约一半，字面就"站得住"了 ✓（`html[lang="en"]` 由 frApplyI18n 设置 ✓）
   ⚠ 环半径由 frFitKeyword 按 k 缩放，但它读的是**计算值** → 会以这里的新基准再缩 ✓
     ⚠ 2026-10-09 改：JS 不再写 inline 半径了 → 这里必须**显式乘** `--kwf1`（自身倍率）
       与 `--kwfit`（整组倍率），否则英文档的环会**不跟着字号缩** ✗（与中文档观感不一致）
   ⚠ 选择器必须带上 `.fr-card-kw`（0-4-1）—— 否则会被上面那条分档修复（0-3-0）盖回去 ✗
   ⚠ 中文完全不受影响（选择器带 lang 限定）✓ */
html[lang="en"] .fr-card-kw .fr-kw-main {--kw-r1:calc(1.00px * var(--kwfit,1) * var(--kwf1,1)); --kw-r2:calc(3.00px * var(--kwfit,1) * var(--kwf1,1))}
html[lang="en"] .fr-card-kw .fr-kw-sub  {--kw-r1:calc(0.90px * var(--kwfit,1) * var(--kwf1,1)); --kw-r2:calc(2.55px * var(--kwfit,1) * var(--kwf1,1))}
html[lang="en"] .fr-card-kw .fr-kw-tag  {--kw-r1:calc(0.80px * var(--kwfit,1) * var(--kwf1,1)); --kw-r2:calc(2.30px * var(--kwfit,1) * var(--kwf1,1))}
html[lang="en"] .fr-card-kw .fr-kw-task {--kw-r1:calc(0.65px * var(--kwfit,1) * var(--kwf1,1)); --kw-r2:calc(2.00px * var(--kwfit,1) * var(--kwf1,1))}
html[lang="en"] .fr-card-kw .fr-kw-label{--kw-r1:calc(0.60px * var(--kwfit,1) * var(--kwf1,1)); --kw-r2:calc(1.80px * var(--kwfit,1) * var(--kwf1,1))}
/* ★ 英文关键词改用**拉丁字距**（用户：以英文版效果优先，主市场美国）——
   原来各档字距是照中文定的（tag `.3em`、sub `.16em`），中文 2~6 字靠它撑开很好看 ✓；
   但拉丁文案长得多 ✗ → 宽字距把文字撑得更宽，`nowrap` 下会直接溢出牌面 ✗
   （真实池子里就有 21 字符的 `CHANGING CIRCUMSTANCES` ✓；
     ⚠ 注意卡面 tag 用的是 `FLASH_CARD1.status`（短标签 6~16 字符 ✓），
     不是 `STAGE1_LIB_EN`（那个池子只喂对话框里的 `.emotion-kw` ✓））
   → 英文下收到拉丁常规字距 ✓（大写标签仍留一点字距，保留"标签感"） */
html[lang="en"] .fr-kw-tag {letter-spacing:.12em}
html[lang="en"] .fr-kw-tag.in{letter-spacing:.14em}
html[lang="en"] .fr-kw-main{letter-spacing:.05em}
html[lang="en"] .fr-kw-sub {letter-spacing:.06em}
/* ⚠⚠ 这里**不能**用 `overflow-wrap:anywhere/break-word` ✗ ——
   实测会把单词从中间劈开：`FRUSTRATION` → `FRUSTR / ATION`、`LONGING` → `LONG / NG` ✗✗
   用户明确："一个单词没必要分行" ✓ → 保持默认 `overflow-wrap:normal`（**只在词边界换行** ✓）
   `min-width:0` 仍要保留：折行时盒子才收得回容器宽 ✓（否则 flex 子项被最长单词撑开 ✗） */
html[lang="en"] .fr-card-kw .fr-keyword{min-width:0;}

/* ---------- T3a: 小字 caption ---------- */
 .fr-caption{
   position:absolute;
   left:50%;
   transform:translateX(-50%);
   bottom:5vh;
   width:84vw;
   max-width:480px;
   /* 高度 = "牌底 → 屏幕底部"的剩余空间，**但必须留出足够间隙，绝不能顶到牌**。
      公式 = 100vh − bottom(5vh) − [牌顶(14vh) + 牌高] − 90px 间隙 = 81vh − 牌高 − 90px。
      那 90px 不是随便留的：牌的 box-shadow 光晕有 120px 扩散，留 40px 时对话框虽然没真重叠、
      但会压在光晕里，看着就是"顶到牌了" ✗。再套一层 46vh 硬上限，超高屏上也不会长过头。
      ⚠⚠ **这条只服务"窄屏档"（手机）** ✗ —— 平板/桌面另有一套，写在 `@media (min-width:768px)` 里
         （牌 68vh / 框 80vw·25vh−35px）✓。
      ⚠ 牌高在**两处**各出现一次：CSS 的 `.fr-card{height:…}` + JS `frFitCardToViewport()`；
         而 JS 又分 narrow/wide 两支 → **改任何一处都要四边同步**（CSS 基础 / CSS 平板 / JS narrow / JS wide）✗ */
   max-height:min(calc(81vh - min(58vh, 83vw) - 90px), 46vh);
   /* 纵向 flex：正文可滚动，底部提示行 .fr-hook-bar 固定为框内最后一行 */
   display:flex;
   flex-direction:column;
   text-align:center;
   font-family:"Noto Serif TC","Songti TC","STSong","SimSun",serif;
   font-size:clamp(12px, 2.6vh, 14px);
   color:rgba(232,220,255,.82);
   line-height:1.9;
   padding:8px 10px 8px;
   border-radius:12px;
   /* 塔罗风花纹边框：9-slice SVG（圆角双金线 + 边中菱形 + 四角卷曲与菱形）。
      注意 border-image 会忽略 border-radius，所以圆角是画在 SVG 里的；
      border-radius 只用来给底色圆角，两者视觉上要对齐。 */
   border:16px solid transparent;
   border-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='128' height='128' viewBox='0 0 128 128' fill='none'%3E%3Cpath d='M4 44 V24 A20 20 0 0 1 24 4 H44 M32 4 H96 M84 4 H104 A20 20 0 0 1 124 24 V44 M124 32 V96 M124 84 V104 A20 20 0 0 1 104 124 H84 M96 124 H32 M44 124 H24 A20 20 0 0 1 4 104 V84 M4 96 V32' stroke='%23e9c877' stroke-width='2.2'/%3E%3Cpath d='M13 44 V27 A14 14 0 0 1 27 13 H44 M32 13 H96 M84 13 H101 A14 14 0 0 1 115 27 V44 M115 32 V96 M115 84 V101 A14 14 0 0 1 101 115 H84 M96 115 H32 M44 115 H27 A14 14 0 0 1 13 101 V84 M13 96 V32' stroke='%23e9c877' stroke-width='1.2' opacity='.7'/%3E%3Cg fill='%23e9c877'%3E%3Cpath d='M64 2 l3.4 4.6 -3.4 4.6 -3.4 -4.6 z'/%3E%3Cpath d='M64 116 l3.4 4.6 -3.4 4.6 -3.4 -4.6 z'/%3E%3Cpath d='M8 64 l4.6 3.4 -4.6 3.4 -4.6 -3.4 z'/%3E%3Cpath d='M120 64 l4.6 3.4 -4.6 3.4 -4.6 -3.4 z'/%3E%3Cpath d='M26 26 l3.6 4.6 -3.6 4.6 -3.6 -4.6 z'/%3E%3Cpath d='M102 26 l3.6 4.6 -3.6 4.6 -3.6 -4.6 z'/%3E%3Cpath d='M102 102 l3.6 4.6 -3.6 4.6 -3.6 -4.6 z'/%3E%3Cpath d='M26 102 l3.6 4.6 -3.6 4.6 -3.6 -4.6 z'/%3E%3C/g%3E%3Cg stroke='%23e9c877' stroke-width='1.2' fill='none' opacity='.85'%3E%3Cpath d='M9 30 Q9 9 30 9'/%3E%3Cpath d='M98 9 Q119 9 119 30'/%3E%3Cpath d='M119 98 Q119 119 98 119'/%3E%3Cpath d='M30 119 Q9 119 9 98'/%3E%3C/g%3E%3Cg fill='%23e9c877' opacity='.8'%3E%3Ccircle cx='6' cy='6' r='1.6'/%3E%3Ccircle cx='122' cy='6' r='1.6'/%3E%3Ccircle cx='122' cy='122' r='1.6'/%3E%3Ccircle cx='6' cy='122' r='1.6'/%3E%3C/g%3E%3C/svg%3E") 32 / 16px round;
   box-shadow:0 0 34px rgba(120,80,200,.42),0 0 90px rgba(120,80,200,.18),inset 0 0 30px rgba(96,64,170,.22);
   background:linear-gradient(180deg, rgba(26,15,52,.72), rgba(15,9,32,.86));
   -webkit-backdrop-filter:blur(10px);
   backdrop-filter:blur(10px);
   /* 同 .fr-popup：补一个层叠上下文，低端机关掉 backdrop-filter 后层级才一致 ✓ */
   isolation:isolate;
   opacity:0;
   transition:opacity .8s ease;
   z-index:2;
 }
 .fr-caption.in{opacity:1}
 /* 正文区（可滚动）—— 提示行不在里面，所以滚动时提示行始终留在框底 */
 .fr-caption-body{
   flex:1 1 auto;
   min-height:0;
   overflow-y:auto;
   -webkit-overflow-scrolling:touch;
   white-space:pre-line;
   scrollbar-width:thin;
   scrollbar-color:rgba(180,130,255,.35) transparent;
 }
 .fr-cap-line{opacity:0;transform:translateY(8px);transition:opacity .7s ease,transform .7s ease;}
 .fr-cap-line.in{opacity:1;transform:translateY(0)}
 .fr-caption-body::-webkit-scrollbar{width:3px}
 .fr-caption-body::-webkit-scrollbar-thumb{background:rgba(180,130,255,.35);border-radius:3px}
 .fr-caption-body::-webkit-scrollbar-track{background:transparent}

/* ---------- T3a: Reading Progress ---------- */
.fr-progress{
  position:absolute;
  top:4.5vh;
  left:0;right:0;
  display:flex;
  justify-content:center;
  gap:clamp(14px, 5vw, 40px);
  z-index:5;
}
.fr-progress-step{
  display:flex;
  align-items:center;
  gap:8px;
  opacity:.4;
  transition:opacity .5s ease;
}
.fr-progress-check{
  width:22px;height:22px;
  border-radius:50%;
  border:1.5px solid rgba(255,215,0,.6);
  display:flex;
  align-items:center;
  justify-content:center;
  font-size:12px;
  color:#ffd700;
  transform:scale(.6);
  opacity:0;
  transition:transform .45s cubic-bezier(.34,1.56,.64,1), opacity .45s ease, box-shadow .45s ease;
}
.fr-progress-label{
  font-size:clamp(10px, 2.4vh, 13px);
  letter-spacing:.12em;
  text-transform:uppercase;
  color:#cbb8f0;
  white-space:nowrap;
}
.fr-progress-step.done{opacity:1}
.fr-progress-step.done .fr-progress-check{
  transform:scale(1);
  opacity:1;
  box-shadow:0 0 12px rgba(255,215,0,.5);
  animation:frCheckPulse 2.4s ease-in-out 1s infinite;
}
@keyframes frCheckPulse{0%,100%{box-shadow:0 0 12px rgba(255,215,0,.5)}50%{box-shadow:0 0 22px rgba(255,215,0,.85)}}

/* ---------- 底部短 Hook ---------- */
/* 提示行：现在位于 .fr-caption **内部**的最后一行（固定在框底、不随正文滚动），
   不再自己绝对定位在框外面。 */
.fr-hook-bar{
  flex:0 0 auto;
  margin-top:7px;
  padding-top:7px;
  border-top:1px solid rgba(233,200,119,.22);
  text-align:center;
  font-size:clamp(12px, 2.6vh, 14px);
  font-style:italic;
  color:#fff8d6;
  text-shadow:0 0 16px rgba(255,215,0,.35);
  opacity:0;
  transform:translateY(8px);
  transition:opacity .7s ease,transform .7s cubic-bezier(.22,1,.36,1);
}
.fr-hook-bar.in{opacity:1;transform:translateY(0);animation:frHookPulse 2.2s ease-in-out infinite;}
.fr-hook-bar:empty{display:none;}   /* 无文案时不占位、也不显示那条分隔线 */
@keyframes frHookPulse{0%,100%{text-shadow:0 0 16px rgba(255,215,0,.35)}50%{text-shadow:0 0 28px rgba(255,215,0,.75)}}

/* ---------- T3b: Energy Thread ---------- */
.fr-thread{
  position:absolute;
  top:0;left:0;right:0;bottom:0;
  pointer-events:none;
  opacity:0;
  transition:opacity .8s ease;
  /* z-index:0 = 在氛围光斑(0，DOM 更靠前)之上、在 .fr-card-zone(1) 之下。
     ① 必须低于 .fr-card-zone：卡2 飞出来时丝线要从牌**后面**穿过，不能压在牌面上。
     ② 但已经足够高：三张已选中的牌被 _arrangePickedCards 放在 document.body(9500~9502)，
        它们在舞台**之外**，而整个舞台是 z-index:999999 —— 所以舞台内任何 z-index
        都在那三张牌之上，丝线不会被牌挡住。
     ③ 亮度不靠 z-index：舞台背景遮罩已在牌那一排挖了透光洞（见 .fr-stage）。 */
  z-index:0;
  will-change:opacity;
}
.fr-thread.active{opacity:1}
.fr-thread svg{width:100%;height:100%;display:block}

/* ---------- T3b: 弹窗三态 ---------- */
.fr-popup{
  position:absolute;
  top:0;left:0;right:0;bottom:0;
  display:flex;                  /* 常驻布局，靠 opacity/visibility 切换 → 可以过渡 */
  opacity:0;
  visibility:hidden;
  transition:opacity 1.2s cubic-bezier(.4,0,.55,1), visibility 0s linear 1.2s;
  flex-direction:column;
  align-items:center;
  justify-content:center;
  /* 深色半透明底：比之前更深、更不透明（.5 → .3 那次调过头了，用户要求"色深一点、透明度高一点"）。
     星系画布是 z-index:-1，画在这层**之上**，所以加深底色不会把星云压没。 */
  background:rgba(2,0,7,.8);
  -webkit-backdrop-filter:blur(3px) saturate(1.1);
  backdrop-filter:blur(3px) saturate(1.1);
  /* ⚠ `backdrop-filter` 会创建层叠上下文 —— 低端机把它关掉后（见文件末尾 low-perf 段）
     层叠上下文会消失，而 `.fr-popup` 只在 opacity<1 时才自带一个 → 低端/高端两边的层级会不一致 ✗。
     用 `isolation:isolate` 显式补一个（它只建层叠上下文、不产生任何绘制开销）✓ */
  isolation:isolate;
  z-index:10;
}
.fr-popup.active{opacity:1;visibility:visible;transition:opacity 1.2s cubic-bezier(.4,0,.55,1), visibility 0s}
/* 弹窗背景的星系画布。
   ⚠ z-index:-1 是刻意的：它落在**弹窗自己的底色之上、正文之下**
   （绘制顺序 = 背景 → 负 z-index 子元素 → 正常流内容），所以星系不会被那层 .3 的底色压暗。 */
.fr-popup-galaxy{
  position:absolute;
  top:0;left:0;
  width:100%;height:100%;
  display:block;
  opacity:0;
  transition:opacity .9s ease;
  pointer-events:none;
  z-index:-1;
}
.fr-popup.active .fr-popup-galaxy{opacity:1}
/* 右上角"✕ 快速跳過"：只在可跳过的弹窗激活时出现 */
.fr-popup-skip{
  position:absolute;
  top:2.6vh;right:4vw;
  opacity:0;
  visibility:hidden;
  display:inline-flex;   /* 原来靠 .on 时才 display:inline-flex，改成常驻后要补上 */
  align-items:center;
  padding:7px 14px;
  font-family:inherit;
  font-size:clamp(11px,2.2vh,13px);
  letter-spacing:.06em;
  color:rgba(255,246,220,.86);
  background:rgba(28,14,52,.55);
  border:1px solid rgba(233,200,119,.45);
  border-radius:999px;
  cursor:pointer;
  -webkit-backdrop-filter:blur(6px);
  backdrop-filter:blur(6px);
  transition:opacity .35s ease, visibility 0s linear .35s, background .25s ease, border-color .25s ease, color .25s ease;
  z-index:3;
}
.fr-popup-skip.on{opacity:1;visibility:visible;transition:opacity .35s ease, visibility 0s, background .25s ease, border-color .25s ease, color .25s ease}
.fr-popup-skip:hover,.fr-popup-skip:active{
  background:rgba(46,24,84,.75);
  border-color:rgba(233,200,119,.85);
  color:#fff8d6;
}
/* 颗粒尺寸：用 @property 注册后才能参与过渡（自定义属性默认是"不可插值"的 ✗）。
   不支持 @property 的老浏览器会退化成"直接跳变"，但下面的 opacity/blur 仍在过渡，观感依然平滑。 */
@property --fr-grain{
  syntax:'<length>';
  inherits:false;
  initial-value:0px;
}
/* 各环节（message / silhouette / whiteboard）**叠在同一位置**，靠 opacity + transform 交叉淡入淡出。
   ⚠ 原来用 display:none ↔ flex 切换 —— 新环节能淡入，但**旧环节是瞬间消失**的 ✗，
   两环节之间就会"跳"一下。改成常驻布局 + 过渡后，旧的一边淡出、新的一边淡入，衔接自然。
   visibility 的过渡延迟到淡出结束（`0s linear .6s`）—— 否则元素会在淡出过程中就被隐藏 ✗。 */
.fr-popup-state{
  position:absolute;
  top:0;left:0;right:0;bottom:0;
  display:flex;
  flex-direction:column;
  align-items:center;
  justify-content:center;
  text-align:center;
  padding:24px;
  opacity:0;
  visibility:hidden;
  /* 颗粒遮罩：**只留点**（点半径 = --fr-grain）。
     ⚠ 之前是反过来"打孔吃掉内容"——那样到最大时内容就**全没了**，粒子根本看不见、没法"漂浮" ✗。
     现在点半径大到互相重叠 = 实心；变小 = 碎成一颗颗可见的颗粒 ✓。 */
  --fr-grain:5.5px;
  -webkit-mask-image:radial-gradient(circle at 50% 50%, #000 var(--fr-grain), transparent calc(var(--fr-grain) + .6px));
  mask-image:radial-gradient(circle at 50% 50%, #000 var(--fr-grain), transparent calc(var(--fr-grain) + .6px));
  -webkit-mask-size:7px 7px;
  mask-size:7px 7px;
  opacity:0;
  filter:blur(9px) brightness(1.5);
  transform:translateY(-16px) scale(1.08);
  /* ⚠ 这里（基础态）的 transition 是**退场**时长 —— 用户要求"退出时缓慢" → 拉到 1.1~1.3s ✓ */
  transition:opacity 1.1s cubic-bezier(.4,0,.6,1), filter 1.2s cubic-bezier(.4,0,.6,1),
             transform 1.25s cubic-bezier(.4,0,.6,1),
             --fr-grain 1.2s ease, mask-size 1.2s ease, visibility 0s linear 1.25s;
  pointer-events:none;
}
/* —— 实心态：点半径大到互相重叠 → 遮罩等于没有，内容完整清晰 —— */
.fr-popup-state.active{
  opacity:1;
  visibility:visible;
  --fr-grain:5.5px;
  -webkit-mask-size:7px 7px;
  mask-size:7px 7px;
  filter:blur(0) brightness(1);
  transform:none;
  /* ⚠ 这里的 transition 是**进场**时长 —— 用户要求"进来时有力" → 短、带轻微过冲 ✓ */
  transition:opacity .34s cubic-bezier(.22,1.2,.36,1), filter .38s cubic-bezier(.22,1.1,.36,1),
             transform .42s cubic-bezier(.2,1.25,.35,1),
             --fr-grain .4s cubic-bezier(.22,1.15,.36,1), mask-size .4s cubic-bezier(.22,1.15,.36,1), visibility 0s;
  pointer-events:auto;
}
/* ---------- 三段式：散开 → 颗粒停留漂浮 → 汇聚成下一张 ----------
   · .leaving  = 正在离开的旧环节
   · .entering = 正在进入的新环节（延迟 .18s 起步，于是"旧的在漂、新的也开始漂"，粒子看起来是连续的）
   两条关键帧互为镜像，中间那段（~25%~60%）都是"稀疏颗粒在漂"，这就是"粒子继续存在漂浮"的那一拍。 */
.fr-popup-state.leaving{animation:frStateScatter 1.5s cubic-bezier(.4,0,.6,1) forwards}   /* 退：更缓慢 ✓ */
.fr-popup-state.entering{animation:frStateAssemble .72s cubic-bezier(.16,1,.3,1) .06s backwards}   /* 进：更快更有力 ✓ */
@keyframes frStateScatter{
  /* 颗粒放大到 2.4~2.6px（原来是 1.2~1.8，太小看不出"颗粒"）；
     位移改用 **scale**（而不是 translateY）—— scale 是"整体向外扩"，
     配上 mask-size 变大（点阵间距拉开），看起来就是**从中心向外扩散** ✓ */
  0%  {opacity:1;   --fr-grain:5.5px; mask-size:7px 7px;   filter:blur(0)   brightness(1);   transform:scale(1)}
  22% {opacity:.95; --fr-grain:2.7px; mask-size:15px 15px; filter:blur(1.4px) brightness(1.45);transform:scale(1.16)}
  55% {opacity:.78; --fr-grain:2.5px; mask-size:22px 22px; filter:blur(2.6px) brightness(1.55);transform:scale(1.34)}
  100%{opacity:0;   --fr-grain:2.1px; mask-size:30px 30px; filter:blur(6px)   brightness(1.6); transform:scale(1.5)}
}
@keyframes frStateAssemble{
  /* 与上面互为镜像：从"扩到 1.5 倍的稀疏颗粒"**向内收拢**，越收越密、越收越实 */
  0%  {opacity:0;   --fr-grain:2.1px; mask-size:30px 30px; filter:blur(6px)   brightness(1.6); transform:scale(1.5)}
  22% {opacity:.5;  --fr-grain:2.5px; mask-size:22px 22px; filter:blur(2.6px) brightness(1.55);transform:scale(1.34)}
  68% {opacity:.95; --fr-grain:3.0px; mask-size:12px 12px; filter:blur(1.2px) brightness(1.22);transform:scale(1.1)}
  100%{opacity:1;   --fr-grain:5.5px; mask-size:7px 7px;   filter:blur(0)   brightness(1);   transform:scale(1)}
}
@keyframes fr-popup-in{
  from{opacity:0;transform:scale(.94)}
  to{opacity:1;transform:scale(1)}
}
/* 态1: A Message For You 大字卡 */
.fr-popup-title{
  font-size:clamp(12px, 3vh, 15px);
  letter-spacing:.4em;
  text-transform:uppercase;
  color:#b8a6e8;
  margin-bottom:20px;
}
.fr-popup-text{
  font-size:clamp(20px, 5.5vh, 32px);
  font-weight:600;
  color:#fff8d6;
  line-height:1.5;
  max-width:72vw;
  text-shadow:0 0 20px rgba(255,215,0,.3);
}
.fr-popup-star{
  font-size:22px;
  color:#ffd700;
  margin-top:24px;
  animation:fr-star-pulse 2s ease-in-out infinite;
}
@keyframes fr-star-pulse{
  0%,100%{opacity:.5;transform:scale(.9)}
  50%{opacity:1;transform:scale(1.15)}
}
/* 态2: 双人剪影 + 光链 */
.fr-silhouette-wrap{
  display:flex;
  align-items:flex-end;
  margin-bottom:28px;
}
/* 人物剪影：整组用**一张** SVG（形状来自设计给的图，695×690）。
   原图是纯黑填充 —— 在深色背景上看不见，这里换成主题紫渐变（见 HTML 里的 frSilPair）。
   丝线**绝对定位**叠在两人身体之间的空隙上（原图那条缝 x≈264~423，占 695 的 38%~61%）。 */
.fr-sil-pair{
  position:relative;
  height:clamp(150px, 34vh, 236px);
  aspect-ratio:695 / 690;
}
.fr-sil-pair > svg{
  display:block;width:100%;height:100%;
  filter:drop-shadow(0 0 16px rgba(180,130,255,.42)) drop-shadow(0 0 44px rgba(140,90,220,.26));
}
.fr-sil-pair .fr-silhouette-link{
  position:absolute;
  left:38.5%;
  top:47%;
  width:22%;
  height:clamp(18px, 4.5vh, 26px);
  transform:translateY(-50%);
}
.fr-sil-pair .fr-silhouette-link svg{display:block;width:100%;height:100%;overflow:visible}
/* 两人之间联系的"特效层"（原版只有一个 ::after 光点，太简单，已换掉）：
   ① 沿丝线扫过的流动高光  ② 一串粒子（5 颗错开延迟）  ③ 两颗升起的小心心 */

/* ① 流动高光：一段亮线沿丝线扫过（dashoffset 动画） */
.fr-th-flow{
  stroke-dasharray:20 100;
  animation:frThDash 2.6s linear infinite;
}
@keyframes frThDash{ from{stroke-dashoffset:120} to{stroke-dashoffset:0} }

/* ② 粒子流：5 颗小光点沿丝线（近似波浪路径）从左流到右。
   用 left% + translateY 近似丝线的起伏 —— 这样能跟着容器宽度自适应，
   不用写死 px 的 offset-path（连线宽度是 clamp 的，写死会跑出界）。 */
.fr-th-p{
  position:absolute;
  top:50%;
  left:0;
  width:3.2px;height:3.2px;
  margin:-1.6px 0 0 -1.6px;
  border-radius:50%;
  background:#fff8e6;
  box-shadow:0 0 6px rgba(255,240,200,.95), 0 0 14px rgba(255,226,160,.6);
  opacity:0;
  animation:frThFlow 2.6s linear infinite;
}
@keyframes frThFlow{
  0%  {left:0%;  transform:translateY(0)    scale(.55); opacity:0}
  12% {opacity:1}
  25% {left:25%; transform:translateY(-6px) scale(1)}
  50% {left:50%; transform:translateY(3px)  scale(1.18); opacity:1}
  75% {left:75%; transform:translateY(-6px) scale(1)}
  88% {opacity:1}
  100%{left:100%;transform:translateY(0)    scale(.55); opacity:0}
}

/* ③ 小心心已按用户要求去掉（"连线上不要出现心型"），原来的 .fr-th-heart / frThHeart 一并删除 */
/* @keyframes fr-link-flow 已随旧版"单光点"一起废弃（旧动画体也一并删干净 ——
   ⚠ 上次只删到第一个 `}`，残留的 `15%{…}` 等孤立块把**紧跟其后的 .fr-silhouette-text 规则整个吞掉**了，
   导致剪影下方文字退回继承色（黑），在深色底上"消失"。） */
.fr-silhouette-text{
  /* 原来 clamp(14px,3.6vh,18px) 且没加粗，在深色底上偏"轻" → 调明显一点 */
  font-size:clamp(17px, 4.4vh, 22px);
  font-weight:600;
  color:#f2eaff;
  max-width:76vw;
  line-height:1.6;
  letter-spacing:.01em;
  text-shadow:0 0 16px rgba(200,170,255,.45), 0 2px 10px rgba(0,0,0,.6);
}
/* 态3: 白板三步动态打勾 */
.fr-whiteboard-step{
  display:flex;
  align-items:center;
  gap:12px;
  /* ⚠ 三行文字长短不一。父级是 align-items:center，若让每行按"自身内容宽度"居中，
     各行的起始 x 就都不一样 → 左边的圆圈互相错开、竖线也对不上 ✗。
     给行一个**统一宽度**后，三行左边缘对齐，圆圈自然竖成一条线，
     竖线（margin-left:12px，宽 2px → 中心 13px）也正好落在 26px 圆圈的中心上 ✓。 */
  width:min(94vw, 384px);
  /* ⚠ 行基础态（未点亮）= .55 —— 必须与 `#fr-wb-step-3{opacity:.55}` **完全一致**，
     否则"第1行亮起时"第2行是 .35、第3行是 .55，两行深浅不一样 ✗（用户反馈）*/
  opacity:.55;
  transition:opacity .5s ease;
}
.fr-wb-check{
  /* 参考图那种"大号实心对勾圆"：26 → 30px，加一层径向填充 + 外发光 */
  width:30px;height:30px;
  border-radius:50%;
  border:2px solid rgba(255,215,0,.7);
  background:radial-gradient(circle at 34% 28%, rgba(255,244,190,.42), rgba(255,190,60,.14) 70%);
  display:flex;
  align-items:center;
  justify-content:center;
  font-size:16px;
  color:#ffe9a0;
  /* ⚠ 未点亮的圆圈原来是 opacity:0（**完全看不见**）——
     但第三步的淡环是 opacity:.3，于是"还没轮到的那一步"左边空一块，
     看起来像少画了一个圆 ✗（用户截图圈出来的就是这个）。
     改成与第三步一致的**淡环**，三行左边就都有圆了 ✓；点亮时再放大变实 ✓ */
  transform:scale(.9);
  opacity:.55;
  transition:transform .45s cubic-bezier(.34,1.56,.64,1), opacity .45s ease, box-shadow .45s ease;
  flex-shrink:0;
}
.fr-wb-text{
  /* 原来 clamp(14px,3.8vh,19px) 且没加粗 → 调明显一点 */
  /* 23px 时「第一張牌，看見了起因：岔路口」(14 字)放不下一行会折行 —— 收到 21px 并把行加宽：
     14 字 × 21px ≈ 294px + 圆圈 26 + 间距 12 = 332px < 384px → 每步一行 ✓ */
  font-size:clamp(16px, 4.2vh, 21px);
  font-weight:600;
  color:#fff8d6;
  text-align:left;
  line-height:1.5;
  text-shadow:0 0 14px rgba(255,226,150,.4), 0 2px 10px rgba(0,0,0,.55);
}
/* ---------- 第三步专属：圆圈先亮 → 字随后亮 → 停顿 → 化成烟消失 ----------
   ⚠ 前两步是"整行一起点亮"（.done 改的是**行**的 opacity）——
   第三步要"圆圈和文字先后亮"，就不能再靠行的 opacity（它会同时影响两者）✗，
   所以第三步把行本身设为不透明，改用**圆圈/文字各自的颜色与透明度**分别控制。 */
/* ⚠ 用户反馈："中间那步的圆看着像没有，最下面第三条却很清晰"。
   真正原因不是透明度，而是**里面的字符不同**：
     第3步的字符是 "○"（本身就是个粗圆环）→ 即使 opacity .3 也很清楚 ✓
     第1/2步的字符是 "✓"（细笔画）→ 在 .3 下几乎看不见，只剩一圈很淡的边框 ✗
   所以未点亮的第 1/2 步：**把对勾隐掉、只留圆环**，并把圆环提亮到与第三步同一观感。 */
#fr-wb-step-1:not(.done) .fr-wb-check,
#fr-wb-step-2:not(.done) .fr-wb-check{
  color:transparent;                    /* 藏掉细对勾（对勾只在点亮后出现）*/
  opacity:.55;                          /* .3 → .55：圆环看得清，不再"像没有" */
  border-color:rgba(255,215,0,.6);
}
/* 再补一个 "○" 字符 —— 第三步之所以最清楚，一半功劳在它那个"○"本身。
   补上之后，未点亮的第 1/2 步与第 3 步**完全同一观感** ✓ */
#fr-wb-step-1:not(.done) .fr-wb-check,
#fr-wb-step-2:not(.done) .fr-wb-check{
  /* ⚠ 用户反馈"第二步的圈不在正中" ✓ 根因就在这里：
     里面的 ✓ 只是 color:transparent（**仍然占位** ✗），而 ::after 又补了一个 ○
     → 两个字符并排 → ○ 被挤到右边、看起来不在圈中心 ✗（第三步用的是真 ○，所以它正常 ✓）
     修法：把隐藏的 ✓ 的**字号归零**（不占位 ✗），::after 自己带 16px 字号 ✓ */
  font-size:0;
}
#fr-wb-step-1:not(.done) .fr-wb-check::after,
#fr-wb-step-2:not(.done) .fr-wb-check::after{
  content:'○';
  color:rgba(255,233,160,.9);
  font-size:16px;
  line-height:1;
  display:block;      /* 块级 + 容器 flex 居中 → 精确居中 ✓ */
}
/* ⚠ 第 3 步原来走的是**另一套机制**：整行 opacity:1 + 文字用 .32 透明度 ✗
   → 未点亮时"第2行 = .55、第3行 = 1×.32 = .32"，两行深浅不一样 ✗（用户反馈）
   现在统一成与第 1/2 步同一套：**整行 .55 + 文字实色**，亮起时再把整行推到 1 ✓ */
#fr-wb-step-3{opacity:.55}
#fr-wb-step-3 .fr-wb-check{
  opacity:.55;
  transform:scale(.9);
  transition:opacity .5s ease, transform .5s cubic-bezier(.22,1,.36,1),
             box-shadow .5s ease, border-color .5s ease;
}
#fr-wb-step-3 .fr-wb-text{
  color:#fff8d6;                        /* 实色（原 rgba(...,.32)）*/
  transition:color .6s ease, text-shadow .6s ease;
}
/* 文字亮起时整行也到 1（与第1/2步的 .done 对齐 ✓）*/
#fr-wb-step-3.text-on{opacity:1}
/* ① 圆圈先亮 */
#fr-wb-step-3.circle-on .fr-wb-check{
  opacity:1;
  transform:scale(1);
  border-color:rgba(255,215,0,.95);
  box-shadow:0 0 16px rgba(255,215,0,.75), 0 0 34px rgba(255,215,0,.35);
}
/* ② 字随后亮 */
#fr-wb-step-3.text-on .fr-wb-text{
  color:#fff8d6;
  text-shadow:0 0 14px rgba(255,226,150,.55);
}
/* ③ 化成烟消失：**整个白板**（三步 + 两条竖线）一起散掉，不是只有第三步。
   挂在容器上，所以连竖线也跟着走。 */
#fr-popup-whiteboard.smoke{animation:frWbSmoke .95s cubic-bezier(.4,0,.7,1) forwards}
@keyframes frWbSmoke{
  0%  {opacity:1;filter:blur(0) brightness(1);transform:translateY(0) scale(1)}
  35% {opacity:.7;filter:blur(5px) brightness(1.35);transform:translateY(-5px) scale(1.02)}
  100%{opacity:0;filter:blur(16px) brightness(1.6);transform:translateY(-16px) scale(1.07)}
}
.fr-whiteboard-step.done{opacity:1}
.fr-whiteboard-step.done .fr-wb-check{
  transform:scale(1);
  opacity:1;
  border-color:rgba(255,232,150,.95);
  background:radial-gradient(circle at 34% 28%, rgba(255,250,215,.95), rgba(255,205,80,.55) 72%);
  color:#3a2400;
  box-shadow:0 0 14px rgba(255,215,110,.75), 0 0 30px rgba(255,190,60,.4);
}
.fr-whiteboard-line{
  /* ⚠ 竖线是白板容器的**直接子项**，父级 align-items:center 会把它整条居中
     （实测圆心在 48.4，竖线却跑到 202.5 ✗）。
     所以让竖线也"占一行宽" → 与 .fr-whiteboard-step 同样的居中基准；
     真正的 2px 竖线用 ::before 画在 left:12px —— 26px 圆圈中心是 13px，2px 线中心也是 13px ✓ */
  position:relative;
  width:min(94vw, 384px);
  height:26px;
  margin:6px 0;
  background:none;
}
.fr-whiteboard-line::before{
  content:'';
  position:absolute;
  /* 30px 圆 → 中心 15px；3px 粗线 → 左边 13.5px，中心正好 15px ✓ */
  left:13.5px; top:0; bottom:0;
  width:3px;
  border-radius:2px;
  background:linear-gradient(180deg, rgba(255,226,140,.9), rgba(255,196,70,.45));
  box-shadow:0 0 10px rgba(255,205,90,.45);
}

/* ---------- T3c: 最终总结卡 ---------- */
.fr-summary{
  position:absolute;
  top:50%;
  left:50%;
  transform:translate(-50%,-50%);
  display:none;
  flex-direction:column;
  align-items:center;
  z-index:8;
  width:86vw;
  max-width:440px;
  background:rgba(10,4,24,.78);
  border:1px solid rgba(255,215,0,.25);
  border-radius:20px;
  padding:clamp(24px, 6vh, 44px) clamp(20px, 6vw, 48px);
  box-shadow:0 0 60px rgba(180,130,255,.25);
  text-align:center;
}
.fr-summary.active{
  display:flex;
  animation:fr-summary-in .8s cubic-bezier(.22,1,.36,1);
}
@keyframes fr-summary-in{
  from{opacity:0;transform:translate(-50%,-50%) scale(.94)}
  to{opacity:1;transform:translate(-50%,-50%) scale(1)}
}
.fr-summary-title{
  font-size:clamp(16px, 4.2vh, 22px);
  letter-spacing:.25em;
  text-transform:uppercase;
  color:#fff8d6;
  margin-bottom:14px;
}
.fr-summary-stars{
  color:#ffd700;
  font-size:clamp(18px, 4.6vh, 26px);
  letter-spacing:.3em;
  margin-bottom:18px;
  text-shadow:0 0 14px rgba(255,215,0,.6);
}
.fr-summary-row{
  display:flex;
  justify-content:space-between;
  align-items:baseline;
  gap:12px;
  padding:10px 0;
  border-top:1px solid rgba(255,255,255,.08);
  text-align:left;
}
.fr-summary-label{
  font-size:clamp(10px, 2.6vh, 13px);
  letter-spacing:.18em;
  text-transform:uppercase;
  color:#b8a6e8;
  flex-shrink:0;
}
.fr-summary-value{
  font-size:clamp(13px, 3.4vh, 17px);
  color:#fff8d6;
  font-weight:600;
  text-align:right;
}

/* ---------- T3c: Pro 推荐 ---------- */
.fr-pro-rec{
  position:absolute;
  left:50%;
  transform:translateX(-50%);
  bottom:5vh;
  width:88vw;
  max-width:400px;
  display:none;
  flex-direction:column;
  align-items:center;
  gap:12px;
  z-index:9;
}
.fr-pro-rec.active{
  display:flex;
  animation:fr-prorec-in .8s cubic-bezier(.22,1,.36,1);
}
@keyframes fr-prorec-in{
  from{opacity:0;transform:translateX(-50%) scale(.94)}
  to{opacity:1;transform:translateX(-50%) scale(1)}
}
.fr-pro-rec-text{
  font-size:clamp(13px, 3.4vh, 16px);
  color:#e8dcff;
  text-align:center;
  line-height:1.5;
}
.fr-pro-rec-buttons{
  display:flex;
  flex-direction:column;
  gap:10px;
  width:100%;
}
.fr-pro-rec-btn{
  width:100%;
  padding:13px 16px;
  background:rgba(31,7,42,.85);
  border:1px solid rgba(255,215,0,.35);
  border-radius:12px;
  color:#fff8d6;
  font-size:clamp(13px, 3.2vh, 15px);
  cursor:pointer;
  transition:border-color .3s ease, box-shadow .3s ease, transform .3s ease;
  -webkit-tap-highlight-color:transparent;
}
.fr-pro-rec-btn:hover,
.fr-pro-rec-btn:active{
  border-color:#ffd700;
  box-shadow:0 0 18px rgba(255,215,0,.35);
  transform:translateY(-1px);
}

/* ---------- 移动端 375px 微调 ---------- */
/* ---------- PC / 宽屏 / **平板**布局 (防止关键词与 caption 叠加) ---------- */
/* ★★ 2026-10-08 平板适配（用户："iPad 上牌和对话框都很小，看不清"）★★
   两个根因：
   ① 布局：`frFitCardToViewport()` 原来拿 `#dialog-panel` 的**矩形高度**当占位（76% 屏高 ✗），
      而那个面板平时藏在屏幕外（`translateY(100%)`）→ 牌被一路挤到 280px 下限 ✗
      （实测 iPad 牌只占屏高 24%、桌面 19%；`.fr-card{height:min(54vh,78vw)}` 从未生效 ✗）
      → 已在 JS 侧修：没 `.show` 时改用实际可见的 `.fr-caption` 高度 ✓
   ② 字号：下面这些 clamp 的**上限是按手机定的**（正文上限只有 14px ✗，iPad 上 2.6vh=31px 被卡死）
      而且这一档原来把关键词上限**往下调**（26→24、38→36 ✗）—— 那是为"小牌"调的
      → 牌变大后必须整体抬高 ✓ */
@media (min-width:768px){
  /* ★ 2026-10-08 平板/桌面档重调（用户："iPad 上牌和文字框都很小，看不清" + "文字框两边宽一点、高一点，卡牌放大"）
     ⚠ 这里的 min(...) 必须与 `frFitCardToViewport()` 的 `wide` 分支**保持一致**（共 2 处）✗
     旧值 54vh/78vw 是按"牌要为屏幕外面板让位"调的 → 牌被压到 280px 下限 ✗；
     现在牌不再为屏幕外的面板让位，改成 67vh / 100vw（= 牌宽 ≤ 60% 屏宽）✓
     ⚠ 为什么不是 80vh？—— 牌与对话框之间必须留 **≥90px 间隙**（牌的 box-shadow 光晕有 120px 扩散，
       留 40px 时对话框会压在光晕里，看着就是"顶到牌了" ✗，见基础规则那段注释）。
       iPad 1180 的纵向预算：padTop 2.5vh(30) + 牌 67vh(791) + 间隙 90 + 框 22vh−20px(240) + 底距 2.5vh(30) = 1181 ✓ 刚好铺满
       → 若把牌推到 80vh(944)，间隙就只剩 20px ✗✗（实测过 53px 就已经很难看了） */
  .fr-card{height:min(67vh, 100vw)}
  .fr-card-zone{padding-top:16vh}
  /* 关键词已挪进牌内，不再需要按牌高推算 padding-top */
  .fr-card-kw{gap:calc(1.2vh * var(--nms,1) * var(--kwfit,1))}
  /* 对话框正文 / 底部 hook：上限 14px → 26px（iPad 上 2.2vh≈26px ✓ 明显好读）*/
  .fr-caption-body,
  .fr-hook-bar{font-size:clamp(15px, 2.2vh, 26px)}
  /* 牌内关键词：上限抬高（原来这一档反而是往下压的 ✗）
     ⚠⚠ 2026-10-09：**必须同样乘 `--nms`/`--kwfit`** ✗ ——
       这一档原本漏了乘数 → 在平板/桌面档（≥768px）上，扇牌的关键词**不会跟着牌缩小** ✗
       （扇牌关键字号仍是舞台上那么大 → 胀出小牌，与"三张牌一致"的目标相悖 ✗）✓ */
  .fr-kw-tag{font-size:calc(clamp(18px, 2.8vh, 30px) * var(--kwfit,1) * var(--kwf1,1))}
  .fr-kw-main{font-size:calc(clamp(30px, 4.4vh, 50px) * var(--kwfit,1) * var(--kwf1,1))}
  .fr-kw-sub{font-size:calc(clamp(22px, 3.6vh, 40px) * var(--kwfit,1) * var(--kwf1,1))}
  .fr-kw-task{font-size:calc(clamp(14px, 2.4vh, 24px) * var(--kwfit,1) * var(--kwf1,1))}
  /* 牌名：上限 15px 在大屏上太小 ✗ */
  .fr-card-name{font-size:clamp(14px, 2.2vh, 24px)}
  /* ★ 2026-10-08 对话框：加宽 + 加高（用户："两边宽一点减少换行，也可以高一点"）
     ⚠ 元凶是基础规则里的 `max-width:480px` ✗ —— iPad 820 宽下它把框锁在 **480px（58%）**，
       左右各空 170px，英文被迫频繁折行 ✗
     → 宽度改 `80vw`（iPad 656px ✓，左右各留 10%）+ `max-width:720px` 兜住桌面（2560 宽屏上不会拉成一行超长）✓
     ⚠ 宽度一变，`frMaxLenFor()` 会按新的 clientWidth 重算每行字数 → 换行自动变少 ✓（不用手改阈值）
       iPad 实测：每行 27 → **36 字**（+33%）✓
     → 高度 217px → **约 240px**（iPad）：`22vh − 20px`
     ⚠ 这个 22vh 不是随便定的：它和上面的"牌 67vh + 间隙 90px"是同一笔预算（见那段的算式）✗
       调大它 → 间隙就变小 → 对话框会压进牌的光晕里 ✗ */
  .fr-caption{
    bottom:2.5vh;
    width:80vw;
    max-width:720px;
    max-height:min(calc(22vh - 20px), 30vh);
  }
}

@media (max-width:420px){
  .fr-card{height:min(58vh, 87vw)}   /* 手机档：随比例补偿（74→87） */
  .fr-card-zone{padding-top:14vh}
   .fr-caption{bottom:5vh; max-height:min(calc(81vh - min(58vh, 87vw) - 90px), 46vh)}   /* 手机：同上 */
   .fr-progress{gap:10px}
  .fr-progress-label{letter-spacing:.06em}
}

/* 白板这一步要「每步一行」：左右内边距收窄 */
.fr-popup-whiteboard{padding-left:10px;padding-right:10px}

/* ============================================================================
   新环节：能量连通（三步流程 → 本环节 → 光柱）
   进度条 + 两只手缓慢靠近/分开，指尖相碰时闪电光火花
   ============================================================================ */
.fr-popup-energy{
  display:flex; flex-direction:column; align-items:center; justify-content:center;
  gap:3.2vh;
  /* ⚠ 整体上移：让**两只手正好落在背景爱心的中心** ✓
     实测（393x852）：爱心视觉中心在弹窗的 43%，双手原本在 56% → 需上移 ~13% 弹窗高 ≈ 14.5vh。
     ⚠ **不能用 transform** —— `.fr-popup-state.active{transform:none}`（0,2,0）会把它覆盖掉 ✗，
       而且那条 transform 是入场/退场的"颗粒汇聚"过渡、动不得 ✗。
     正解：加**底部内边距** —— 内容在 padding 盒里居中，底留 X 就等于整体上移 X/2 ✓ */
  /* ⚠ 底部留白 -6vh：因为底部那行加了 6vh 上边距（整列变高 → 居中会把内容整体顶上去 ✗）。
     减掉等量留白后：底部那行净下移 6vh ✓，而双手/进度条位置不变 ✓ */
  padding:24px 20px calc(24px + 23vh);
}
/* ---- 进度条（按参考图：**粗、短、不满**，末端留一点并持续颤动）---- */
.fr-en-barwrap{width:min(88vw, 420px); display:flex; flex-direction:column; align-items:center}
.fr-en-bar{
  width:58%;                       /* 短：不铺满整行 ✓ */
  height:17px;                     /* 粗 ✓ */
  border-radius:9px; overflow:hidden;
  background:rgba(255,255,255,.09);
  border:1.5px solid rgba(255,215,0,.34);
  box-shadow:inset 0 0 12px rgba(0,0,0,.55), 0 0 14px rgba(255,190,60,.16);
}
.fr-en-fill{
  position:relative; width:0%; height:100%; border-radius:9px;
  background:linear-gradient(90deg, rgba(160,110,255,.9), rgba(255,205,70,.96) 62%, #fff3c0);
  box-shadow:0 0 14px rgba(255,215,0,.8), 0 0 30px rgba(255,190,60,.45);
  transition:width 1.5s cubic-bezier(.25,.8,.35,1);
}
/* 末端光点：让"差一点点"有实感，配合 JS 的颤动 */
.fr-en-tip{
  position:absolute; right:-2px; top:50%; width:14px; height:14px; margin-top:-7px;
  border-radius:50%;
  background:radial-gradient(circle, #fff 0%, #fff6c8 35%, rgba(255,205,80,.5) 62%, transparent 78%);
  filter:drop-shadow(0 0 8px rgba(255,235,150,.95));
}
/* ---- 大号百分比（参考图的主角）---- */
.fr-en-pct{
  margin-top:2px; line-height:.98;
  font-size:clamp(40px, 10.5vh, 62px); font-weight:800;
  letter-spacing:.01em;
  color:#fff3c0;
  background:linear-gradient(180deg,#fffbe4 8%, #ffe08a 46%, #f7b733 100%);
  -webkit-background-clip:text; background-clip:text;
  -webkit-text-fill-color:transparent;
  filter:drop-shadow(0 2px 10px rgba(255,190,60,.55));
}
.fr-en-pct span{font-size:.5em; font-weight:700; margin-left:2px}
.fr-en-title{
  margin-top:10px; font-size:clamp(13px, 3.3vh, 16px); font-weight:700;
  color:#fff8d6; letter-spacing:.06em;
  text-shadow:0 0 12px rgba(255,215,0,.45);
}
.fr-en-sub{
  margin-top:4px; font-size:clamp(11px, 2.9vh, 13px);
  color:rgba(255,233,160,.66); letter-spacing:.06em;
}
/* ---- 手周围的「波点(halftone)」背景 ----
   参考图那种漫画网点：**多层不同大小的圆点叠在一起** + 径向遮罩向外淡出，
   纯 CSS（radial-gradient 平铺），不用额外图片、也不占绘制性能 ✓
   ⚠ 必须放在 .fr-popup-energy 的 ::before（它是第一个绘制的内容）→ 自动落在所有内容之下 ✓ */
.fr-popup-energy::before{
  content:'';
  position:absolute; left:50%; top:43%;        /* 对准双手所在高度（双手中心也是 43%）*/
  /* ⚠ 范围只包住"手附近"（用户原话）—— 太大就会盖到上面的进度条/百分比文字区 ✗ */
  width:min(96vw, 440px); height:min(96vw, 440px);
  transform:translate(-50%,-50%);
  pointer-events:none;
  /* ⚠ z-index:-2：星系画布（心型星流）是 -1，要让它压在波点**之上** ✓
     （CSS 绘制顺序：元素背景 → 负 z-index 子层(越负越先画) → 正常流内容；
      所以 -2 仍在弹窗底色之上、但位于心型之下 ✓）*/
  z-index:-2;
  background-image:
    radial-gradient(circle, rgba(255,230,160,.66) 1.6px, transparent 2.0px),
    radial-gradient(circle, rgba(255,215,110,.50) 2.8px, transparent 3.2px),
    radial-gradient(circle, rgba(214,170,255,.34) 4.4px, transparent 4.9px);
  background-size: 14px 14px, 24px 24px, 38px 38px;
  background-position: 0 0, 7px 9px, 15px 5px;
  -webkit-mask-image: radial-gradient(circle at 50% 50%, #000 0%, rgba(0,0,0,.70) 30%, rgba(0,0,0,.26) 56%, transparent 74%);
  mask-image: radial-gradient(circle at 50% 50%, #000 0%, rgba(0,0,0,.70) 30%, rgba(0,0,0,.26) 56%, transparent 74%);
  /* 心跳节奏：一周期里"砰—砰"两下（双峰），其余时间静息 → 有搏动感 ✓ */
  animation:frEnDots 1.5s cubic-bezier(.35,0,.65,1) infinite;
}
@keyframes frEnDots{
  0%  {opacity:.5;  transform:translate(-50%,-50%) scale(.985)}
  6%  {opacity:1;   transform:translate(-50%,-50%) scale(1.05)}    /* 砰 ① */
  12% {opacity:.64; transform:translate(-50%,-50%) scale(1.002)}
  19% {opacity:.98; transform:translate(-50%,-50%) scale(1.032)}   /* 砰 ② */
  31% {opacity:.52; transform:translate(-50%,-50%) scale(.99)}
  100%{opacity:.5;  transform:translate(-50%,-50%) scale(.985)}
}
/* ---- 两只手 ---- */
.fr-en-stage{
  position:relative; z-index:1;               /* 手/火花/电弧都在波点之上 ✓ */
  width:min(94vw, 470px);
  /* ⚠ 必须**固定宽高比** —— 原来宽用 min(94vw,470px)、高用 clamp(150px,27vh,235px)，
     两者各自独立 ✗ → 而手的**左右位置按宽算、大小按高算** ✗
     → 宽高比一变（手机 1.606 / 桌面 ≥500px 时 2.000 ✗），指尖间距会从 **-11px 漂到 +90px** ✗
       本地手机看是刚好相触 ✓、服务器桌面看就错开/叠在一起 ✗（用户反馈）
     固定比例后宽高联动，% 定位在宽高上等价 → 任何视口下指尖都保持相触 ✓ */
  aspect-ratio:369 / 230;          /* = 1.6043，与调好的手机端一致 */
  height:auto;
  overflow:hidden;                 /* 手臂延伸到画面外，和参考图一样被裁掉 ✓ */
  /* ⚠ 但 `overflow:hidden` 会在舞台边缘切出**一条硬边** ✗ ——
     手机上舞台离屏幕边只有 ~19px 不明显 ✓，桌面宽屏（舞台 469 vs 屏 1280）就非常扎眼 ✗。
     加左右渐隐遮罩让手臂自然淡出，任何屏宽都不会出现硬切 ✓ */
  -webkit-mask-image:linear-gradient(90deg, transparent 0, #000 16%, #000 84%, transparent 100%);
  mask-image:linear-gradient(90deg, transparent 0, #000 16%, #000 84%, transparent 100%);
}
/* ⚠ 两张手图**没有透明留白**（内容占满整张）：
     左手是「左上 → 右下」，指尖在**右下角**；右手是「右 → 左」，指尖在**左上角**。
   所以必须**对角摆放**（左手在左上、右手在右下），指尖才会在中间相遇 ✓
   —— 并排摆放的话两个矩形会在中间重叠、指尖反而错开 ✗。 */
.fr-en-hand{
  position:absolute; width:auto;
  will-change:transform;
  filter:drop-shadow(0 6px 18px rgba(0,0,0,.5));
}
/* 位置/尺寸按**指尖锚点**反推（实测：左手指尖在自身 (100%,83%)，右手在 (0%,4%)）。
   用户要求"手大一点、移动时不要看到手的边界" → 放大到 hL=58%H / hR=44%H，
   并把基座**往外挪到画面外**（left:-12% / right:-10% / bottom:-4%）：
   于是手臂末端落在舞台外（左手 x=-43、右手 x=397 > 舞台宽 361）→ 移动时也看不到图片边界 ✓，
   而两指尖仍在舞台中心附近相遇（间隙 21px，靠移动收拢）✓ */
/* ⚠ 实测舞台是 369x230（94vw x 27vh @393x852），按此重算：
   右手要"末端仍在画面外"（right 至少 -12px，移动 12px 后也不露边）
   又要"指尖尽量靠中线"→ 只能把右手**放大到 h=45%**（臂更长 → 指尖更靠左）✓
   结果：左指尖 x=159、右指尖 x=170 → **间距 11px**（原来 40px ✗）*/
/* ⚠ 目标：两指尖**真正贴上**（用户连说两次"太远"）。
   实测舞台 369x230；约束仍是"右臂末端移动后也不露边"（right ≤ -13px）→ 右手放到 h=46%（臂更长，指尖更靠左）：
   左手 left=-10% → 指尖 x=166 ；右手 right=-3.5% + h=46% → 指尖 x=166 → **间距 0（贴上）** ✓ */
/* ⚠ 注意：用"最外侧不透明像素"当指尖锚点会**高估**贴近程度 ✗ ——
   那是最外圈的**极细边缘丝**，肉眼几乎看不见，所以数值上"贴上"了、看着仍有缝。
   这里按**观感**再收紧 16px（左手臂内移）→ 指尖真正挨上 ✓ */
/* ⚠ 按**实测舞台尺寸**（355x221 @393x852，容器有 40px 内边距 ✗）重解 ——
   之前一直按 369x230 反推，所以怎么调都对不上 ✗。
   固定比例后 H=0.6233W；左指尖 = W*(L% + 0.95*hL%)；右指尖 = W*(1 - R% - 1.2765*hR%)
   令两者都落在 0.5W（相触）→ L%=-8.9、R%=-8.7；竖直 top+bottom=2.5 → top=6.5、bottom=-4
   此时两臂末端分别在 -32 / 381（都在画面外 ✓），指尖间距 0 ✓ */
.fr-en-hand-l{left:-8.9%; top:6.5%; height:62%}
.fr-en-hand-r{right:-8.7%; bottom:-4%; height:46%}
/* ---- 指尖之间的间歇电流弧 ----
   ⚠ 改成**等离子球那种平滑细丝**（用户反馈原来的锯齿折线不好看 ✗）：
     · 路径用**三次贝塞尔**（平滑），不再是锯齿 polyline ✓
     · 极细（1.5px）+ 分层辉光（大范围柔光 + 近距离亮光）→ 像真实电弧 ✓
     · 同时画 3 条（主丝 + 2 条更淡的）→ 有等离子球的层次感 ✓
   ---- */
.fr-en-arc{
  position:absolute; inset:0; width:100%; height:100%;
  pointer-events:none; opacity:0;
  transition:opacity .05s linear;
  z-index:1;
}
.fr-en-arc.on{opacity:1}
.fr-en-arc path{
  fill:none;
  stroke-linecap:round; stroke-linejoin:round;
}
/* 主丝：细、亮、带两层辉光 */
.fr-en-arc .fr-en-arc-line{
  stroke:#ffffff; stroke-width:1.5;
  filter:drop-shadow(0 0 3px rgba(235,245,255,1)) drop-shadow(0 0 9px rgba(150,200,255,.95));
}
/* 副丝：更淡更细，做出"多股电弧"的层次 */
.fr-en-arc .fr-en-arc-f2{stroke:rgba(215,235,255,.62); stroke-width:1.1;
  filter:drop-shadow(0 0 5px rgba(150,200,255,.8));}
.fr-en-arc .fr-en-arc-f3{stroke:rgba(190,220,255,.42); stroke-width:0.9;
  filter:drop-shadow(0 0 6px rgba(140,190,255,.7));}
/* 底层柔光：同一条主丝画粗、模糊，做出电弧周围的空气电离辉光 */
.fr-en-arc .fr-en-arc-halo{
  stroke:rgba(165,205,255,.34); stroke-width:7;
  filter:drop-shadow(0 0 12px rgba(140,190,255,.9));
}
/* ---- 指尖电光火花 ---- */
.fr-en-spark{
  position:absolute; left:50%; top:50%;   /* 实际位置由 JS 按指尖中点设置 */
  width:64px; height:64px; margin:-32px 0 0 -32px;
  pointer-events:none; opacity:0;
  /* ⚠ 改成**接触点的柔和亮团**（用户反馈原来的"四道光芒"不好看 ✗）：
     一个亮核 + 一圈冷色柔晕，像等离子球被手指引出的那一团光 ✓ */
  background:
    radial-gradient(circle, #ffffff 0%, #f2f8ff 14%, rgba(190,220,255,.85) 30%, rgba(140,190,255,.35) 52%, transparent 72%);
  border-radius:50%;
  filter:drop-shadow(0 0 10px rgba(190,225,255,.95));
}
.fr-en-spark.on{animation:frEnSpark .46s ease-out}
@keyframes frEnSpark{
  0%  {opacity:0; transform:scale(.22) rotate(0deg)}
  14% {opacity:1; transform:scale(1.12) rotate(9deg)}
  34% {opacity:.45;transform:scale(.78) rotate(15deg)}
  56% {opacity:1; transform:scale(1.0) rotate(21deg)}
  78% {opacity:.7; transform:scale(.9)  rotate(26deg)}
  100%{opacity:0; transform:scale(1.35) rotate(32deg)}
}
/* ---- 手下面的字 ---- */
.fr-en-caption{
  margin-top:6vh;                     /* 用户要求：最下面那行再低一点 ✓ */
  font-size:clamp(17px, 4.3vh, 22px); font-weight:800; letter-spacing:.05em;
  color:#e8232a;                      /* 红字（字面）✓ */
  text-align:center;
  text-shadow:0 3px 10px rgba(0,0,0,.5);
  position:relative;
}
/* ⚠ 描边**必须单独放一层、压在字面之下** —— 不要用 `-webkit-text-stroke` + `paint-order:stroke fill`。
   真机实测（Android Chrome，build 20261004g）：部分版本对 **HTML 文本**的 `paint-order` 不生效，
   描边仍画在字面**之上**；而 5px 描边在 22px 字上是"向内各吃 2.5px"，
   直接把笔画（约 2~3px 宽）整个盖掉 → **只剩一圈白边、看不到字的内容** ✗
   （headless 里能正确渲染出红字白边，所以本地截图看不出来 —— 属真机差异）。
   拆成两层后，无论 `paint-order` 支不支持，不透明的字面都一定在描边之上 ✓
   可见白边仍是 2.5px（描边向内的一半被字面盖住），与设计一致 ✓ */
.fr-en-caption::before{
  content:attr(data-text);
  position:absolute; left:0; right:0; top:0;
  -webkit-text-stroke:5px #ffffff;
  color:transparent;                  /* 只保留描边本身 */
  text-shadow:none;                   /* ⚠ 否则继承父级那层阴影、描边会画两遍 */
  z-index:-1;                         /* 压在字面之下 */
  pointer-events:none;
}

/* ---- 分步出现（用户要求：不要静态一起冒出来）----
   ① 进度条 + 百分数一起 0→99.99%  ② 到达后出「連接進度僅差」  ③ 再出双手  ④ 2s 后底部那行"砸入" */
.fr-en-title, .fr-en-pct, .fr-en-sub, .fr-en-stage{opacity:0; transition:opacity .36s ease, transform .4s cubic-bezier(.18,1.25,.34,1)}   /* 进：有力 ✓ */
.fr-en-sub{transform:translateY(7px)}
.fr-en-stage{transform:translateY(12px) scale(.96)}
.fr-popup-energy.step1 .fr-en-title,
.fr-popup-energy.step1 .fr-en-pct{opacity:1}
.fr-popup-energy.step2 .fr-en-sub{opacity:1; transform:none}
.fr-popup-energy.step3 .fr-en-stage{opacity:1; transform:none}
/* 底部那行：和牌面关键词同一套"砸入"动画（frKwSlam）✓ */
.fr-en-caption{opacity:0}
.fr-en-caption.slam{opacity:1; animation:frKwSlam 1s cubic-bezier(.22,1.05,.3,1) both}

/* ============================================================================
   ★ 低端设备自动降级（.low-perf 由 JS 按 CPU核数/内存/系统偏好/实测帧率 自动加上 ✓）
   背景：实测 OPPO X7 的 chrome://gpu 显示 **Multiple Raster Threads = Disabled** ✗
        → 栅格化单线程，所有"绘制密集型"效果（模糊/滤镜/毛玻璃）都慢一个数量级 ✗
   原则：**只关特效，不动布局与文案** ✓；高档机不加这个类、完全不受影响 ✓
   ============================================================================ */
html.low-perf .fr-orb{filter:none;}                       /* 氛围光斑：本就是径向渐变，去模糊无差 ✓ */
html.low-perf .fr-keyword{filter:none !important;}        /* 关键词：不做逐帧模糊 ✓ */
html.low-perf .fr-keyword{transition:opacity .45s ease, transform .45s cubic-bezier(.22,1,.36,1);}
/* 关键词"砸入"：保留大小+位移+旋转，只去掉逐帧模糊 ✓ */
html.low-perf .fr-card-kw .fr-keyword.in{animation:frKwSlamLite 1s cubic-bezier(.22,1.05,.3,1) both;}
@keyframes frKwSlamLite{
  0%  {opacity:0;transform:translateY(-26%) scale(3.0) rotate(-7deg)}
  26% {opacity:1;transform:translateY(-6%) scale(1.7) rotate(-2.5deg)}
  42% {opacity:1;transform:translateY(0) scale(1.04) rotate(1.2deg)}
  56% {transform:translateY(0) translateX(-1.6px) scale(.996) rotate(-.4deg)}
  70% {transform:translateY(0) translateX(1px) scale(1.002) rotate(.2deg)}
  100%{opacity:1;transform:translateY(0) translateX(0) scale(1) rotate(0deg)}
}
html.low-perf .fr-card-kw .fr-keyword::after{animation:none;}   /* 去掉"字面流动反光"无限动画 ✓ */
html.low-perf .fr-popup-energy::before{animation:none;}         /* 波点：停掉心跳，保持静态 ✓ */
html.low-perf .fr-en-arc path{filter:none;}                     /* 电弧：去掉 drop-shadow 辉光（线还在 ✓）*/
html.low-perf .fr-en-spark{filter:none;}
/* ★ 最后机会「光柱」画面（2026-10-09 补）——**原来这条画面一个降级都没有** ✗：
   它的放射光线是"12.7 倍视口面积 + blur(2px) + 44s 无限旋转"，
   低端机（单线程栅格化）上是本次"出现时有点卡"的主因 ✗。
   低端处理：① 去掉 blur（2px 本就极淡，肉眼几乎无差 ✓）
             ② 停掉旋转（静置 → 完全不再重栅格化 ✓；放射光"扫过"的动态感牺牲掉，
                但整个画面只在最后出现 ~6 秒，可接受 ✓）
             ③ 顺手收窄尺寸：低端不要 130vmax，压到 100vmax 仍能盖满视口 ✓ */
html.low-perf .fr-finale-rays{
  width:100vmax; height:100vmax; margin:-50vmax 0 0 -50vmax;
  filter:none !important;
  animation:none !important;
}
/* 光柱/光晕本已是预烘焙图 + 纯 transform/opacity 动画（合成器友好 ✓）；
   低端再省一层：光晕的呼吸停掉（它面积 0.77 视口，收益小但零风险 ✓）。*/
html.low-perf .fr-finale-glow{animation:none !important;}

/* ★★★★ 2026-10-09 第十次·真凶（终局结论，前 9 次全部作废）★★★★
   ── 现象（用户连报）：「第一张和后面的效果不一样，全部改成按第一张的效果」
      —— 卡2/3 旋转入场时，**牌的左右两侧**出现**竖直的暗带**、牌上下也有暗块
      （合起来像一块"比牌略大的圆角暗板"），卡1 完全没有 ✗
   ── ⚠⚠⚠ 历次误判（**全部作废，别再照着改**）：
      ① `.fr-card` / `.fr-card-flying` 占位底色 ✗   ② `.fr-stage` 压暗椭圆半径不够 ✗
      ③ canvas / `bg2.webp` 的暗区 ✗               ④ 常驻全屏 overlay（terms/ios）的 backdrop-filter ✗
      ⑤ `#desk-layer.dialog-dimmed`（brightness .06 的黑桌面）✗✗ ← **看着最像，其实是假的**
   ── 为什么 ⑤ 是假的（**这是本次最大的教训**）：
      `getComputedStyle('#desk-layer').display === 'none'`、`rect = 0x0`、
      `:has()` 与 `.fr-reading-on` 两条选择器**各命中 1 个元素** —— 一切指标都说"已隐藏" ✓，
      **但像素一点没变** ✗。⇒ **`computed style`/`matchCount` 不能证明"画面变了"**，
      必须**直接读像素**（本次最终靠像素 A/B 才定位到真因）✓
   ── **真凶（同源单变量 A/B 实证，`.dbg/verify_veil_ab.js` / `shot_slab_ab.js`）**：
      卡1 飞行时 `frFlyCardFromFan(..., keepVeil=false)` 会建一个
      **`.fr-card-veil`**（不透明的暖白径向 + `backdrop-filter`，`z-index:3`）盖在牌位上 ✓；
      卡2/3 走 `keepVeil=true` —— **完全没有 veil** ✗✗
      → 牌转到 edge-on 附近时，牌面（两张 `backface-visibility:hidden` 的 img）**双双塌成一条线**，
        背后 `.fr-card` 那层近黑底色**没有任何东西盖住** → 裸露成"竖直暗带 + 暗块" ✗
      ⚠ 为什么改 `.fr-card` / `.fr-card-inner` 的**背景色**都没用 ✗：
        背景是**跟着 3D 一起旋转**的，edge-on 时它在投影里同样塌成一线，盖不住 ✗✗
        而 veil 是**独立子元素**（不参与 `rotateY`），能完整盖住整个矩形 ✓
   ── 受控 A/B（`shot_slab_ab.js`，冻结所有 rotateY/translate 到 600ms 锁死相位，
      唯一变量 = veil 有无；采样牌中轴 y=160/200/260/340）：
        veil 关（=修复前） 207 / **63** / **95** / **37**   ← 暗带
        veil 开（=修复后） 203 / **118** / **131** / **71**  ← **提亮 +55 / +36 / +34** ✓
        再关再开**逐点完全一致** → 确认唯一变量就是 veil ✓
   ── 修法：`js/fast-script.js` 的 `frFlyCardFromFan()` 里，`keepVeil=true` **也创建 veil**
      （类名 `.fr-card-veil fr-card-veil-soft`，样式完全复用 `.fr-card-veil`），
      并在**落地瞬间**用 260ms 淡出移除它（不挡"牌背朝上等付费"的落地态 ✓）。
      ⚠ 安全性：卡2/3 飞行时**两层都是牌背图**（faceImg 已被换成牌背）→ veil 不泄露牌面 ✓
      ⚠ `.fr-card-veil-soft` 那条规则**只作标记、不写任何视觉** —— 卡2 必须与卡1 逐像素一致 ✓
   ── 下方这两条 `#desk-layer{display:none}` **保留但已确认无关**（desk 本来就不显示）：
      留着是为了让"读牌阶段不出现桌面插图"这件事有个显式保证，删掉也不会变坏 ✓ */
body.dialog-open:has(#flash-reading-stage.active) #desk-layer,
body.fr-reading-on #desk-layer{
  display:none !important;
}
/* 毛玻璃 backdrop-filter → 半透明实色底（安卓 Chrome 的 backdrop-filter 很慢 ✗）*/
html.low-perf .dialog-panel,
/* ⚠ 原来这里写的是 `.terms-modal` —— 但那 5 个法务弹窗的实际 class 是 `terms-modal-overlay`，
   所以这条规则**一个元素都没匹配到、从来没生效过** ✗（5 个全屏 blur(10px) 一直在跑）。
   选择器写错是"降级看起来没效果"的常见原因 → 改完务必用探针断言"命中数 > 0" ✓ */
html.low-perf .terms-modal-overlay,
html.low-perf .fr-popup-skip{
  -webkit-backdrop-filter:none !important;
  backdrop-filter:none !important;
}
html.low-perf .fr-popup-skip{background:rgba(24,6,38,.82);}
/* ⚠⚠ 之前**漏掉了最贵的一处**：`.fr-popup` 是**全屏**毛玻璃，而它自己还在做 1.2s 的 opacity 过渡
   → 进场/退场的**每一帧**都要把背后整个画面重新采样 + 模糊一次 ✗
   （Multiple Raster Threads=Disabled 的机型上，这就是"弹窗进出场明显卡"的主因）。
   `.fr-caption`（对话框，blur 10px）同理，也在做 .8s 的 opacity 过渡 ✗。
   两者的底色本来就是 72%~86% 不透明 → 去掉模糊肉眼几乎无差 ✓ */
html.low-perf .fr-popup,
html.low-perf .fr-caption{
  -webkit-backdrop-filter:none !important;
  backdrop-filter:none !important;
}
/* ⚠ 但 `.fr-card-veil`（牌的光遮盖）**绝对不能关** —— 它的底色是 alpha .38~.85 的径向渐变，
   挡住牌面**全靠这层 blur**；关掉会让牌面在飞行途中透出来（付费墙泄露）✗✗ */
/* 弹窗状态的"颗粒遮罩"是**全屏 mask** ✗ —— 单线程栅格化下很贵；
   低端机去掉遮罩（进出场变成普通淡入淡出 ✓，功能不受影响 ✓）*/
html.low-perf .fr-popup-state{
  -webkit-mask-image:none !important;
  mask-image:none !important;
  /* ⚠⚠ 比遮罩更贵的是**大面积 blur 滤镜**：state 是全屏尺寸（实测占 132%~203% 视口），
     基础态 `filter:blur(9px) brightness(1.5)`、`.active` `filter:blur(0)`，
     再加上 scatter/assemble 关键帧里的 blur 0→6px ✗ ——
     这就是"大面积模糊图层每帧重栅格化"（与「最后机会」光柱同一个坑，那次是用预烘焙图解决的），
     低端机上就表现为**弹窗进出场明显卡** ✓
     低端改成**无模糊的普通淡入淡出**：两端 filter 都是 none → 那条 filter 过渡自然失效；
     opacity + transform 的交叉过渡仍在 → 观感依旧是"进有力、退缓慢" ✓ */
  filter:none !important;
  animation:none !important;      /* 去掉 scatter/assemble 关键帧里的逐帧模糊 ✓ */
}
/* ⚠⚠ 曾把付费页的 `.pay-light-beam` / `.pay-price-glow` 也降级成 `filter:none` —— **已撤回** ✗
   原因：受控实验（.dbg/probe_filter_anim_cost.js，空白页 + 数 BeginMainFrame）证明——
   **只有"动画 `filter` 的值"才会逼主线程每帧重算（180 帧/60fps）**；
   而 `transform` / `opacity` 即使加在**带 blur 的元素**上也是**完全合成**的（0 帧）✓。
   这两个元素动的是 `opacity`（lightPulse / glowPulse）→ 属完全合成，本来就不贵 ✓；
   而且 `.pay-light-beam` 是**线性**渐变（横向是硬边），去掉 blur(50px) 会明显变硬 ✗。
   → 教训：**"动了 opacity"不等于"每帧重栅格化"**，别只凭属性名推断 ✗ */
/* `.desk-layer` 是**全屏**（100% 视口）底图，`transition:filter 1.2s` 让它**逐帧重栅格化整屏** ✗
   （它过渡的是 **filter 的值**：brightness(1) → brightness(0.18)，属上面那条"真贵"的情形 ✓）。
   低端只去掉"滤镜过渡"、**保留滤镜本身** → 变暗效果完全不变，
   只是从"1.2s 渐暗"变成"瞬间变暗"（底图本身已经很暗，几乎看不出差别）✓ */
html.low-perf .desk-layer{transition:opacity 1.2s ease !important;}
html.low-perf .desk-layer.dialog-dimmed{transition:none !important;}
