/* ============================================================
   问杯 · 视觉基调：深靛蓝底 + 暖琥珀字（夜空与灯光的对照）
   气质规则：慢、深色、衬线大字距、雨一直在、不用红、不用感叹号
   ============================================================ */

:root{
  --bg-0:      #070a0e;
  --bg-1:      #0e1620;
  --ink:       #e9e3d6;   /* 暖白 */
  --ink-soft:  rgba(233,227,214,.58);  /* 小字档：够读，又不抢正文 */
  --ink-dim:   #7d7568;   /* 暗一档，只给"轻触屏幕"这类旁注 */
  --amber:     #d8b45f;   /* 暖琥珀 */
  --amber-lit: #f0cf85;   /* 点亮时的琥珀 */
  --lamp:      rgba(216,180,95,.075);  /* 吧台灯，纯气氛 */

  --fade:      700ms;     /* 转场淡入淡出（game.js 里的 FADE 必须跟着改） */
  --fade-slow: 900ms;

  /* ⚠️ KaiTi 必须是第一个，这不是排序偏好，是要它**不依赖别人缺席**。
     S2 用 _font.html 逐像素实测过（同一句"雨落下来的时候"分别用整条栈和
     各款单字体画进 canvas 比 hash）：这台机器上原来的第三名
     "Source Han Serif SC" / "Noto Serif SC" / "Songti SC" 一个都没装，
     真正渲染出来的**确实**是排在原第四位的 KaiTi —— 也就是说，
     原来那份字体栈是"因为前三名都不在"才碰巧对上的。
     哪天装了思源宋体，楷体会被悄悄顶掉，而截图上看不出来（都像衬线）。
     现在把楷体提到最前，把这个巧合变成确定。

     楷体是用户拍板的，不要换。后面的 STKaiti / Kaiti SC 是 macOS 上的楷体；
     SimSun / 思源宋体是别的机器上的保底衬线。
     ⚠️ 不要用 @font-face 带本地字体文件：file:// 下会被 CORS 拦掉。 */
  --serif: "KaiTi", "STKaiti", "Kaiti SC",
           "Source Han Serif SC", "Noto Serif SC", "Songti SC",
           SimSun, Georgia, serif;
}

/* 选项文字的左缩进 —— .note（"继续往下读"的说明）要和它对齐全。
   以前这里是个写死的 1.69em 魔法值，来历是 1.15 ÷ 0.68：
   .opt 的缩进是 1.15em，而 .note 自己的字号是 .68em，
   同一个 em 在两边算出来不一样长，所以要先把比值折进去。
   数字没错，错的是让它**依赖两个字号之比** ——
   这次给 .note 加了"手机上不小于 12px"的下限，比值一变它立刻又偏了。

   ⚠️ 为什么不能直接写 var(--opt-pad) 就完事：
   自定义属性在**没有 @property 注册**时，值是当作"未解析的记号串"往下继承的，
   里面的 em 要等到**使用它的那个元素**上才算 —— 于是 .note 上拿到的是
   .note 的 1.15em，又缩回 68%，白改。
   注册成 <length> 之后，它在 .scene--q 上就定死成 px 再往下传，
   谁来用都一样长。 */
@property --opt-pad{
  syntax: "<length>";
  inherits: true;
  initial-value: 18px;
}

*, *::before, *::after { box-sizing: border-box; }

html, body{
  margin: 0;
  padding: 0;
  height: 100%;
  overflow: hidden;                 /* 全场不滚动 */
  overscroll-behavior: none;
}

/* html 也要上底色，否则加载瞬间和过度滚动时会闪白 */
html{ background: var(--bg-0); }

body{
  /* 两盏光叠在一起：
     ① 中间那团冷一点的，把舞台托起来（原来的那层）
     ② 底下那团暖的，像吧台上方一盏灯 —— 作用是让画面下半部"有盏灯"，
        而不是从上到下一样地平。**极淡**，只是让暗部有层次；
        一调亮就俗，宁可看不见。 */
  background:
    radial-gradient(58% 40% at 50% 92%, var(--lamp) 0%, rgba(216,180,95,0) 72%),
    radial-gradient(120% 90% at 50% 42%, var(--bg-1) 0%, var(--bg-0) 72%);
  color: var(--ink);
  font-family: var(--serif);
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;

  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  -webkit-user-select: none;
  user-select: none;
}

/* ---------- 雨 ---------- */

#rain{
  position: fixed;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
  pointer-events: none;
  opacity: .9;
}

/* 四角压暗，把注意力收到中间 */
#vignette{
  position: fixed;
  inset: 0;
  pointer-events: none;
  background: radial-gradient(110% 80% at 50% 45%,
              rgba(0,0,0,0) 42%, rgba(0,0,0,.55) 100%);
}

/* ---------- 舞台 ---------- */

#stage{
  position: fixed;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  padding: 8vh 8vw calc(8vh + env(safe-area-inset-bottom));
  text-align: center;

  transition: opacity var(--fade) ease, transform var(--fade) ease;
  opacity: 1;
  transform: translateY(0);
}

/* 换屏时先淡出、微微下沉，再换内容 */
#stage.is-out{
  opacity: 0;
  transform: translateY(10px);
}

/* ---------- 引子 ---------- */

/* ⚠️ 字号基准必须设在这里（.scene 上），不能靠 .line 自己带。
   如果 .scene 没有字号，em 会沿用从 body 继承的 16px，
   max-width 和 gap 全都按 16px 算，容器只有 352px 宽，把整句硬折断。

   宽度按 width:100% + max-width 写（等价于 min(100%, Nem)，但更好读）。
   另外 .scene 是 #stage 这个纵向 flex 的项目，里面 .opts / .rows / .deck
   全靠 width:100% 撑满 —— 所以 .scene 自己的宽度必须是确定值，
   不能写成一个"先按内容收缩再说"的形态，否则子元素的百分比会一起失效。 */
.scene{
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 1.5em;
  width: 100%;
  max-width: 20em;              /* 20 × 本屏基准字号 */
}

.scene .line{
  margin: 0;
  font-size: 1em;               /* 跟随 .scene 的基准字号 */
  line-height: 1.9;
  letter-spacing: .16em;
  text-indent: .16em;           /* 补掉字距在行尾多出来的那一格，让每行视觉居中 */
  text-wrap: balance;           /* 万一行长到必须折行，让两行长度均衡 */
  color: var(--ink);

  opacity: 0;
  transform: translateY(14px);
  animation: rise var(--fade-slow) cubic-bezier(.22,.61,.36,1) forwards;
}

@keyframes rise{
  to { opacity: 1; transform: translateY(0); }
}

/* 发牌：22 张一张一张浮上来（S3）。
   ⚠️ 动的是 translate / scale 这两个**独立变换属性**，不是 transform。
      用 transform 的话，动画填满之后会永久压住 transform ——
      :hover 的上浮和 .is-on 的抬升会被一起锁死（动画赢过普通声明）。
      独立属性和 transform 各走各的，互不干扰。 */
@keyframes deal{
  from { opacity: 0; translate: 0 18px; scale: .94; }
  to   { opacity: 1; translate: 0 0;    scale: 1; }
}

/* ---------- 引子 ---------- */

.scene--prologue{ font-size: clamp(19px, 4.6vw, 30px); }

/* ⚠️ v22 删掉了一条 .scene--prologue .line:last-child{琥珀色}。
   引子从四句删到一句之后，这唯一的一行就是 :last-child，
   会被自动点成琥珀色 —— 而店主定的是"保持正文色"。
   ⚠️ 没有任何机检测试看着这条规则，靠的就是这一行注释别让人加回来。 */

/* ---------- 叙事线 ---------- */

.scene--divider{ font-size: clamp(17px, 4vw, 26px); }

.scene--divider .line{ color: var(--amber-lit); }

/* ⚠️ 全场**不许用 font-style: italic**。
   楷体（KaiTi）根本没有斜体字重，浏览器只能把字面整体斜切一下来充数 ——
   楷体的笔锋本来就是斜着走的，再被整块切一刀，笔画立刻糊成一团，
   在深色底上尤其脏（S2 前的截图里「——没有标准答案，只有今晚。」
   就是这么糊掉的）。
   要区分层级就用字号 / 颜色 / 字距这三样，不要动倾斜。 */
.scene--divider .aside{
  margin: 1em 0 0;
  font-size: max(.64em, 12px);
  letter-spacing: .2em;
  text-indent: .2em;
  line-height: 1.8;
  color: var(--ink-soft);

  opacity: 0;
  animation: rise var(--fade-slow) 1.6s cubic-bezier(.22,.61,.36,1) forwards;
}

/* 动画没放完就被点了 —— 立刻落定，但不跳过内容。
   ⚠️ 这一串必须**穷举**：漏掉哪一个，提前点击时它就停在 opacity:0，
      而屏幕上什么都看不出来（那一屏只是"少了个东西"）。
      S4 新增的是 cup / box / open 三屏里带进场动画的那些。
   ⚠️ **不许**把 .dial__rotor / .dial__stop 加进来 ——
      它们身上的 transform 是 JS 一行行写上去的（转子的角度、每挡的 --a），
      这条 rule 一句 transform:none 会把五个轮一起转回零位。 */
.scene.is-settled .line,
.scene.is-settled .aside,
.scene.is-settled .stem,
.scene.is-settled .opts,
.scene.is-settled .deck,
.scene.is-settled .card,
.scene.is-settled .cup,
.scene.is-settled .atlas,
.scene.is-settled .answerbox,
.scene.is-settled .mirror,
.scene.is-settled .mirror__card,
.scene.is-settled .arcana,
.scene.is-settled .card--arcana .rows,
.scene.is-settled .open__secretline,
.scene.is-settled .open__line{
  animation: none;
  opacity: 1;
  transform: none;
}

/* 盒盖是**唯一**一个终态不在场的：它掀开、走掉了（见 lidopen 收尾）。
   所以它不并进上面那一串，得单独写一条 —— 并进去的话，
   提前点击时盒盖会停在"盖着"的位置，把镜子挡掉一半。 */
.scene.is-settled .open__lid{
  animation: none;
  opacity: 0;
  transform: rotateX(-104deg);
}

/* v22 的「秘密」特写，理由和盒盖**一模一样**：它的终态也是"走掉了"
   （secretbeat 收尾在 0），所以**绝不能**并进上面那一串。
   并进去的后果正是店主最怕的那种：截图 / 提前点击 / 晕动症设置下，
   剪影 + 名字**永久浮在终态卡片上面**，和卡片第五行里那一个撞在一起。
   ⚠️ 三个子元素（.secret__mask / .secret__name）**不用加** —— 它们不动，
      只有容器动。 */
.scene.is-settled .mirror__secret{
  animation: none;
  opacity: 0;
}

/* 牌单独一条：它动的是 translate/scale 两个独立属性，
   上面那条只写 transform 收不干净 —— 收不干净的表现是
   静帧截图里 22 张牌一起停在"还没浮上来"的位置，透明得看不见。 */
.scene.is-settled .deck__card{
  animation: none;
  opacity: 1;
  translate: none;
  scale: none;
}

/* ============================================================
   题目屏（Q1–Q5）
   ============================================================ */

/* 宽度取 33em = 660px（桌面档字号 20px 时）。
   ⚠️ **这段的依据在 v22 变了，值没变**：
   v22 之前最长的是 23 字（「两样东西碰在一起了。你想它们把你带到哪儿去——」），
   1.22em 字号 × 1.13 字距 ≈ 27.6px/字 → 约 637px < 660px，**一行放得下**。
   v22 把题干改成"按线路一句"之后，最长的是拿铁线那句
   「你跑进那处亮着的地方，那条"下雨了"还没发出去——」共 **25 字** → 约 690px，
   **已经越过 33em**，所以**桌面档也折成两行了**（实测 1100×800 折在「那 / 条」之间，两行等长）。
   这条**没有坏**：`text-wrap:balance` 兜住的正是当初要解决的问题 ——
   末尾那个「——」不会被单独甩到第二行（原来 26em / 520px 时就会）。
   真想让最长那句也一行放得下，把 33em 提到 36em（720px）即可 ——
   但那会**连带改宽所有题目屏**（Q1/Q3/Q4/Q5 一起），**v22 没动，等店主发话**。 */
.scene--q{
  width: 100%;
  max-width: 33em;
  gap: 1.1em;

  /* ⚠️ 这里的 vw 系数是配合上面的 33em 一起算的，别单独改。
     2.6vw 保证窄屏（平板竖屏、700px 上下）时字号同步变小，
     否则容器缩了、字没缩，同一句又要折行。
     下限 14px 是手机宽度下认怂的地方 —— 那时确实会折，
     靠 .stem 的 text-wrap:balance 折成两行等长，而不是 21+2。 */
  font-size: clamp(14px, 2.6vw, 20px);

  /* 选项文字的左缩进，在这里定死一次，.opt 和 .note 都取它。
     在 .scene--q 上算（字号 = 场景基准），所以两边拿到的是同一个 px 长度 ——
     这正是 .note 以前那个 1.69 想手工凑出来的东西。见顶部 @property 的注释。 */
  --opt-pad: 1.15em;
}

/* 题干 */
.stem{
  margin: 0;
  font-size: 1.22em;
  line-height: 1.85;
  letter-spacing: .13em;
  text-indent: .13em;
  color: var(--ink);
  text-wrap: balance;              /* 真要折，折成两行等长 */

  opacity: 0;
  transform: translateY(14px);
  animation: rise var(--fade-slow) cubic-bezier(.22,.61,.36,1) forwards;
}

/* 小字（"这张是它给你的"等） */
.scene--q .aside{
  margin: -.35em 0 0;
  font-size: max(.72em, 12px);   /* 手机档 14px×.72 ≈ 10px，太小，兜个 12px */
  line-height: 1.8;
  letter-spacing: .12em;
  text-indent: .12em;
  color: var(--ink-soft);

  opacity: 0;
  transform: translateY(14px);
  animation: rise var(--fade-slow) 420ms cubic-bezier(.22,.61,.36,1) forwards;
}

/* ⚠️ v22：这一段**已经用不上了，但故意不删** ——
   它服务的 `.aside--key` 就是 Q2 列表上方那句
   「这些不是你此刻看到的，是你想要的那个瞬间。」，
   随 33 句意象全部回到同一个雨夜而**撤掉**（见 `data.js` 的 `q2` 与 `game.js` 的 `SCREENS.q2`）。
   那句小字当初唯一的作用是解释"为什么上面四行横跨早七点、夏冬"；
   画面不再横跨之后，它变成了说反话。
   **现在没有任何元素带 `.aside--key`** —— 这几条规则是死代码，留着只为万一要恢复。

   下面这段是它当初的来历（**已失效，留作记档**）：
   它是全场的**连接句**，不是普通小字 —— 题干问"你想它们把你带到哪儿去"，
   下面四行是意象；要是没有它，玩家会把四行意象读成"我现在看到的"，
   投射就降级成看图说话。
   原来它跟别的小字一个待遇（.72em + 暗灰 + 伪斜体），分量明显不够 ——
   所以当初**不给它小字待遇**：字号接近正文、换成琥珀色、字距再放开一档、
   上边距不再是负的（原来是 -.35em，等于把它往题干底下塞，自己把自己藏起来）。
   用 .scene--q .aside.aside--key 三节，特异度稳过上面的两节，日后重排也不会翻车。 */
.scene--q .aside.aside--key{
  margin: -.1em 0 .2em;
  font-size: max(.86em, 13px);
  line-height: 1.9;
  letter-spacing: .16em;
  text-indent: .16em;
  color: var(--amber);
}

/* ---------- 选项 ---------- */

.opts{
  display: flex;
  flex-direction: column;
  width: 100%;
  margin-top: .5em;

  opacity: 0;
  transform: translateY(14px);
  animation: rise var(--fade-slow) 760ms cubic-bezier(.22,.61,.36,1) forwards;
}

/* 选项 = 两列网格：左边画面（1fr），右边原料/饮品名（auto）。
   每个 .opt 自己是一个网格，而每个 .opt 又都是等宽的整行，
   所以右边那一列的右边缘在所有选项里天然对齐 ——
   四行的「茶 / 奶 / 咖啡 / 苏打水」会排成齐齐的一竖列，像菜单上的价目栏。 */
.opt{
  display: grid;
  grid-template-columns: 1fr auto;
  align-items: baseline;
  column-gap: 1em;

  position: relative;
  width: 100%;
  padding: .82em 1em .82em var(--opt-pad);   /* 缩进由 .scene--q 统一发 */

  appearance: none;
  background: transparent;
  border: 0;
  border-left: 1px solid rgba(233,227,214,.16);

  font-family: inherit;
  font-size: 1em;
  line-height: 1.6;
  letter-spacing: .1em;
  text-align: left;
  color: var(--ink);

  cursor: pointer;
  min-height: 46px;                /* 手指点得中 */
  transition: color .35s ease, border-color .35s ease, background .35s ease;
}

.opt + .opt{ margin-top: .45em; }

.opt:hover,
.opt:focus-visible{
  border-left-color: rgba(216,180,95,.55);
  color: var(--amber-lit);
  outline: none;
}

.opt.is-on{
  border-left-color: var(--amber);
  background: linear-gradient(90deg, rgba(216,180,95,.11), rgba(216,180,95,0) 72%);
  color: var(--amber-lit);
}

/* 小字：原料 / 饮品名。
   这一栏是要被读的（顾客靠它在四条里认自己），不能太暗太小 ——
   .6em + --ink-dim 那一版实测跟不上，提到 .68em 并单独调亮一档。
   S2 再收一次：加 px 下限 + 换 --ink-soft。
   原来 .68em 在手机档（基准 14px）只有 9.5px，而且 .46 的白
   压在深底上对比度约 3.9:1 —— 两样都够呛，一起补。 */
.opt__small{
  font-size: max(.68em, 11.5px);
  letter-spacing: .14em;
  color: var(--ink-soft);
  white-space: nowrap;
}

.opt.is-on .opt__small{ color: rgba(240,207,133,.72); }

/* 台词（Q4）—— 横跨两列，单独占一行 */
.opt__say{
  grid-column: 1 / -1;
  margin-top: .32em;
  font-size: max(.7em, 11.5px);
  letter-spacing: .12em;
  color: var(--ink-soft);
}

.opt.is-on .opt__say{ color: rgba(240,207,133,.72); }

/* ---------- Q1 的提示 / 互斥话术 ---------- */

/* 左对齐 + 和选项文字同一缩进，让它读起来像"上面那一列的补充说明"，
   而不是屏幕中央一句孤零零的话。 */
.note{
  width: 100%;
  margin: .2em 0 0;
  /* 缩进和 .opt 用同一个变量，见顶部 @property 的注释 ——
     这里曾经是个写死的 1.69em（= 1.15 ÷ 0.68），专为凑两个字号之比而生。
     现在 .note 的字号有了 px 下限，那个比值会变，魔法值必然失效。 */
  padding-left: var(--opt-pad);
  min-height: 2.6em;               /* 占位，免得文字出现时整屏跳动 */
  text-align: left;
  font-size: max(.68em, 12px);
  line-height: 1.75;
  letter-spacing: .12em;
  color: var(--ink-soft);
  transition: color .35s ease;
}

.note.is-warn{ color: var(--amber-lit); }

/* ---------- "继续"（只有 Q1 需要） ---------- */

.go{
  appearance: none;
  background: transparent;
  border: 0;
  border-bottom: 1px solid rgba(216,180,95,.45);
  padding: .45em .2em;

  font-family: inherit;
  font-size: .82em;
  letter-spacing: .34em;
  text-indent: .34em;
  color: var(--amber);

  cursor: pointer;
  min-height: 44px;

  opacity: 0;
  pointer-events: none;
  transition: opacity .5s ease, color .3s ease, border-color .3s ease;
}

.go.is-shown{ opacity: 1; pointer-events: auto; }
.go:hover{ color: var(--amber-lit); border-color: var(--amber-lit); }

/* ============================================================
   抽牌（S1 最简版，动画留到 S3）
   ============================================================ */

/* 6 张一行 × 22 张 = 6/6/6/4，最后一行不满，用 justify-content:center 居中。
   用 flex-wrap 而不是 grid，就是为了让这不满的一行自动居中 ——
   grid 的 repeat(6,1fr) 会让它停在左边。

   ⚠️ 一行必须是 6 张，所以**牌宽和牌堆宽要一起定**，不能各定各的：
   牌宽 = min(90px, 8.5vh)，牌堆宽 = 6 × 牌宽 + 5 个 8px 缝。
   只给牌宽加 8.5vh 上限（牌堆写死 540px）的话，矮窗口里牌变小了、
   横向反而空出地方，22 张会挤成 11/9/2 三行，看着像撒了一地。
   8.5vh 这个上限是为了在 700px 高的窗口上四行也放得下
   （aspect-ratio 2/3 → 牌高 = 牌宽 × 1.5，四行加起来约 60vh）。 */
.deck{
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 8px;
  width: 100%;
  max-width: calc(6 * min(90px, 8.5vh) + 40px);
  margin-top: .9em;

  opacity: 0;
  transform: translateY(14px);
  animation: rise var(--fade-slow) 520ms cubic-bezier(.22,.61,.36,1) forwards;
}

.deck__card{
  appearance: none;
  flex: 0 0 auto;
  width: calc((100% - 40px) / 6);
  aspect-ratio: 2 / 3;
  padding: 0;

  /* 牌背那个点靠 flex 居中。以前用 margin:0 auto 只居了横向，
     点在牌的上端 —— 22 张牌看上去是 22 个"上标小点"，不像牌。 */
  display: flex;
  align-items: center;
  justify-content: center;

  background:
    linear-gradient(150deg, rgba(216,180,95,.10), rgba(216,180,95,.02) 60%),
    var(--bg-1);
  border: 1px solid rgba(216,180,95,.28);
  border-radius: 3px;

  cursor: pointer;
  transition: opacity .45s ease, transform .45s ease,
              border-color .45s ease, box-shadow .45s ease;

  /* 发牌动画。animation-delay 由 game.js 按 i 错峰注入（520 + i×38ms）。
     ⚠️ 填的是 backwards，**不是** forwards：
        forwards 会把收尾状态钉死在元素上，其中含 opacity:1 ——
        那 .is-drawn 那句 opacity:.18 就永远盖不下去，
        没抽中的 21 张牌暗不下来，抽牌这个动作等于没发生。
        backwards 只在延迟那段时间里摆成"还没上来"的样子，
        动画一完就交还给基础样式（opacity 由上面的规则说了算）。 */
  animation: deal 620ms cubic-bezier(.22,.61,.36,1) backwards;
}

/* 牌背中间那个点 */
.deck__card::after{
  content: '';
  width: 6px; height: 6px;
  border-radius: 50%;
  background: rgba(216,180,95,.42);
}

.deck__card:hover{
  border-color: rgba(216,180,95,.7);
  transform: translateY(-3px);
}

.deck__card.is-on{
  border-color: var(--amber-lit);
  box-shadow: 0 0 18px rgba(216,180,95,.45);
  transform: translateY(-6px);
}

/* 抽完，其余的暗下去 */
.deck.is-drawn .deck__card:not(.is-on){ opacity: .18; }

/* ============================================================
   结果页 · 命盘卡（S3）
   ============================================================
   版式照设计方案第七章「正面版式」那张图落：五个栏位 + 底下一行牌，
   第一行上面和"牌"那一行上面各有一条横线（横线就是每行的 border-top）。

   卡面质感**故意和抽牌那张牌背一模一样**（同一套渐变 + 同一条描边 +
   同一个小圆角）：命盘卡和牌在故事里是同一种东西，长得也该是同一种。

   每一行第三列放那个栏位的符号（symbols.js 画）。
   ⚠️ 五个符号各用一种互不相同的视觉语言，免得在一张卡上互相撞车：
      基底=一个独立图案（认轮廓，不画外圈）/ 风味=族剪影+外圈刻度 / 温度=单个字形
      / 甜度=一排四点 / 秘密=问号。详见 data.js 的 symbols 块。
   ============================================================ */

.scene--reveal{
  width: 100%;
  max-width: 23em;
  gap: .5em;
  font-size: clamp(14px, 2.5vw, 18px);
}

.reveal__title{
  margin: 0;
  font-size: 1.8em;
  letter-spacing: .22em;
  text-indent: .22em;          /* letter-spacing 会在右边多出一格，缩回来才真居中 */
  color: var(--amber-lit);
}

.card{
  width: 100%;
  padding: .1em 1.15em .4em;
  text-align: left;

  background:
    linear-gradient(150deg, rgba(216,180,95,.10), rgba(216,180,95,.02) 60%),
    var(--bg-1);
  border: 1px solid rgba(216,180,95,.28);
  border-radius: 3px;

  opacity: 0;
  transform: translateY(14px);
  animation: rise var(--fade-slow) 520ms cubic-bezier(.22,.61,.36,1) forwards;
}

/* 卡头那句「✦ 命 盘 ✦」是**印在卡上的**（设计方案第七章），
   和屏幕上那句牌名（.reveal__title）是两回事，别合并。 */
.card__head{
  margin: 0;
  padding: .75em 0 .6em;
  font-size: .8em;
  letter-spacing: .5em;
  text-indent: .5em;
  text-align: center;
  color: var(--amber);
}

.rows{
  display: flex;
  flex-direction: column;
  width: 100%;
  text-align: left;
}

/* 三列：栏位名 / 值（+注释另起一行）/ 符号。
   auto 那列是符号，宽度由 .row__sym 自己定，不参与拉伸。 */
.row{
  display: grid;
  grid-template-columns: 3.4em 1fr auto;
  align-items: center;
  gap: .16em .9em;
  padding: .6em 0;
  border-top: 1px solid rgba(233,227,214,.09);
}

/* ⚠️ S3 顺带修掉的对比度：原来是 --ink-dim(#7d7568) 的 .74em，
   在卡面底色上只有 4.00:1 —— 低于正文要求的 4.5:1。
   换成 --ink-soft 并抬到 .78em，实测值记在 进度.md。
   这一档是"旁注"该有的亮度：看得清，又不跟右边的值抢。 */
/* ⚠️ 手机档兜底 max(...,11px) 是 **S4 才量出来的 S3 既有问题**，不是 S4 的回归：
   这一屏（reveal）以前从没进过 _s2.html 的 CASES，所以 .78em 在 390 档
   落到 14px × .78 = 10.92px 这件事一直没被量到 —— 比项目自己的 11px 下限低 0.08px。
   加兜底后桌面档仍是 .78em（18px × .78 = 14.04px），一处不改两处都过。 */
.row__k{
  font-size: max(.78em, 11px);
  letter-spacing: .18em;
  color: var(--ink-soft);
}

.row__v{
  font-size: .95em;
  letter-spacing: .1em;
  color: var(--ink);
}

/* 注释独占第二列的第二行。
   DOM 里它是第三个子元素（k/v/sym/n）—— 不写 grid-column 的话
   它会被自动排到第三列，也就是**压在符号上**。 */
.row__n{
  grid-column: 2;
  grid-row: 2;
  font-size: .68em;
  letter-spacing: .1em;
  color: rgba(216,180,95,.72);
}

/* 符号跨两行居中：有注释时跟整行对齐，没注释时行高只有一行，效果一样。
   （写 1/-1 要显式网格，这里换成 span 2 稳一点；空的第二行高度是 0。）

   颜色写在这里，不写在 symbols.js 里 —— 那里面全是 currentColor，
   所以改这一个值，整套九个符号跟着换，一行 JS 都不用碰。
   以后 S4 的转轮要换个色，也是改这一处。

   ⚠️ 试过提到 2em（S3 返工三想给新基底多留点地方），**退回来了**：
      五栏的符号是同一个格子，一提就是五栏一起大 —— 而基底那个簇本来就
      占满整个 44 画布（91%），别的四个只占五六成，一提它就把其余四栏
      压得发虚，整张卡头重脚轻。**要解决的是簇内部太碎，不是整栏太小**，
      动这一个数治不了病，只会把别的栏一起带歪。 */
.row__sym{
  grid-column: 3;
  grid-row: 1 / span 2;
  align-self: center;
  width: 1.6em;
  height: 1.6em;
  color: var(--amber);
  display: block;
}

/* SVG 默认是 inline，底下会留一条基线缝，跟同行的字对不齐。 */
.row__sym > svg{ display: block; width: 100%; height: 100%; }

/* 「秘密」那一格的问号不是 symbols.js 画的（那 49 个里没有它），
   是**排出来的字**——设计方案第七章原话是"永远留空印问号"，
   既然印的是字，屏幕上就也用字，别去描一个 SVG 问号。
   它和四个符号同色同大小，看上去才是"这一栏也该有个符号，只是空着"。 */
.row__qm{
  display: block;
  font-size: 1.05em;
  line-height: 1.5;
  letter-spacing: 0;      /* 继承下来的 .18em 字距会把问号推离中心 */
  text-align: center;
  color: var(--amber);
}


/* ============================================================
   解密区 · 你的杯子 + 墙上那块牌子（S4）
   ============================================================
   设计方案第九章的三步流程第一步（L933）。难度是「标准」档（L941）：
   不直接给符号，给的是**杯面剪影**加**墙上四件剪影**，靠形状比对认出来。

   ⚠️ **同屏禁令**：图鉴里「青柠皮」是一片带弧度的小叶，而风味的
      「leaf 族」就是一片叶子 —— 两者语言撞车。它们不同屏是**设计出来的**：
      图鉴只在 cup 屏出现，那时命盘卡不在场；镜子里映的是命盘卡，
      而秘密栏那一格虽然画的是辅料剪影，**场上只有它一个植物**，
      不构成"四选一"。以后**不许**把图鉴和命盘卡并排摆到同一屏。
   ============================================================ */

.scene--cup{
  max-width: 26em;         /* 比别的屏宽：底下那块牌子要横向摆四格 */
  gap: 1.1em;
  font-size: clamp(14px, 2.5vw, 18px);
}

/* 你的杯子。做成一只上宽下窄的杯身（clip-path），
   杯面上那件辅料就是这一步的谜面 —— 也就是"秘密"栏里那个问号。
   ⚠️ 杯名挂在**杯子外面**，别塞进 .cup 里：clip-path 会把超出多边形的
      部分连同文字一起裁掉，名字会被拦腰切一半。 */
.cup{
  position: relative;
  width: clamp(104px, 32vw, 156px);
  aspect-ratio: 1 / 1.02;
  display: grid;
  place-items: center;
  padding-bottom: 8%;

  background: linear-gradient(180deg,
              rgba(216,180,95,.14), rgba(216,180,95,.04) 62%, rgba(216,180,95,.11));
  clip-path: polygon(3% 0, 97% 0, 85% 100%, 15% 100%);

  opacity: 0;
  transform: translateY(14px);
  animation: rise var(--fade-slow) 260ms cubic-bezier(.22,.61,.36,1) forwards;
}

/* 杯面上那一件。比命盘卡上的符号大一号 —— 它是给人"看"的，不是给人"读"的。 */
.cup__mark{
  display: block;
  width: 52%;
  aspect-ratio: 1;
  color: var(--amber-lit);
}
.cup__mark > svg{ display: block; width: 100%; height: 100%; }

.cup__name{
  margin: 0;
  font-size: .92em;
  letter-spacing: .18em;
  text-indent: .18em;
  color: var(--ink-soft);
}

/* ⚠️ .aside 在项目里**没有基础样式** —— 每一屏自己写一条
   （.scene--divider .aside、.scene--q .aside 各一处）。
   新屏不写的话，那两句小字会拿正文的亮度、正文的字号、零字距出场 ——
   在一屏精心排过字距的界面里，看着就是"这两行没排过"。
   这一条是 S4 补的，_s2.html 里量得到（原来量出来是字距 normal）。 */
.scene--cup .aside,
.scene--box .aside{
  margin: -.2em 0 0;
  font-size: max(.72em, 12px);
  line-height: 1.8;
  letter-spacing: .12em;
  text-indent: .12em;
  color: var(--ink-soft);

  opacity: 0;
  transform: translateY(14px);
  animation: rise var(--fade-slow) 420ms cubic-bezier(.22,.61,.36,1) forwards;
}

/* 「看看你的杯子上有什么…」和「转轮上刻的是符号…」——
   这两句是**操作说明**，读不到就解不开盒子。所以不给它们小字待遇：
   琥珀色、字号接近正文、字距再放开一档 —— 和 Q2 那句连接句
   （.scene--q .aside.aside--key）同一个理由：分量不够，机制就哑。 */
.scene--cup .aside.aside--key,
.scene--box .aside.aside--key{
  margin: -.1em 0 .2em;
  font-size: max(.86em, 13px);
  letter-spacing: .16em;
  text-indent: .16em;
  color: var(--amber);
}

/* ---------- 墙上那块牌子（辅料图鉴） ---------- */

.atlas{
  width: 100%;
  padding: clamp(10px, 3vw, 18px) clamp(10px, 3vw, 18px) clamp(8px, 2.4vw, 14px);
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: .8em;

  /* 木牌：比别处那层纸更"实"一点（多一道内框），
     因为它在这屏里是"墙上的一件东西"，不是一张卡 */
  background: linear-gradient(180deg, rgba(233,227,214,.045), rgba(233,227,214,.008));
  border: 1px solid rgba(233,227,214,.13);
  box-shadow: inset 0 0 0 1px rgba(233,227,214,.04);
  border-radius: 3px;

  opacity: 0;
  transform: translateY(14px);
  animation: rise var(--fade-slow) 520ms cubic-bezier(.22,.61,.36,1) forwards;
}

.atlas__grid{
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: clamp(7px, 2.4vw, 15px);
  width: 100%;
}

.herb{
  appearance: none;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: flex-end;
  gap: .5em;
  padding: .95em .25em .55em;
  cursor: pointer;

  background: linear-gradient(180deg, rgba(216,180,95,.075), rgba(216,180,95,.015));
  border: 1px solid rgba(216,180,95,.24);
  border-radius: 3px;
  color: rgba(216,180,95,.80);
  font-family: var(--serif);
  min-height: 44px;                  /* 触控目标 */

  transition: border-color .35s ease, color .35s ease,
              box-shadow .35s ease, opacity .35s ease, transform .35s ease;
}

.herb__sym{
  display: block;
  width: clamp(32px, 10vw, 46px);
  aspect-ratio: 1;
}
.herb__sym > svg{ display: block; width: 100%; height: 100%; }

/* 编号 A/B/C/D。
   ⚠️ 带了 11px 下限：0.72em 在手机档（场景 14px）只有 10.1px，
      比全场那条"手机小字不小于 11px"还小 —— 这一档是**唯一**能区分
      四件剪影的东西，糊了这一步就没法解了。_s2.html 逐条盯着这条下限。 */
.herb__mark{
  font-size: max(.72em, 11px);
  font-weight: 400;                  /* <b> 默认加粗，楷体一粗就糊 */
  letter-spacing: .2em;
  text-indent: .2em;
  color: var(--ink-dim);
}

/* 点错：亮一下、落回去。
   ⚠️ **不用红、不用感叹号**（气质规则第二条）。方案里也没写"错了会怎样"，
      那就不编台词、不加音效 —— 木牌底下那行小字已经把话说完（L891）。
      这个 .is-miss 由 puzzle.js 加，420ms 后自己摘掉。 */
.herb.is-miss{
  border-color: rgba(216,180,95,.78);
  color: rgba(216,180,95,1);
  transform: translateY(-2px);
}

/* 认对了：其余三格暗下去，选中那格亮住 —— 就是"你看，就是它"的那一拍。
   暗下去这一步比亮起来更重要：不暗，玩家会以为四格都还是可点的。 */
.atlas.is-done .herb{ opacity: .38; }
.atlas.is-done .herb.is-on{
  opacity: 1;
  border-color: var(--amber-lit);
  color: var(--amber-lit);
  box-shadow: 0 0 20px rgba(216,180,95,.38);
}
.atlas.is-done .herb.is-on .herb__mark{ color: var(--amber-lit); }

.atlas__sign{
  margin: 0;
  font-size: max(.64em, 12px);
  letter-spacing: .18em;
  text-indent: .18em;
  line-height: 1.7;
  color: var(--ink-soft);
}


/* ============================================================
   答案盒 · 盒面五个转轮（S4）
   ============================================================
   三个几何比值，五个轮共用（见 puzzle.js 顶部那笔 33 挡的账）：

     --R  = --d × .35     符号环半径
     --g  = --d × --k     单个符号边长（--k 由 JS 按挡数给，上限 .225）
     --ro = --d × .34     中心大读数的直径

   ⚠️ 尺寸**只由 --d 一处驱动**。改一个数，整个轮等比缩放 ——
      五个轮各写一套 px 的话，风味轮会跟另外四个对不齐。
   ⚠️ 只有**风味轮**放大（--d-flavor）。另外四个挡数少（3–9），
      现在这个尺寸画得下**完整符号**，所以 SOP 那句
      「转轮上刻的是符号」（L214）对它们是成立的。
      风味轮挡数 33，环上只有 9.3px 一挡（d=140 时）—— 比符号的可读验收线
      22.4px 还小一倍半，所以它环上只刻**族剪影**，
      真正被读到的是正中央那个大读数（就是真实盒子上那个窗口）。
      这一条与 SOP 那句话的张力记在 进度.md，等店主拍板。
   ============================================================ */

/* ⚠️ v22：34em → 44em。
   34em 是从**文字屏**继承来的，从来没为"五个轮子并排"算过 ——
   那正是桌面档和矮横屏的溢出（136px / 150px）的来由。
   算账：一行五个要 4×104 + 168 + 4×22 = 672px，
         加框左右内边距（20px×2）= 712px，取 44em（@17px = 748px）留 36px。
   全局改是安全的：44em 真正接管要等字号撞到 17px 上限（视口 ≥ 739px），
   而且**永远不会超过视口**（min(44em, 0.84×视口)）——
   739–930 这一段只是让盒面宽一点、轮子离内壁远一点，不影响任何断言。 */
.scene--box{
  max-width: 44em;
  gap: 1.1em;
  font-size: clamp(13px, 2.3vw, 17px);
}

.answerbox{
  width: 100%;
  padding: clamp(12px, 3.4vw, 22px) clamp(10px, 3vw, 20px);
  display: flex;
  justify-content: center;

  background: linear-gradient(180deg, rgba(233,227,214,.045), rgba(233,227,214,.008));
  border: 1px solid rgba(233,227,214,.13);
  box-shadow: inset 0 0 0 1px rgba(233,227,214,.04);
  border-radius: 3px;

  opacity: 0;
  transform: translateY(14px);
  animation: rise var(--fade-slow) 260ms cubic-bezier(.22,.61,.36,1) forwards;
}

.dials{
  /* 手机竖屏 390 的账（⚠️ v22 按 390 **实算**，不是拿下限算）：
     --d-small = 15vw = 58.5px，列间距 3vw = 11.7px，
     四个小轮 4×58.5 + 3×11.7 = 269.1 ≤ 盒内宽 302.2（余 33.1）。
     ⚠️ 这里原来写着「4×62 + 3×8 = 272 ≤ 可用宽约 340」—— 那是拿 clamp 的
     **下限 62px** 算的，而 390 的手机拿到的是 17.5vw = 68.25px。
     那段算术描述了一个**根本不存在的视口**，看着让人放心，
     正是它让 320–615px 整段的溢出蒙混过关。
     总高 124(风味) + 10 + 58.5 ≈ 193 ≤ 盒盖比例给的高度。
     桌面档另走一条（一行五个），门槛是**宽 ≥930 且 高 ≥461**。 */
  --d-flavor: clamp(122px, 34vw, 168px);
  /* ⚠️ v22：17.5vw → 15vw、下限 62px → 52px。
     系数太大是 320–615px 整段溢出的病根；下限是拿掉 320–346px 那一段。
     ⚠️ **上限 96px 一个数不动** —— 它是"环上符号可读性"的锚
     （--g = --d × --k，k 封顶 .225）。压上限能更省事，
     但会让 ≥640px 的视口上符号一起变小，不值当。 */
  --d-small:  clamp(52px, 15vw, 96px);

  display: grid;
  grid-template-columns: repeat(4, auto);
  align-items: center;
  justify-content: center;
  gap: clamp(9px, 3vw, 18px) clamp(8px, 3vw, 24px);
}

.dial{
  --d: var(--d-small);
  --R: calc(var(--d) * .35);
  --g: calc(var(--d) * var(--k, .2));
  --ro: calc(var(--d) * .34);
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: .45em;
}

/* 风味轮单独占一整行、居中在上面（order 让它排到最前）。
   ⚠️ 用 order 而不是把它挪到 DOM 最前：puzzle.js 里 NAMES 的顺序
      就是命盘卡上五栏的顺序，DOM 顺序跟着它走，别为了版式去动数据顺序。 */
.dial--flavor{
  order: -1;
  grid-column: 1 / -1;
  --d: var(--d-flavor);
}

.dial__wheel{
  position: relative;
  width: var(--d);
  height: var(--d);
  border-radius: 50%;
  overflow: hidden;                  /* 转到边上的挡不许溢出去 */

  background: radial-gradient(circle at 50% 32%,
              rgba(216,180,95,.09), rgba(216,180,95,.015) 72%);
  border: 1px solid rgba(216,180,95,.26);

  /* ⚠️ touch-action: none 是硬要求。不写的话手机上竖向拖会被浏览器
      当成滚屏吞掉 —— 轮子一动不动，而**没有任何报错**。 */
  touch-action: none;
  cursor: grab;
}
.dial__wheel.is-dragging{ cursor: grabbing; }

.dial__rotor{
  position: absolute;
  inset: 0;
  transform-origin: 50% 50%;
  /* will-change 只加在转子上。加到每个 .dial__stop 上就是一次性
     提升上百个合成层（33 档 × 五轮），手机直接跪。 */
  will-change: transform;

  /* 环上那些"刻着的"符号：暗一档。真正亮的只有中心读数。 */
  color: rgba(216,180,95,.52);
}

/* 一挡。
   rotate(-i·step) translateY(-R)：转子转 rot 之后，正对指针那一挡的
   净角度 = -i·step + rot = 0，也就是**读数位天然正立、底下的挡是倒的** ——
   机械密码盘就长这样，不用逐帧反向补偿。
   ⚠️ --a 由 JS 写在行内（-i·step），这里只负责用。 */
.dial__stop{
  position: absolute;
  left: 50%;
  top: 50%;
  width: var(--g);
  height: var(--g);
  margin: calc(var(--g) / -2) 0 0 calc(var(--g) / -2);
  transform: rotate(var(--a, 0deg)) translateY(calc(var(--R) * -1));
}
.dial__stop > svg{ display: block; width: 100%; height: 100%; }

.dial__rim{
  position: absolute;
  inset: 0;
  pointer-events: none;              /* 拖动一律由 .dial__wheel 收 */
}

.dial__ring{
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  color: rgba(233,227,214,.16);
}

/* 正上方固定的指针 —— 这一挡"转到位了"，就是它指的。
   三角形用 border 拼，不引任何图标字体或图片。 */
.dial__pointer{
  position: absolute;
  left: 50%;
  top: 4%;
  transform: translateX(-50%);
  width: 0;
  height: 0;
  border-left: 4px solid transparent;
  border-right: 4px solid transparent;
  border-top: 6px solid var(--amber);
}

/* 中心大读数。
   ⚠️ 它是**风味轮唯一的可读出口**（环上 33 个只有 9.3px 一挡），
      也是自动验收的断言口：data-key 写的就是当前挡住的那个 key。
   ⚠️ 半径 0.17d 与环上最内侧 0.2375d 之间留着 0.0675d 的空隙 ——
      这是 --k 上限取 .225 换来的，一改就要重算，否则读数会压到符号上。 */
.dial__readout{
  position: absolute;
  left: 50%;
  top: 50%;
  width: var(--ro);
  height: var(--ro);
  transform: translate(-50%, -50%);
  display: grid;
  place-items: center;

  border-radius: 50%;
  background: radial-gradient(circle, rgba(216,180,95,.13), rgba(216,180,95,0) 72%);
  color: var(--amber-lit);           /* 全场最亮的一处 */
}
.dial__readout > svg{ display: block; width: 100%; height: 100%; }

.dial__label{
  font-size: max(.62em, 11px);
  letter-spacing: .2em;
  text-indent: .2em;
  color: var(--ink-soft);
  white-space: nowrap;
}

/* 桌面档：一行五个，风味轮靠 --d 大一圈居中，不再独占一行。

   ⚠️ v22：门槛从 `(min-width: 900px)` 改成 `(min-width: 930px) and (min-height: 461px)`。
   **min-height: 461px** —— 与 @media (max-height: 460px)（下面的矮窗口档）**正好互补**，不留缝。
   "一行五个"成立的前提是**框够宽**；矮窗口档的字号是 2.2vh 驱动的，框宽跟着缩 ——
   两个条件同时命中（900×420 那种）就把五个轮子挤出框外 150px。
   **900 → 930** —— #stage 两侧各吃 8vw，要用满 44em = 748px，视口至少要 748/0.84 = 891px；
   取 930 留 33px 余量。**别贴着 891 走** —— 那样只剩 8px，将来谁把 8vw 调成 9vw、
   或者冒出一条滚动条，900–908 这一小段立刻又溢出。
   块里的 --d-flavor / --d-small / repeat(5,auto) / gap / .dial--flavor **一个不动**。 */
@media (min-width: 930px) and (min-height: 461px){
  .dials{
    --d-flavor: 168px;
    --d-small:  104px;
    grid-template-columns: repeat(5, auto);
    gap: 22px;
  }
  .dial--flavor{ order: 0; grid-column: auto; }
}


/* ============================================================
   开盒 · 镜子里是你自己（S4）
   ============================================================
   设计方案 L916 / L939：盒盖内侧一面 60mm 小镜子，旁边印一句
   「你选的那些，就是你。」——**里面没有奖品，只有那面镜子**。
   镜中浮出的就是他自己那张命盘卡（和 reveal 那张是同一个构造函数，
   只有"秘密"栏那一格换掉了）。

   四段动画按顺序接上（⭐ v22 在中间插了一拍）：
     盒盖掀开 260ms → 镜面亮起 900ms → **秘密特写 1700ms（3200 走完）**
     → 卡浮出 3200ms → 牌名 3350 → 五行 3650 → 那件辅料的落点 3950
     → 盒盖内侧那句 4250。
   ⚠️ **特写与卡严格接续、零交叠（3200 / 3200）**，理由见 .mirror__secret 那一大段。
   gate 是 4300ms（game.js 的 open.gate），所以提前点击会一次落定到终态。
   ⚠️ 终态里盒盖是**不透明的 0**（它掀开、走掉了），和其余元素的
      "落到 opacity:1"相反 —— 所以 .is-settled 与 prefers-reduced-motion
      里它必须**单独写一条**，不能并进那一串选择器。
   ============================================================ */

.scene--open{
  max-width: 23em;
  gap: 1.3em;
  font-size: clamp(14px, 2.5vw, 18px);
}

.open{
  position: relative;
  width: 100%;
  display: flex;
  flex-direction: column;
  align-items: center;
  perspective: 900px;                /* 盒盖要 rotateX 掀开，没有它就是个平面 */
}

/* 盒盖：盖着的时候在镜面之上，掀开是绕**上边缘**翻上去 */
.open__lid{
  position: absolute;
  left: 0;
  right: 0;
  top: 0;
  height: 38%;
  z-index: 2;

  background: linear-gradient(180deg, rgba(216,180,95,.16), rgba(216,180,95,.05));
  border: 1px solid rgba(216,180,95,.30);
  border-radius: 3px;

  transform-origin: top center;
  transform: rotateX(0deg);
  animation: lidopen 1000ms 260ms cubic-bezier(.22,.61,.36,1) forwards;
}

/* 掀到 -104° 时它已经翻到镜面上方看不见了 —— 收尾淡出，
   免得那一条被压扁的边留在画面上。 */
@keyframes lidopen{
  0%   { opacity: 0; transform: rotateX(2deg); }
  20%  { opacity: 1; transform: rotateX(0deg); }
  100% { opacity: 0; transform: rotateX(-104deg); }
}

.mirror{
  position: relative;
  width: 100%;
  padding: clamp(10px, 3vw, 18px);
  display: flex;
  justify-content: center;

  border: 1px solid rgba(216,180,95,.30);
  border-radius: 3px;
  background:
    linear-gradient(160deg, rgba(216,180,95,.14), rgba(216,180,95,.02) 55%,
                    rgba(178,200,224,.05)),
    var(--bg-1);
  box-shadow: 0 0 34px rgba(216,180,95,.10);

  opacity: 0;
  animation: mirrorlit 1100ms 900ms cubic-bezier(.22,.61,.36,1) forwards;
}

@keyframes mirrorlit{
  from { opacity: 0; transform: translateY(6px) scale(.985); }
  to   { opacity: 1; transform: none; }
}

/* 镜面那道斜光。纯装饰，不承接任何语义。 */
.mirror__glass{
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: linear-gradient(112deg,
              rgba(255,255,255,.05) 0%, rgba(255,255,255,0) 34%,
              rgba(255,255,255,0) 62%, rgba(255,255,255,.035) 100%);
}

/* 镜中那张卡。⚠️ .card 自己的 rise 动画在这里会被下面这条盖掉
   （两条都写 opacity/transform），所以它由 .mirror__card 统一带进场，
   不再各动各的 —— 两段动画抢同一个属性，结果就是卡先浮完、
   镜面后亮起来，看着像两个东西。 */
.mirror__card{
  position: relative;
  width: 100%;
  opacity: 0;
  /* ⚠️ v22：1250 → 3200 —— 必须与 .mirror__secret 的 secretbeat 严格接续、零交叠。 */
  animation: cardrise 1000ms 3200ms cubic-bezier(.22,.61,.36,1) forwards;
}
.mirror__card .card{ animation: none; opacity: 1; transform: none; }

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

/* ============================================================
   ⭐ v22：给「秘密」单独一拍
   ------------------------------------------------------------
   镜面亮完（2000ms）之后、卡升起（3200ms）之前，先只浮出**那件辅料**
   的剪影 + 名字，停一下，再退场 —— 让玩家先看清"是哪一件"，
   再看见整张卡。原来那件辅料只在卡的第五行出现一次，和「温度」「甜度」
   平级，看不出它是主角（店主报「最后的秘密没有太突出」）。

   ⚠️ DOM 位置钉死在 .mirror 直下、.mirror__glass 之后、.mirror__card 之前：
      · 塞进 .mirror__card **里面** → 卡会多一行，_puzzle.html 的
        `rows.length === 5` 与 `.row__sym svg` 计数当场变 6 而红。
      · 当 .mirror 的**第二个在流内的 flex 子项** → 它会和卡并排、
        把卡挤到一边（.mirror 是 flex row，在流内的子项只能有卡一个）。

   ⚠️ **「然后呢？」落在哪儿**：特写**只放剪影 + 名字，不放那句话**。
      那句话（garnishes[].read）是独立的一行 .open__secretline，
      **永久留在最后**（在 .open__line 之前）。
      这么切是因为卡片第五行**已经有名字 + 字母 + 剪影**了 ——
      特写若要"陪着卡一起留着"，屏幕上就同名同形两处，
      那正是 v21 刚为镜中卡消掉的那种重复。

   ⚠️⚠️ **硬约束：secretbeat 在 3200ms 走完，cardrise 3200ms 才开始 ——
      严格接续、零交叠。** 一旦改成交叠，交叠的那几百毫秒里屏幕上会同时出现
      "特写里的剪影 + 名字"和"卡片第五行里同一个剪影 + 同一个名字"，
      那一瞬**真的像印了两遍**。**这条不许"顺手"改。**
   ============================================================ */

.mirror__secret{
  position: absolute;
  inset: 0;
  z-index: 2;                        /* 压在 .mirror__card（z-index:auto）之上 */
  display: grid;
  place-content: center;
  justify-items: center;
  gap: .5em;
  text-align: center;

  pointer-events: none;              /* 纯装饰，绝不接事件（同 .mirror__glass） */

  opacity: 0;
  animation: secretbeat 1500ms 1700ms cubic-bezier(.22,.61,.36,1) forwards;
}

/* 入 → 停 → 走，一条动画走完，终态天然是 0。
   0% 1700 · 20% 2000（镜面刚亮完的那一瞬）· 82% 2930 · 100% 3200（走光）
   ⚠️ 82%（而不是 75%）是**量出来的**：淡出那一段越短越好 ——
      卡片要 3200 才开始升、又要 ~300ms 才看得出，所以"特写已经淡掉、卡还没上来"
      的空窗如果太长，看着不像"停一下"，像卡了一下。
      75% 时空窗约 400ms；82% 把它压到约 290ms，同时把停留从 825ms 拉到 930ms。 */
@keyframes secretbeat{
  0%   { opacity: 0; transform: translateY(10px); }
  20%  { opacity: 1; transform: none; }
  82%  { opacity: 1; transform: none; }
  100% { opacity: 0; transform: none; }
}

.secret__mask{
  width: clamp(44px, 12vw, 64px);
  height: clamp(44px, 12vw, 64px);
  color: var(--amber);
}
/* symbols.js 画出来的 svg **只带 viewBox**，没有 width/height ——
   尺寸全靠父级给，所以这一条不能省（省了就缩成 0 高）。 */
.secret__mask > svg{ display: block; width: 100%; height: 100%; }

.secret__name{
  margin: 0;
  font-size: 1.3em;
  letter-spacing: .2em;
  text-indent: .2em;
  color: var(--amber-lit);
}

/* 那件辅料的那句落点话 —— 卡立住之后才出，出完**永久留在最后**。
   它在 .open__line 之前，两条一起留在终态。 */
.open__secretline{
  margin: 0;
  font-size: max(.9em, 12px);
  letter-spacing: .12em;
  text-indent: .12em;
  color: var(--ink);

  opacity: 0;
  animation: rise var(--fade-slow) 3950ms cubic-bezier(.22,.61,.36,1) forwards;
}

/* ============================================================
   镜中版式：牌升到卡头当主角（⭐ v21 · open 屏专有）
   ------------------------------------------------------------
   和 reveal 是**同一张卡、同一套数据、同一个 buildCard**，
   只多一个 opts.promoteArcana。牌号 + 牌名 + 那句牌语升到卡的上半部放大，
   五行参数缩在下面；底部**不再**出现「牌」那一行（牌只印一次）。
   ⚠️ 因此镜中那张卡是 **5 行**不是 6 行 —— `_puzzle.html` ⑥ 的断言跟着改了。
   ⚠️ **只收行距，一个字的字号都没收**：`.row__k` 现在是 max(.78em, 11px)，
      在 390 档**恰好卡在 11px**，`_s2.html` 那条「手机档不许 < 11px」是硬底线，
      这里碰它一下就红。（真正先坏的会是 .row__n 的秘密栏编号 A/B/C/D，
      390 档只有 9.52px —— 所以更不能整体缩字号：层级靠"主角放大"给，不靠缩参数。）
   ============================================================ */

.arcana{
  text-align: center;
  padding: .3em 0 .6em;
  border-bottom: 1px solid rgba(233,227,214,.09);   /* 和 .row 的横线同一支笔 */
  opacity: 0;
  transform: translateY(10px);
  /* ⚠️ v22：1400 → 3350 —— 保持"卡起（3200）+ 150"的相对关系。 */
  animation: rise var(--fade-slow) 3350ms cubic-bezier(.22,.61,.36,1) forwards;
}

/* 牌号 + 牌名：全卡最大的字（390 档 14px × 1.25 = 17.5px）。 */
.arcana__title{
  margin: 0;
  font-size: 1.25em;
  letter-spacing: .2em;
  text-indent: .2em;
  color: var(--amber-lit);
}

/* 那句牌语。不给它暗一档的色 —— 它是这张牌真正说的话，不是旁注。 */
.arcana__line{
  margin: .32em 0 0;
  font-size: 1em;
  letter-spacing: .12em;
  text-indent: .12em;
  color: var(--ink);
}

/* 五行参数：**只收行距**（原 .6em 0）。缩字号会撞上面那两条硬底线。 */
.card--arcana .row{ padding: .42em 0; }

/* 五行单独带入场，比牌名晚 300ms —— 先看见"抽到了哪张"，再看见"这杯是什么"。
   ⚠️ 它必须同时进 .is-settled 和 prefers-reduced-motion 两张清单（都加了），
      否则动画没放完就点击时，这五行会停在 opacity:0 上，看着像卡没画完。 */
.card--arcana .rows{
  opacity: 0;
  transform: translateY(10px);
  /* ⚠️ v22：1700 → 3650 —— 保持"卡起（3200）+ 450"的相对关系。 */
  animation: rise var(--fade-slow) 3650ms cubic-bezier(.22,.61,.36,1) forwards;
}

.open__line{
  margin: 0;
  font-size: 1.5em;
  letter-spacing: .24em;
  text-indent: .24em;
  color: var(--amber-lit);

  opacity: 0;
  /* ⚠️ v22：2000 → 4250 —— 保持"卡起（3200）+ 750"的相对关系，
     并且排在 .open__secretline（3950）之后：先"那件的落点"，再"整张卡就是你"。 */
  animation: rise var(--fade-slow) 4250ms cubic-bezier(.22,.61,.36,1) forwards;
}

/* ---------- 轻触提示 ---------- */

#hint{
  position: fixed;
  left: 50%;
  bottom: calc(5.5vh + env(safe-area-inset-bottom));
  transform: translateX(-50%);
  font-size: clamp(11px, 2.6vw, 13px);
  letter-spacing: .34em;
  text-indent: .34em;
  color: var(--ink-dim);

  opacity: 0;
  pointer-events: none;
}

/* 呼吸自成一体：自己淡入、起伏、淡出，再循环。
   不靠 opacity 过渡，否则会和动画抢同一个属性。 */
#hint.is-on{ animation: breathe 4.2s ease-in-out infinite; }
#hint.is-off{ animation: none; opacity: 0; }

@keyframes breathe{
  0%   { opacity: 0; }
  18%  { opacity: .62; }
  50%  { opacity: .30; }
  82%  { opacity: .62; }
  100% { opacity: 0; }
}

/* ---------- 声音开关 ---------- */

#sound{
  position: fixed;
  right: calc(4vw + env(safe-area-inset-right));
  bottom: calc(4.5vh + env(safe-area-inset-bottom));

  appearance: none;
  border: 0;
  background: transparent;
  color: var(--ink-dim);
  font-family: var(--serif);
  font-size: clamp(11px, 2.6vw, 13px);
  letter-spacing: .18em;

  padding: 12px;                 /* 触控目标撑到 44px 以上 */
  min-height: 44px;
  cursor: pointer;
  /* ⚠️ 原来是 .5。S5 之后默认**有声**，这条开关的分量就变了：
     默认静音时"找不到开关"无所谓（本来就没打算响），
     默认有声时"找不到开关"是**想安静却静不掉** —— 那是真难受。
     提到 .7：仍然很轻（hover 才到 .9 并转琥珀），但一眼扫得到。
     2026-10-13 店主定的。 */
  opacity: .7;
  transition: opacity .3s ease, color .3s ease;
}

#sound:hover,
#sound:focus-visible{ opacity: .9; color: var(--amber); outline: none; }

/* ---------- 返回上一级 ---------- */

/* 与 #sound 左右对称，同字号同内边距，只是默认藏起来。
   ⚠️⚠️ 这里必须用 transition，**绝不能改成 animation / @keyframes**。
   #back 在 #stage 之外，用进场动画就进不了 .scene.is-settled 那条兜底选择器，
   于是你得去 style.css 里那张"提前点击后要补显示的元素清单"上再加一条 ——
   而那张清单有两份（正常收尾一份、prefers-reduced-motion 一份），
   漏掉任何一份的症状是"提前点击之后这个按钮永远不出现"。
   （那份清单的历史坑见 进度.md，同一类事故记过三次。）
   用 transition 控显隐，两张清单一条都不用动。 */
#back{
  position: fixed;
  left: calc(4vw + env(safe-area-inset-left));
  bottom: calc(4.5vh + env(safe-area-inset-bottom));

  appearance: none;
  border: 0;
  background: transparent;
  color: var(--ink-dim);
  font-family: var(--serif);
  font-size: clamp(11px, 2.6vw, 13px);
  letter-spacing: .18em;

  padding: 12px;                 /* 触控目标撑到 44px 以上，与 #sound 同规 */
  min-height: 44px;
  cursor: pointer;

  /* 默认藏 —— 引子屏没有"上一屏"可退。
     藏起来时 pointer-events 也断掉，免得点在空气上也算一下。 */
  opacity: 0;
  pointer-events: none;
  transition: opacity .4s ease, color .3s ease;
}

#back.is-shown{ opacity: .7; pointer-events: auto; }

/* 到底了（第一题）：**还在场上，只是暗下来点不动**。
   变暗而不是藏掉 —— 藏掉会看起来像坏了（店主 2026-10-13 报的那条）。
   ⚠️ 这条**必须写在 .is-shown 之后**：两者特异性一样（都是 #id.class），
      靠文件顺序决胜，放前面会被上面那条的 opacity: .7 盖掉。
   配合 game.js 里 paintBack() 设的 disabled —— 那才是真人点不动的正解；
   这儿的 pointer-events 只是收口，顺带把 hover 让开。 */
#back.is-dead{
  /* .38 不是随手定的：站内"已经出局、但留在场上"的既有量级就是这个
     —— .atlas.is-done .herb 用的 .38。
     ⚠️ 别往 .22 以下压：0.22 × --ink-dim 的实际对比度只有 1.7:1，
        屏幕上接近水印 —— 店主报的那条就是"看着像坏了"，
        暗到读不出来会把同一个误会再犯一遍。 */
  opacity: .38;
  pointer-events: none;
  cursor: default;
  color: var(--ink-dim);
}

/* 暗着的那一态不跟手 —— hover / focus 只给能点的状态 */
#back:not(.is-dead):hover,
#back:not(.is-dead):focus-visible{ opacity: .9; color: var(--amber); outline: none; }

/* ---------- 小屏横屏 ---------- */

@media (max-height: 460px){
  #stage{ padding: 5vh 7vw; }
  .scene{ gap: 1em; }
  .scene .line{ line-height: 1.7; }
  #hint{ bottom: 3vh; }

  /* ⚠️ 这里必须逐屏写字号，不能图省事写成 .scene{font-size:...}。
     媒体查询在最底下，同特异度下会盖掉上面 .scene--q 的字号 ——
     一盖，"33em 宽"就跟着换成别的数，题干又要折行。
     矮窗口是横屏平板，宽得有限、高更有限，所以这批字号按 vh 走。 */
  .scene--prologue{ font-size: clamp(17px, 4vh, 26px); }
  .scene--divider{ font-size: clamp(15px, 3.6vh, 22px); }
  .scene--q{
    gap: .8em;
    font-size: clamp(13px, 2.4vh, 17px);
  }
  .scene--reveal{ font-size: clamp(12px, 2.2vh, 15px); }
  .scene--cup,
  .scene--box,
  .scene--open{ font-size: clamp(12px, 2.2vh, 15px); }

  /* 矮窗口 = 横屏平板：盒子那屏要一屏摆下五个轮，尺寸跟着高度走。
     固定 168px 的话，420px 高的窗口里风味轮 + 一行小轮 + 那句话就超了。
     ⚠️ v22：这一段（高 ≤460）与上面"桌面档一行五个"（≥930 且 高 ≥461）**正好互补**，
        所以矮窗口永远走"风味轮独占一行"那一套 —— 下面这个 --d-small 的
        clamp 下限 52px 是配着 .dials 那两条默认值一起定的。
        （原注释说的"一屏摆下五个轮"是指五个轮**都摆下**，不是挤成一排。） */
  .dials{
    --d-flavor: clamp(86px, 30vh, 128px);
    --d-small:  clamp(52px, 17vh, 76px);
  }
  .cup{ width: clamp(84px, 24vh, 120px); }
}

/* 尊重系统的"减少动态效果" */
@media (prefers-reduced-motion: reduce){
  *{ animation-duration: .01ms !important; animation-iteration-count: 1 !important; }
  #stage{ transition-duration: .01ms; }
  /* ⚠️ 这里要**逐个列出**会做升起动画的元素。
     原来漏了 .scene--q .aside（Q2 的关键句就在这一类里）——
     漏掉它就等于关掉动效时那一句停在 opacity:0，整句看不见。
     场上只有这一句是"缺了会毁掉机制"的，偏偏漏的就是它。 */
  .scene .line, .scene--divider .aside, .scene--q .aside,
  .scene--cup .aside, .scene--box .aside,
  .stem, .opts, .deck, .card,
  .cup, .atlas, .answerbox, .mirror, .mirror__card,
  .open__secretline, .open__line,
  .arcana, .card--arcana .rows{
    opacity: 1; transform: none;
  }
  /* 盒盖同 .is-settled：终态是"走掉了"，不能并进上面那一串 */
  .open__lid{ opacity: 0; transform: rotateX(-104deg); }
  /* ⭐ v22 的「秘密」特写：同上，终态也是"走掉了"。
     关掉动效的人看到的应当是**终态卡 + 那件辅料的落点话**，
     不该看到浮出来的特写，更不该看到"同名同形两处"。 */
  .mirror__secret{ opacity: 0; }
  /* 牌动的是 translate/scale，不是 transform，得单独收（同 .is-settled） */
  .deck__card{ opacity: 1; translate: none; scale: none; }
}
