/* ============================================================
   科德大陆 · 符文试炼 —— 游戏页样式（/game.html）

   ⚠️ 本页不引用演示页的 style.css：
      style.css 里的 `.stage` 是三栏 Grid，与本页布局同名会互相覆盖
      （本次已踩过这个坑），因此游戏页布局类统一加 `game-` 前缀。

   主题调色板、基础 reset、按钮、状态胶囊、判题阶段指示器、代码面板
   均已抽到 theme.css。

   ⚠️ 颜色一律引用变量，不得写死色值 —— 否则切换主题时会「漏色」。
      半透明色用 rgba(var(--xxx-rgb), α) 形式。

   加载顺序：vendor/bootstrap.min.css → theme.css → game.css
   布局依据：实施计划文档 6.6「答题界面布局规范」（方案 C：分屏 + 游戏静默）
   ============================================================ */

/* ============================================================
   〇、世界美术调色板（固定 · 不随主题切换）
   ------------------------------------------------------------
   设计决定：「固定一套世界美术 + 画框跟随主题」
     · 世界是"画作" —— 科德大陆应当有稳定的视觉身份，
       不因学生换了 UI 主题就变成紫色
     · 阶段底衬是"画框" —— 那个跟随主题（theme.css 的 --stage-bg）

   为什么不跟随主题（三条硬理由）：
     1. 颜色一旦承载语义就不能漂移：老师会说"点蓝色 NPC"，
        若 NPC 随主题变色，这句指令在糖果主题下就不成立
     2. 换成像素素材后成本 ×N：每套美术都要做 4 遍
     3. 世界失去"地方感"，更像滤镜坏了

   ⚠️ 未来替换为像素素材时：删掉本区块，
      world.js 的 palette() 一并删除，配色改由贴图决定。
   ============================================================ */
:root {
  /* 地形
     明度取「中等」（≈55%），既不像深色主题那样沉，也不像明亮主题那样扎眼，
     嵌进深色或浅色 UI 里都不违和 —— 方案 C「折中」。 */
  --world-grass-a: #7a9b76;
  --world-grass-b: #71926d;
  --world-path: #cbb187;

  /* NPC：三态各有独立色，且**不随主题变化**。
     ⚠️ 这三个变量原名是 `--world-stele*`（石碑）—— 2026-09-18 随旧链路
        退场一并改名：触发单位早已是 NPC，留着旧名字只会误导后来人 ✓
     取值围绕草地明度向两侧拉开：可挑战偏亮、未解锁偏暗、已通关亮且高饱和 */
  --world-npc: #b3c6d8;          /* 可挑战 */
  --world-npc-locked: #5d6b78;   /* 未解锁 */
  --world-npc-done: #7ee89a;     /* 已通关 —— ⚠️ **已退役**（2026-09-20 晚 ✓）：状态不再染到 NPC 身上
                                     （改成头顶标记 + 脚下光环 ✓ 见 Plan/5 §9.40 ✓）；
                                     变量留着，供将来"低调提示"用（如一行的名字染色 ✓） */

  /* ★任务标记配色（2026-09-20 晚 ✓）—— ⚠️它们**不再被运行时读取** ✓，但**不是废弃** ✓：
     标记现在是**像素素材** ✓（`tiles.png` 图集帧 12~15 ✓ 见 Plan/5 §9.41 ⑧ ✓），
     颜色由 `tools/gen-hero-sprite.mjs` **烘进图里** ✓ ⇒ 这 8 个变量现在的身份是"**口径来源**" ✓：
     谁要改标记配色，必须**两处一起改** ✗（这里 + 生成器的 `P` 表 ✓），否则"CSS 上看着变了、画面没变" ✓
     ⚠️ 另有一处**仍然读 CSS** ✓：矢量兜底（`--world-rune` / `--world-mark-done` ✓ 见 world.js ✓）
     理由与障碍那支一样：标记承载**状态语义** ✓ 且**必须与草地拉开** ✓
     符号色一律"**亮底配深字 / 深底配浅字**" ✓（对比度靠这条，不靠调色板直觉 ✓） */
  --world-mark-quest: #ffd23c;         /* 可挑战：金黄底 ✓（MMO 里那个"!"就是黄的 ✓） */
  --world-mark-quest-fg: #4a3200;      /* 可挑战：深棕符号 ✓ */
  --world-mark-done-bg: #3ddc84;       /* 已通关：亮绿底 ✓（像 ✅ ✓） */
  --world-mark-done-fg: #0b3d22;       /* 已通关：深绿符号 ✓ */
  --world-mark-draft: #8d99a6;         /* 筹备中：灰蓝底 ✓ */
  --world-mark-draft-fg: #f7fafc;      /* 筹备中：白符号 ✓ */
  --world-mark-locked: #4d5866;        /* 未解锁：深灰底 ✓ */
  --world-mark-locked-fg: #c2ccd6;     /* 未解锁：浅灰符号 ✓ */

  /* 障碍（**不能走的格子** · 2026-09-20 加 ✓）
     ★为什么单独一支色：它承载的是**玩法语义**（"这儿过不去" ✓）⇒
       必须与草地 / 路面在**明度**上拉开（岩灰 ≈35% vs 草地 ≈55% ✓）——
       与 NPC 的"颜色 + 形状双线索"是同一条原则：**别让学生靠猜** ✓
     ⚠️ 将来换成像素素材时，这一支与整块调色板一起删除 ✓（见本区块顶部说明 ✓） */
  --world-block: #5b6068;          /* 障碍：岩壁 / 树丛 / 水岸 —— 都用它画 ✓ */
  --world-block-top: #767c85;      /* 障碍顶面：上亮下暗 ⇒ 平面色块也像"立着" ✓ */

  /* 状态标记 */
  --world-rune: #2ec4b6;           /* 可挑战 = 实心符文 / 未解锁 = 锁形 */
  --world-mark-done: #146b34;      /* 已通关 = 对勾（深绿，在亮绿柱身上才看得清） */
  --world-hero: #ffb347;           /* 主角：暖橙，与绿草地靠色相拉开 */

  /* NPC 名牌：自带深色描边，因此在任何地形上都可读，
     不依赖主题色，也不怕将来换成明亮的美术 */
  --world-label: #ffffff;
  --world-label-locked: #d4dde6;
  --world-label-stroke: #1f2a22;
}

body.game-page {
  height: 100vh;
  overflow: hidden;
}

/* ============================================================
   一、6.6 布局骨架
   ============================================================ */
.layout {
  display: grid;
  /* ⚠️ 必须用 minmax(0, …)：裸 `1fr` 等于 minmax(auto, 1fr)，
     其 auto 下限取内容的 min-content 宽度，会被 textarea 撑破 */
  grid-template-columns: minmax(0, 55fr) minmax(0, 45fr);
  grid-template-rows: auto minmax(0, 1fr);
  grid-template-areas:
    "toolbar toolbar"
    "left    right";
  gap: 10px;
  height: 100vh;
  padding: 10px;
}

/* ============================================================
   二、顶部工具栏（固定，不随内容滚动）
   ============================================================ */
.toolbar {
  grid-area: toolbar;
  display: flex;
  align-items: center;
  gap: 16px;
  flex-shrink: 0;
  padding: 8px 16px;
  background: var(--bg-panel);
  border: 1px solid var(--border);
  border-radius: 10px;
}

.toolbar__brand {
  font-weight: 700;
  letter-spacing: 2px;
  color: var(--accent);
  white-space: nowrap;
}

.toolbar__stats { display: flex; gap: 8px; flex-wrap: wrap; }

.toolbar__nav {
  margin-left: auto;
  display: flex;
  align-items: center;
  gap: 14px;
}

.toolbar__nav a {
  color: var(--text-dim);
  font-size: 12px;
  text-decoration: none;
  white-space: nowrap;
  transition: color .15s;
}
.toolbar__nav a:hover { color: var(--accent); }

/* 教师管理入口（`#adminLink`，2026-09-20）
   —— **只对白名单老师可见** ✓（显隐由 game/auth.js 探 `/api/admin/whoami` 决定 ✓）
   胶囊外观（内边距 / 描边 / 圆角 / hover）来自 theme.css 的 .navlink，
   这里只把默认的 --text-dim 换成 --accent，表明它通向"管理面"。
   ⚠️ 必须写成「两条类选择器」：单类会被 .toolbar__nav a 反压。
   ⚠️ 上一版这里挂的是 `.link--demo`（「代码版演示」入口）—— 那个页面 2026-09-18 已随
      旧链路删除 ✗ ⇒ 规则是**死代码**，本轮顺手换成在用的这个（同一手法 ✓）*/
.toolbar__nav .link--admin { color: var(--accent); }

/* ★账号格（`#userMenu` · 2026-09-21 用户选 C 方案 ✓）
   —— 昵称是个**可点的胶囊**，点开才见「退出登录」✓（破坏性动作藏一层 ✓）

   ⚠️ 为什么直接用 Bootstrap 的 dropdown ✗：`bootstrap.bundle.min.js` 已经带齐
      「点外部关闭 / Esc / 方向键 / aria-expanded」一整套 ✓ —— 自己写只会写漏 ✓
      （本页**只在页尾引了它** ✓ 且 `data-*` 走 document 事件委托 ⇒ 与 Phaser 无冲突 ✓）
   ⚠️ 但**外观得自己接主题** ✗：菜单底色走 Bootstrap 的 `--bs-dropdown-*` ✓，
      默认那套与本站面板色不一致 ⇒ 下面用主题变量覆写 ✓
      （`--bs-dropdown-*` 是 Bootstrap 5.3 的**公开变量** ✓ 比写死色值稳 ✓
       也与 theme.js 同步 `data-bs-theme` 的做法一致 ✓） */
.toolbar__nav .chip--btn {
  font-family: inherit;          /* <button> 不继承字体 ⇒ 不写会突然变成系统字体 ✗ */
  font-size: 12px;
  cursor: pointer;
  transition: color .15s, border-color .15s;
}
.toolbar__nav .chip--btn:hover { color: var(--accent); border-color: var(--accent); }

/* 菜单：跟主题走（⚠️ 只覆写变量，别整个重画 ✗ —— Bootstrap 的间距/阴影/动画都在用 ✓） */
.toolbar__nav .dropdown-menu {
  --bs-dropdown-bg: var(--bg-panel);
  --bs-dropdown-border-color: var(--border);
  --bs-dropdown-link-color: var(--text-dim);
  --bs-dropdown-link-hover-color: var(--accent);
  --bs-dropdown-link-hover-bg: var(--bg-panel-2);
  --bs-dropdown-link-active-bg: var(--accent);
  font-size: 12px;
  min-width: 132px;
  padding: 4px;
}

/* ★交互提示（2026-09-21 加 ✓ 配合 `F` ✓）
   ⚠️ 位置=**舞台底部居中、抬到经验条之上** ✓：经验条是全宽贴底的 ✓、
      入口图标在右下 ✓ ⇒ 这里不压任何东西 ✓
   ⚠️★必须 `pointer-events: none` ✗：它飘在舞台上 ⇒ 挡住点击就是"点了没反应" ✓
   ⚠️ `z-index: 17` ✗：卡片是 18 ⇒ 让卡片压住它 ✓（卡片开着时它本来就该收起了 ✓） */
.interact-tip {
  position: absolute;
  left: 50%;
  bottom: 52px;
  transform: translateX(-50%);
  z-index: 17;
  padding: 4px 10px;
  border: 1px solid var(--border);
  border-radius: 999px;
  background: var(--bg-panel);
  color: var(--text);
  font-size: 12px;
  white-space: nowrap;
  pointer-events: none;
}

/* ============================================================
   三、左侧：题面 + 代码编辑器
   ============================================================ */
.left {
  grid-area: left;
  display: flex;
  flex-direction: column;
  gap: 10px;
  min-width: 0;   /* ⭐ 允许被压缩，防止被内部 textarea 撑破轨道 */
  min-height: 0;
}

/* ---- 题面（限高 + 独立滚动，可折叠）---- */
.task {
  display: flex;
  flex-direction: column;
  /* 题面改为渲染 OJ 真实题面后内容显著变长（输入/输出格式 + 样例 + 限制），
     故从 36% 放宽到 46%；若实际使用仍显局促可继续上调 */
  max-height: 46%;
  padding: 12px 14px;
  background: var(--bg-panel);
  border: 1px solid var(--border);
  border-radius: 10px;
  overflow: hidden;
}

.task__head {
  display: flex;
  align-items: center;
  gap: 10px;
  flex-shrink: 0;
}

.task__title {
  margin: 0;
  font-size: 15px;
  letter-spacing: 1px;
}

.task__toggle {
  margin-left: auto;
  padding: 3px 10px;
  font-size: 12px;
  border: 1px solid var(--border);
  border-radius: 6px;
  background: var(--bg-panel-2);
  color: var(--text-dim);
  cursor: pointer;
  font-family: inherit;
}
.task__toggle:hover { border-color: var(--accent); color: var(--accent); }

/* ⚠️ 2026-09-25 起本类（连同下面的 `.task.is-collapsed .task__meta`）**没有使用方** ✓：
   `#taskMeta` 元素已注释停用（见 `game.html` 该元素处 ✓），来源并进了 `.problem-limits` ✓
   规则**特意留着**：回滚只需取消 `game.html` 那一处注释 ⇒ 少改一个文件 ✓ */
.task__meta {
  margin-top: 6px;
  font-size: 11px;
  font-family: var(--mono);
  color: var(--text-dim);
  flex-shrink: 0;
}

.task__body {
  margin-top: 10px;
  overflow-y: auto;   /* ⭐ 独立滚动 */
  min-height: 0;
}

/* 题面正文（.problem-body）与剧情（.empty-hint）样式在 theme.css，
   本页不再单独定义 */

.task.is-collapsed { max-height: none; }
.task.is-collapsed .task__body,
.task.is-collapsed .task__meta { display: none; }

/* 操作行末尾的提示（如「Ctrl + Enter 提交」）*/
.quest__hint {
  margin-left: auto;
  font-size: 11px;
  color: var(--text-dim);
  font-family: var(--mono);
}

/* ---- 环节面板（一个任务 = 多个环节）----
   旧链路的代码编辑器（`.editor` / `#codeInput`）已随其整体退场（2026-09-18）；
   作答输入区现在由本面板自带（选项 / 文本 / 代码 三选一，见 game/quest.js）✓ */
.quest {
  flex: 1;
  display: flex;
  flex-direction: column;
  min-height: 0;
  padding: 12px;
  background: var(--bg-panel);
  border: 1px solid var(--border);
  border-radius: 10px;
}
/* ⚠️ 本元素 display:flex ⇒ 必须显式声明 [hidden]，否则 hidden 会被反压失效 ✗
   （同一个坑主题样式里也踩过，见 .dialog__avatar-text） */
.quest[hidden] { display: none; }

.quest__head {
  display: flex;
  align-items: baseline;
  gap: 10px;
  flex-shrink: 0;
}
.quest__title { font-size: 15px; font-weight: 700; letter-spacing: 1px; }
.quest__progress {
  margin-left: auto;
  font-size: 11px;
  font-family: var(--mono);
  color: var(--text-dim);
}

/* 环节进度条（可点：允许回看 / 跳着做） */
.quest__steps {
  display: flex;
  gap: 6px;
  margin: 10px 0 0;
  padding: 0;
  list-style: none;
  flex-shrink: 0;
  flex-wrap: wrap;
}
.step {
  display: flex;
  align-items: center;
  gap: 5px;
  padding: 4px 9px;
  border: 1px solid var(--border);
  border-radius: 999px;
  background: var(--bg-panel-2);
  font-size: 11px;
  color: var(--text-dim);
  cursor: pointer;
  transition: all .15s;
}
.step:hover { border-color: var(--accent); color: var(--accent); }
.step__mark { font-family: var(--mono); font-weight: 700; }
.step.is-current { border-color: var(--accent); color: var(--accent); }
.step.is-done { color: var(--bs-success, #2f9e63); border-color: var(--bs-success, #2f9e63); }
.step.is-draft { opacity: .5; }
/* 可跳过环节：名字前加一个空心菱形，与必做区分（不靠颜色） */
.step.is-optional .step__name::before { content: "◇ "; }

.quest__meta {
  margin: 8px 0 0;
  font-size: 11px;
  font-family: var(--mono);
  color: var(--text-dim);
  flex-shrink: 0;
}

/* 作答区（按题型只显示其中一个） */
.quest__body {
  flex: 1;
  display: flex;
  flex-direction: column;
  min-height: 0;
  margin-top: 10px;
  overflow-y: auto;
}

.quest__options { display: flex; flex-direction: column; gap: 8px; }
.quest__options[hidden] { display: none; }

.opt {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 9px 12px;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--bg-panel-2);
  cursor: pointer;
  transition: all .15s;
}
.opt:hover { border-color: var(--accent); }
.opt input { accent-color: var(--accent); flex-shrink: 0; }
/* 标签 A/B/C/D 是**按展示位置算出来的** —— 选项被打乱后，"第几个"才有意义 ✓ */
.opt__key {
  flex-shrink: 0;
  width: 18px;
  font-family: var(--mono);
  font-weight: 700;
  color: var(--accent);
}
/* 选项文本可能多行（如整段代码片段）⇒ 保留换行与缩进，别被折叠成一行 ✗ */
.opt__text {
  font-family: var(--mono);
  font-size: 13px;
  color: var(--text);
  white-space: pre-wrap;
}

/* 题干里的换行是**有意为之**（例：把「这两行语句」分行列出）
   ⚠️ `<p>` 默认把换行折叠成空格 ⇒ 会把题目拼成一行 ✗ */
.quest__stem { white-space: pre-wrap; }

.quest__input,
.quest__code {
  flex: 1;
  min-height: 120px;
  width: 100%;
}
.quest__snippet { margin: 8px 0 0; padding: 10px; }

.quest__actions {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-top: 10px;
  flex-shrink: 0;
  flex-wrap: wrap;
}

/* 反馈区：pass / fail / warn / error 四种语气，用 --bs-* 语义色（不自己造色） */
.quest__feedback {
  margin-top: 10px;
  padding: 9px 12px;
  border-radius: 8px;
  border: 1px solid var(--border);
  background: var(--bg-panel-2);
  font-size: 12px;
  line-height: 1.7;
  flex-shrink: 0;
  max-height: 30%;
  overflow-y: auto;
}
.quest__feedback[hidden] { display: none; }
.quest__feedback--pass { border-color: var(--bs-success, #2f9e63); color: var(--bs-success, #2f9e63); }
.quest__feedback--fail { border-color: var(--bs-danger, #dc3545); color: var(--bs-danger, #dc3545); }
.quest__feedback--warn { color: var(--bs-warning-text-emphasis, #b8860b); }
.quest__feedback--error { border-color: var(--bs-danger, #dc3545); color: var(--bs-danger, #dc3545); }
.quest__feedback .compile { margin: 6px 0 0; }

/* 环节奖励那一行（★2026-09-24 ✓ 与"通关 / 新解锁"同住在右下**奖励框**里 ✓）
   ⚠️ 只补字重与节奏 ✗：颜色**继承** `.reward` 的 `--accent`（不自己造色 ✓） */
.quest__gain {
  margin: 0;              /* 首行贴住框内 padding ✗ 间距交给下面两行的 margin-top ✓ */
  font-weight: 700;
}

.quest__cleared {
  margin: 6px 0 0;
  font-weight: 700;
  color: var(--accent);
}

/* ★解析（2026-09-22 ✓ 见 `Plan/8` 九·A ✓）—— ★只出现在"**答错**"之后 ✗
   ⚠️★必须**改色** ✗：父级 `.quest__feedback--fail` 把整块染成**红色**（`--bs-danger` ✓）——
      而这是**讲解**，不是又一条错误 ✓ ⇒ 红字连成一片学生**读不下去** ✓
   ⚠️ 左色条 + 中性字色（与 `.book__item` 的"左条表状态"同一套语言 ✓） */
.quest__explain {
  margin: 8px 0 0;
  padding-left: 8px;
  border-left: 3px solid var(--accent);
  color: var(--text-dim);
  line-height: 1.75;
}

/* 通关时"新解锁"追加的那一行（与冒险手册同一套语义色） */
.quest__unlock {
  margin: 6px 0 0;
  font-weight: 700;
  color: var(--accent);
}

/* ---- 冒险手册（已解锁 / 未解锁）----
   未解锁的**照样列出**（灰显 + 锁形）—— 它就是"还有什么可争取"的目标清单 ✓
   左色条 + 半透明两层线索，不靠颜色单独区分（与 NPC 状态标记同一原则） */
.book { font-size: 12px; line-height: 1.75; }

.book__group {
  margin: 12px 0 6px;
  font-size: 12px;
  letter-spacing: 1px;
  color: var(--accent);
}
.book__group:first-child { margin-top: 0; }

.book__item {
  display: flex;
  flex-direction: column;
  gap: 2px;
  margin-bottom: 6px;
  padding: 7px 10px;
  border: 1px solid var(--border);
  border-left-width: 3px;
  border-radius: 6px;
  background: var(--bg-panel-2);
}
.book__item.is-owned { border-left-color: var(--accent); }
.book__item.is-locked { opacity: .5; }
.book__name { font-weight: 700; color: var(--text); }
.book__item.is-owned .book__name { color: var(--accent); }
.book__desc { color: var(--text-dim); }

/* 背包里的数量标（缺口 C5：道具从"有没有"变成"有几个"）*/
.bag__count {
  font-family: var(--mono);
  font-size: 12px;
  color: var(--accent);
}

/* 临时仓库里的数量标（背包满时暂存；与背包数量刻意用**不同颜色** ⇒ 一眼能分清在哪 ✓）*/
.bag__stash {
  font-family: var(--mono);
  font-size: 12px;
  color: var(--warn, #d9a23b);
}

/* 背包里的动作（使用 / 从仓库领取）*/
.bag__ops {
  display: flex;
  gap: 6px;
  margin-top: 4px;
}
.bag__btn {
  font: inherit;
  font-size: 12px;
  padding: 2px 10px;
  border-radius: 6px;
  border: 1px solid var(--border, #2a3546);
  background: transparent;
  color: var(--text);
  cursor: pointer;
}
.bag__btn:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
.bag__btn:disabled { opacity: .5; cursor: default; }

/* 动作反馈（使用 / 领取）：成功与失败分色；它**跨重渲染**保留 ⇒ 学生看得见结果 ✓ */
.bag__note { font-size: 12px; color: var(--text-dim); margin: 4px 0; }
.bag__note.is-bad { color: var(--danger, #e06c75); }

/* 物品分类页签（外壳复用 .bag__btn，只加"当前选中"的强调 ✓）
   ⚠️ 页签的字**来自内容表**（items.json 的 categories）⇒ 改称呼不必动前端 ✓ */
.bag__tabs { display: flex; flex-wrap: wrap; gap: 6px; margin: 4px 0 8px; }
.bag__btn.is-active { border-color: var(--accent); color: var(--accent); }

/* ============================================================
  舞台内入口 + 覆盖层（**背包 / 图鉴 / 冒险手册 / 商店** · 实现记录见 Plan/5 §9.23）
  ------------------------------------------------------------
  ⚠️ 入口**必须在舞台内** ✗：全屏模式下工具栏与右下页签**都会消失**
     （Fullscreen API 只渲染全屏元素及其后代）⇒ 放外面 = 全屏后打不开 ✗
  ⚠️ 卡片 `z-index: 18` 是**算过的**：压在画布之上，但要**低于**
     `#unlockToast(20)` / `#fxLayer(24)` / `#dialogLayer` ✓
     因为"在背包里领回道具"时，那个道具入手卡**必须还能看见** ✓✓
  ============================================================ */

/* 入口排：舞台**右下角**（图标 only；名称与快捷键走**悬浮 tips** ✓）
   ------------------------------------------------------------
   ⚠️ 为什么只用图标（2026-09-19 定）：文字挤在角落会变成"网页按钮排"，
      与世界画面打架 ✗；名称 + 快捷键交给 tips ⇒ 既不占地方又不丢可发现性 ✓
   ⚠️ 无边框 / 无按钮底 ⇒ 每个按钮**必须**有 `aria-label` ✗
      （否则读屏读到的只是一个 emoji，等于没有名字 ✓）
   ⚠️ 仍是 z-index 19（压在卡片 18 **之上**）⇒ 卡片打开时还能点它关闭 ✓
   ⚠️ 横排 ⇒ **宽度不再是瓶颈**（4 × 32px ≈ 140px ✓）；瓶颈转移到**底部**
      —— 对话面板铺满底部 ⇒ 对话进行中让位（见下面 `:has()` 那条 ✓）
   ⚠️ 顺序 = 阅读顺序 = 背包 · 图鉴 · 手册 · 商店 ✓（与 E/C/H/T 一一对应 ✓
      —— T 不是 S ✗：`S` 是移动键的下 ✓，两者撞键会"又开商店又往下走" ✓ 见 main.js 的选键注释 ✓） */
.stage-tools {
  position: absolute;
  right: 10px;
  /* ★★`10` ⇒ **`16`** ✗✓（★2026-10-03 用户拍板 ✓）——
     ★唯一目的：与**左下那排快捷栏同一条水平线** ✓ —— ★两边**都是 32px 高** ✓
       ⇒ ★`bottom` 相同 ＝ **底边对齐 ＝ 中心对齐** ✓（两个口径同一个数 ✓ 省一道选择 ✓）
     ★★改前是**实测**的（不是看着像 ✓）：★快捷栏中心距底 **33** ✓ · 右下图标 **27** ✓
       ⇒ ★差 **6px** ✓ —— ★就是这一行 ✓
     ⚠️★读数天然带**舞台那 1px 边框** ✗（`getBoundingClientRect` 量的是含边框的外框 ✓）
       ⇒ ★以后量"离舞台底多少"一律**看差值** ✓ 或先减 1 ✓（★否则会把 `bottom:10` 读成 11 ✓）
     ⚠️★顺带清掉一条老账 ✗✓：★经验条占 **`3~12px`** ✓ ⇒ 图标底从 10 抬到 16 ⇒
       ★**不再"压在条上"** ✓（★原来那句"图标会压在条上 ⇒ 已接受"从此不成立 ✓）
     ⚠️★代价（★如实写 ✗）：★这**动了 2026-09-20 的旧决定**"图标先不上移"✓ ⇒
       ★★新口径 ✗：★**一切以"与左下快捷栏同一条水平线"为准** ✓ ——
       ★以后谁再挪快捷栏，**记得一并看这里** ✓（★两处各写一个 `bottom` ⇒ 迟早分叉 ✓）
     ★★文档 ✗✓：★这轮的**口径正文**写在 **`Plan/15` §五·六**（快捷栏那一节 ✓ ——
       ★日常用语版 ✓ 连"为什么搬 / 量测那 1px / 新规矩"都在那儿 ✓）⇒
       ★★改之前先读那一节 ✗（★"代码指文档、文档写口径"这条链别断 ✓）*/
  bottom: 16px;
  z-index: 19;
  display: flex;
  align-items: center;
  gap: 4px;
}

/* 对话进行中：入口排**淡出并失效** ✓
   ⚠️ 理由：`.dialog` 是 `inset: 0` + 面板贴底 ⇒ 图标会正压在面板右下角上 ✗
   ⚠️ 用 `:has()` 纯 CSS 判断（不去惊动 dialog.js ✗）：
      不支持 `:has()` 的浏览器只是"照常显示"⇒ 退化成老行为，不会坏 ✓ */
.game-stage:has(#dialogLayer:not([hidden])) .stage-tools {
  opacity: 0;
  pointer-events: none;
}

/* ---- 全屏游戏模式入口（2026-10-04 加 ✓ 见 `Plan/19` §七 ✓）----
   ★位置 = 舞台**右上角** ✗（★用户拍板："挪舞台右上角看效果"✓ ——
     ★与左上角的 `Lv.` 徽标左右对称 ✓ 两角各一个"状态 / 开关" ✓）
   ⚠️★`top/right` 是相对 **`.game-stage`** 算的 ✓（★这是**必须先移出 DOM** 的原因 ✗：
     它原来住在 `.stage-tools` 里，而那一排自己就是 `position: absolute`
     ⇒ 留在里面写 `top/right` 会**相对那一排**算 ⇒ 挪不动 ✗ 见 `game.html` 那段注释 ✓）
   ⚠️★**`z-index` 与两个邻层的关系** ✗：
     · `19` = 与 `.level-chip` / `.stage-tools` **同层** ✓（★那两处也是 19 ✓ 风格一致 ✓）
     · ★覆盖卡片是 `18` ⇒ ★按钮**压在卡片之上** ✓（★与右下那排入口同款行为 ✓
       —— ★卡片开着时还能退出全屏 ✓ 这是刻意的 ✓）
   ⚠️★**tips 必须改成"朝下"** ✗✗：`.stage-tool-btn::after` 默认在按钮**上方**
     （`bottom: calc(100% + 6px)` ✓）—— ★本按钮贴着舞台**上边** ⇒ 朝上会被
     `overflow: hidden` **裁掉** ✗ ⇒ 下面那条把 `bottom` 换成 `top` ✓
     （★其余照旧：仍 `right: 0` 右对齐 ✓ 仍在舞台内 ✓ 见上方 `.stage-tools` 那条"右对齐"理由 ✓）
   ⚠️★两态都**不隐藏** ✗✓：
     · `.is-on`   ⇒ 正在全屏（★按钮再点一次 = 退出 ✓ 所以要看得见 ✓）
     · `.is-locked` ⇒ 未解锁（★**灰但可点** ✓ —— 点了会说"还差 N 分钟游戏时长" ✓
       ★比 `disabled` 好 ✗：`disabled` 点不动也**不说话** ⇒ 学生以为坏了 ✓）*/
#fsBtn {
  position: absolute;
  top: 10px;
  right: 10px;
  z-index: 19;
}
/* ★tips 朝下（★理由见上 ✓）—— ★只改方向，**不动**它的配色 / 动画（都来自主题变量 ✓）*/
#fsBtn::after {
  top: calc(100% + 6px);
  bottom: auto;
}
.stage-tool-btn.is-on {
  color: var(--accent);
  transform: scale(1.08);
}
.stage-tool-btn.is-locked {
  opacity: .45;
  cursor: not-allowed;
}

/* 单个入口：**没有边框、没有底** ✓ —— 反馈只靠 hover 的放大 / 提亮 ✓ */
.stage-tool-btn {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 32px;
  height: 32px;
  padding: 0;
  border: none;
  background: none;
  color: var(--text);
  font-size: 19px;
  font-family: inherit;
  line-height: 1;
  cursor: pointer;
  border-radius: 50%;
  /* ⚠️ 世界底色深浅不一（草地 / 路 / 石板…）⇒ 纯 emoji 会看不清 ✗
     加**深色投影**兜底（不是边框、不是按钮底 ✓）
     固定深色与 .fx-float / .bag-slot 同一种做法（那两处同样用固定深色 ✓） */
  text-shadow: 0 1px 3px rgb(0 0 0 / .65);
  transition: transform .12s, background .12s;
}
/* ⚠️★本元素设了 `display: inline-flex` ⇒ **必须**显式声明 `[hidden]` ✗✗（2026-09-24 补 ✓）
   （★与 `.quest[hidden]` / `.dialog[hidden]` / `.unlock-toast[hidden]` 是**同一个坑** ✓）
   ★★不补这条的**实测**后果 ✗：`#shopBtn` / `#guildBtn` / `#avatarBtn` 明明写了 `hidden`，
   却**照旧显示在右下角** ✓ —— ★"决定下过了、界面没跟上" ✓ 最容易被当成"没做" ✓
   ⚠️ 用 `display: none`（**不是** `visibility` ✓）：要**不占位** ✗
      否则工具条会留一个 32px 的空档 ✓（`gap: 4px` 也在 ⇒ 看着像"有个隐形按钮" ✓） */
.stage-tool-btn[hidden] { display: none; }
.stage-tool-btn:hover {
  transform: scale(1.18);
  background: rgba(var(--accent-rgb), .22);
}
/* 无边框按钮在键盘上会"看不见焦点"✗ ⇒ 必须显式给焦点环 ✓ */
.stage-tool-btn:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}
/* 打开中的那个：用一层底衬当"按下"（仍然不是边框 ✓） */
.stage-tool-btn[aria-pressed="true"] { background: rgba(var(--accent-rgb), .3); }

/* ============================================================
  等级徽标（**舞台左上角** ✓）+ 经验条（**舞台底部 · 全宽** ✓）
  2026-09-20 按用户拍板搬位：经验条从左上角 ⇒ **底部全宽**；左上角改放**等级** ✓
  ------------------------------------------------------------
  ★配色**刻意不跟主题**（不变量二的**例外**，见 Plan/5 §9.27）：
    像素框的颜色是"游戏画面的一部分"，与 `--accent` 那类**语义色**分开 ✓
  ⚠️ **像素九宫格三条铁律**（缺一就糊/裂 ✗）：
    ① `slice` 数字与 `border-width` **相等** ⇒ 角 1:1 ✓
    ② `repeat` 用 **round/repeat**，**绝不用 `stretch`** ✗（会把边上的像素拉长 ✗）
    ③ 目标尺寸尽量取**整数倍** ✓（否则像素落半格 ⇒ 发虚 ✗）
  ⚠️ `background-clip: padding-box` 必写 ✗：否则素材四角**切掉的格子**会被元素底色补回 ⇒ 圆角白做 ✓
     且它**必须写在 `background` 之后**（简写会重置它 ✗ —— 见下方那条注释 ✓）
  ============================================================ */

/* ---- 左上角：等级（**真数据** ✓）----
   为什么放这儿：原来这里是经验条 ✓，用户要求"左上角改放真数据：等级" ✓
   （⚠️ 备选里的"当前地图"**本轮不做** ✓ —— 用户：另作设定位置 ✓）
   ⚠️ 仍**沿用那套 7px 像素框**（`exp-frame.png` ✓）：徽标要"厚"一点才像牌子 ✓ */
.level-chip {
  position: absolute;
  top: 10px;
  left: 10px;
  z-index: 19;
  display: inline-flex;
  align-items: center;
  gap: 4px;
  padding: 2px 8px;
  border: 7px solid transparent;
  border-image: url(/assets/ui/exp-frame.png) 7 / 7px repeat;
  /* 像素图必须 ✓：不写它，条一放大就糊 ✗ */
  image-rendering: pixelated;
  /* 框内底：**固定色** ✓（让字在 6 套主题下都清楚 ✓）—— `background-color` 长写，别用简写 ✗ */
  background-color: rgba(10, 14, 10, .78);
  background-clip: padding-box;
  font-family: var(--mono);
  font-size: 12px;
  letter-spacing: .5px;
  color: #cfe08a;
  pointer-events: none;   /* 纯显示 ⇒ 不挡世界的点击 ✓ */
  user-select: none;
}
.level-chip__label { font-weight: 700; color: #9fb06a; }
.level-chip__num { font-variant-numeric: tabular-nums; }

/* ---- 舞台底部：经验条（全宽 ✓ 细条 ✓ 数字居中 ✓）----
   ★尺寸全是用户定的 ✓：
     · **不要太高** ⇒ 整条 **16px** = 3 上框 + 10 轨道 + 3 下框 ✓
     · 边框 **3px** 就够"把条与地图 / 背景区分开" ✓（素材 `exp-bar-frame.png` ✓
       = `gen-nine-slice --slice 3 --corner 1 --colors "#0a0d06,#c6e86a,#86b03a,#46581c"` ✓）
   ★`z-index: 9` ⇒ **低于对话面板（10）** ⇒ 对话时被盖住 ✓（用户："接受" ✓）
     省掉了 `.stage-tools` 那套 `:has()` 让位逻辑 ✓（那是图标必须可点才要的 ✓，条不用 ✓）
   ★★2026-10-03 更新 ✗✓（★用户拍板 ✓）：★右下四图标已**抬到 `bottom:16px`** ✓ ——
      ★理由：★与**左下那排快捷栏同一条水平线** ✓（★实测中心距底 **33 ↔ 33** ✓）
      ⇒ ★★**"图标压在条上"这条老账作废** ✓（★条下方现在**两侧都让出来**了 ✓）
      ⚠️★上面那句"**图标先不上移**"是 **2026-09-20** 的旧决定 ✗ ⇒
         ★留在这儿只作**沿革** ✓ ★**别再照它做** ✗（★新口径见 `.stage-tools` 那条 ✓）
      ★★文档 ✗✓：★正文见 **`Plan/15` §五·六** 的 **§10"位置与尺寸改档"**（★日常用语版 ✓）
   ⚠️ 它**只在舞台内** ✓：整窗级全屏时舞台就是整屏 ⇒ 全屏下照样在 ✓；
      而挂到 `body` 底部会**在全屏时消失** ✗（§9.23 同一条理由 ✓） */
.exp-bar {
  position: absolute;
  left: 0;
  right: 0;
  /* ★离底 3px（2026-09-20 **四改** ✓ 用户："经验条整体上移 2~3 PX，当前会被舞台区边缘遮挡部分"）：
     `bottom: 0` 时条**本身**没被裁 ✓，被裁的是**数字** ✗ ——
     数字故意比条高 5px ⇒ 上下各探出 2px ✓，下面那 2px 就掉到舞台底边之外 ✗，
     而舞台是 `overflow: hidden` ✓ ⇒ **数字下缘被削平** ✓✓（正是用户看到的现象 ✓）
     ⇒ 抬 3px：数字下缘离底还有 1px ✓，条与数字**都完整** ✓
     （代价：条下方露出一条 3px 的世界画面 ✓ —— 用户要的就是这个观感 ✓） */
  bottom: 3px;
  z-index: 9;
  /* ★高度（2026-09-20 **三改** ✓ 用户："轨道改为 5"）：
     **9px** = **2** 上框 + **5** 轨道 + **2** 下框 ✓（一版 16 → 二版 11 → 三版 9 ✓）
     ⚠️ 边框 / `slice` **不动** ✓（仍 2px ✓ = 素材的 `--slice 2` ✓）——
        只有**轨道**变薄 ✓ ⇒ 无需重出素材 ✓
     ★数字**刻意不受条高约束** ✓（用户："数字字号可以不受约束，悬浮到条中间并超出也是可以的"）：
       字号 **11px** ⇒ 在 9px 的条上**上下各溢出约 3px** ✓ = "浮在条中间"的观感 ✓
       ⇒ 这是**有意为之** ✗ 不是量错 ✓：数字要读得清，条要够细，两者不必妥协 ✓ */
  height: 9px;
  display: flex;
  align-items: center;
  border: 2px solid transparent;
  border-radius: 4px;         /* 内圈半径 = 4 - 2 = 2 ✓ 与素材的 `--corner-inner 2` 对齐 ✓ */
  border-image: url(/assets/ui/exp-bar-frame.png) 2 / 2px repeat;
  image-rendering: pixelated;
  background-color: rgba(10, 14, 10, .78);
  background-clip: padding-box;
  font-family: var(--mono);
  font-size: 11px;
  letter-spacing: .5px;
  pointer-events: none;       /* 纯显示 ⇒ 不挡世界的点击 ✓ */
  user-select: none;
}
/* 轨道铺满条的内区 ✓（绝对定位 ⇒ 数字才能稳稳压在它**上面**居中 ✓） */
.exp-bar__track {
  position: absolute;
  inset: 0;
  background: #131a0e;
  box-shadow: inset 0 0 0 1px #060904;
  overflow: hidden;
}
.exp-bar__fill {
  display: block;
  width: 0;
  height: 100%;
  background: #a8c256;
  /* 数值变化**滑**过去，不硬跳 ✓（与 HUD 的 `countTo` 同一意图 ✓） */
  transition: width .28s ease-out;
}
/* 刻度层（2026-09-20 加 ✓ 用户："绿色经验渲染后几乎看不到刻度，能否在条内部加入灰/白隔断，
   与边框上刻度对应"）
   ★它**故意跨过边框**（`inset: -2px` = 站到 border 的外缘 ✓ —— 边框正好 2px ✓）——
     因为"边框上那点刻度感"其实是九宫格**平铺的接缝** ✗（不是真刻度 ✓）
     ⇒ 与其去"对齐"接缝，不如让**同一组线同时盖住边框与条内部** ✓
        ⇒ "与边框刻度对应"就**天然成立** ✓✓（本来就是同一根线 ✓）
   ★周期 = **10%**（整条的十分之一 ✓）：读作"经验百分之十"✓ ——
     是**比例**刻度而非固定像素栅格 ⇒ 将来曲线换成阶梯也照样成立 ✓
   ⚠️ 必须排在 track **之后**、数字**之前**（DOM 顺序 = 绘制顺序 ✓）：
      绿填充压不住它 ✓，而数字仍压在最上层 ✓
   ⚠️ 白度是唯一旋钮 ✓：先试 42% ⇒ 压在**亮绿填充**上仍偏淡 ✗（实测截图 ✓）
      ⇒ 提到 **55%** ✓ —— 压在亮绿 / 深轨 / 纯黑框上都看得清 ✓（一条线通吃 ✓）
      嫌浓/嫌淡就只改这一个数 ✓（别动周期 10% ✗，那是有含义的 ✓） */
.exp-bar__ticks {
  position: absolute;
  inset: -2px;
  pointer-events: none;
  background: repeating-linear-gradient(
    to right,
    rgb(255 255 255 / .55) 0 1px,
    transparent 1px 10%
  );
}
/* 数字：**条中央** ✓ —— 自带暗底 ⇒ 无论压在"绿色填充"还是"深色轨道"上都读得清 ✓
   （`margin: 0 auto` 居中；`position: relative` 保证盖在轨道之上 ✓）
   ★★配色（2026-09-20 晚 **五版定稿 · 采纳用户指的方向：纯白** ✓）：
     · 一版 `#e8f0d8` / 二版 `#efffa8`（米白系）⇒ 只买到**亮度**，不够醒目 ✗
     · 三版 `#c6ff3d`（满饱和黄绿）⇒ 用户："**不要绿色系**" ✗
     · 四版 `#ffe14a`（亮金黄）⇒ 用户："**受到绿色衬托有些混淆**" ✗
       ★★根因（五版才承认的一条 ✗）：**金黄与黄绿是"相邻色相"** ✓ ⇒
         压在**绿条**上时，两者在色相上**沾亲** ✓ ⇒ 即便亮度与饱和都拉满，
         边缘仍会互相"咬" ✓ ⇒ **换色相解决不了** ✗ ——
         除非换成与绿**对着干**的冷色 ✓（可那类色在 RPG 里全被别的系统占了 ✗：
         红=血 / 青蓝=MP / 紫=稀有度 ✓ —— 这条在四版注释里已记过 ✓）
     · 五版 **`#ffffff`（纯白）** = ★**零色相** ✓ ⇒ 它**不可能被任何底色"染"** ✓✓
       —— 这正是本条的题眼 ✓：**要"不被绿衬托出混淆"，就该用一个"没有颜色"的字** ✓
   ⚠️★但**光换白还不够** ✗ —— 纯白是**亮度的天花板** ✓（1.0 ✓）⇒ **颜色这一层已经没有空间了** ✗;
     用户说的"**混淆**"另有主因 ⇒ ★**底衬只有 55%** ✗：
       压在**亮绿填充**上时，与白字真正相邻的是"填充 × 45%" ≈ **暗橄榄** ✓（亮度仅 0.085 ✓）
       ⇒ 白字对比度只有 **7.8** ✓ —— **够读** ✓，但**边缘**会与绿"咬"住 ✓，
         而 11px 小字的笔画又细 ⇒ 这就是"混淆"感的来源 ✓
     ⇒ 本版**同时**把底衬提到 **70%** ✓（见下面 `background` 那行 ✓）：
       与白字相邻变成"填充 × 30%"（亮度 ≈ 0.038 ✓）⇒ 对比度 **11.9** ✓✓（**提升 53%** ✓）
       ★这一项**随时可撤** ✗（一行 ✓）：嫌"字下面像贴了块黑胶布"就改回 `.55` ✓
   ⚠️ 为什么**不用** `text-shadow` 描边 ✗（本项目世界标签走的是 Phaser `stroke` ✓ 同一意图 ✓）：
      11px 的等宽小字，描边会**吃掉笔画** ⇒ 白字一瘦就发虚 ✗ ⇒ 宁可加厚**底衬** ✓
   ⚠️ 别改成**深色** ✗：底衬即使 70% 也只是"变暗"⇒ 深字压在亮绿填充上仍会糊掉 ✓
   ⚠️ 还想更狠只有**一个**旋钮了 ✓（本次**没动** ✗）：`font-weight: 700`（笔画更粗 ✓）
   ⚠️ 历史备选（记着免得下次再从零选 ✓）：琥珀 `#ffe14a` ✓ / 黄绿 `#c6ff3d` ✓ / 青蓝 `#5ce1ff` ✓
      —— 它们各自被否的理由都在上面 ✓ **别再往回退** ✗ */
.exp-bar__num {
  position: relative;
  margin: 0 auto;
  /* ★数字**浮在条中央**：字号由 `.exp-bar` 给（11px ✓），比 9px 的条高 ✓
     ⇒ 上下各溢出约 3px ✓（用户明确要求 ✓："悬浮到经验条中间并超出也是可以的"）
     ⇒ 那层暗底（`background` ✓）就是它的"底衬"：压在世界画面 / 填充上都读得清 ✓
     内边距/圆角按**数字自己**的尺寸给 ✓（不跟条走 ✗ —— 它已经不跟条同高了 ✓） */
  padding: 1px 5px;
  border-radius: 3px;
  /* ★底衬 55% → **70%**（2026-09-20 晚 五版 ✓ 用户："数字颜色受到绿色衬托有些混淆" ✓）——
     白字本身已到亮度天花板 ✓ ⇒ 唯一还能"把它从绿里垫出来"的就是这层 ✓
     与白字相邻的由"填充 × 45%"变"填充 × 30%" ⇒ 对比度 7.8 → **11.9** ✓（详见上面那段 ✓）
     ⚠️ 撤它只需改回 `.55` ✓；**别改成不透明** ✗（会把条"切断"成两段 ✓ 就不像细条了 ✓） */
  background: rgb(0 0 0 / .7);
  color: #ffffff;
  line-height: 1;
  font-variant-numeric: tabular-nums;
}
/* 已达等级上限：填充暗一档 + 数字给出说法 ✓
   ⚠️ 上限态**必须说出来** ✗：条满了等级却不动 ⇒ 不说清学生只会以为坏了 ✓
   ⚠️ **新玩家一开始就是上限态** ✓（上限 = 1 + 已通关主线 ✓）—— 这是规则本身，
      不是 bug ✓；完整解释在 `aria-label` 里（条是 `pointer-events: none` ⇒ 挂不了 title ✓） */
.exp-bar.is-capped .exp-bar__fill { background: #6d7a48; }
/* ⚠️ 上限态的"**压暗一档**"仍然保留 ✗（2026-09-20 晚 五版同步 ✓）：
   条满了、等级却不动 ⇒ 只有**看得出"暂时到头了"** 才不会让人以为坏了 ✓
   ⇒ **`#d4d8dc`**：★**中性偏冷的白**（**刻意不带绿** ✓ —— 与常态纯白同一"**无色相**"口径 ✓）、
     只把亮度降一档 ✓ ⇒ 语义还在 ✓、对比度仍有 ≈ **8.3** ✓（按上限填充算 ✓）
   ★注意它**不是**唯一线索 ✗：上限态还有"**· 上限**"三个字 ✓ + 填充变暗（`#6d7a48` ✓）
     ⇒ 所以这里的压暗**可以温和** ✓（不必压到发灰 ✗ —— 一版 `#cfd6b0` 就是压过头了 ✓） */
.exp-bar.is-capped .exp-bar__num { color: #d4d8dc; }

/* ⚠️ 矮舞台（`.is-compact`）**只藏等级徽标** ✓ —— 经验条**留着** ✓（2026-09-20 改
   ⚠️ 原来的规则是"整条都藏" ✗，那是因为老条挂在**左上角**：紧凑卡片 `inset: 8% 6%`
      在 150px 舞台上顶部只剩 ~12px 空带，而老条 ~20px ✗ ⇒ 必然压住卡片头 ✓
   ⇒ 现在条在**底部** ✓，而紧凑卡片的底边早就为图标让出了 **46px**
      （`.game-stage.is-compact .stage-card { padding-bottom: 46px }` ✓）
      ⇒ 一条 16px 的细条正好住在那一带里 ✓ 不再抢卡片的地方 ✓
   ⚠️ 等级徽标**仍然要藏** ✗：它还在左上角、还是 ~28px ⇒ 一样会压卡片头 ✓
      代价很小 ✓：紧凑 = 分屏 / 小窗 ⇒ **工具栏就在上面** ✓（等级在 HUD 里 ✓）
      （2026-09-20 用户已撤掉工具栏的 EXP ✓ ⇒ 那条"经验仍在 HUD"的说法只在**等级**上成立 ✓） */
.game-stage.is-compact .level-chip { display: none; }

/* ⚠️ 舞台容器是 `tabindex="-1"`（main.js 在键盘关闭覆盖层时会把焦点交给它 ✓）
   ⇒ 必须**去掉它的焦点环** ✗：它是"焦点的收容所"，不是可操作控件 ✓
   （不去掉的话，按 Esc 关卡片后舞台会闪一圈轮廓 —— 比 tips 残留还扎眼 ✗）
   注：这不是"naked focus is bad"那种反模式 ✓ —— 焦点确实移到了它身上，
       只是它本身不需要视觉提示（里面没有可操作内容 ✓）*/
.game-stage:focus { outline: none; }

/* ---- 悬浮 tips（名称 + 快捷键）----
   ⚠️ **右对齐**（`right: 0`）：舞台是 `overflow: hidden` ✗ ——
      最右那个（商店）若居中/左对齐，tips 会溢出舞台被**裁掉** ✗；
      右对齐后每个都往回长，一定落在舞台内 ✓
   ⚠️ `pointer-events: none`：tips 不能挡住 hover 自己（否则会闪烁 ✗）
   ⚠️ 配色走主题变量 ⇒ 6 套主题自动跟随 ✓（不变量一 / 二 ✓） */
.stage-tool-btn::after {
  content: attr(data-tip);
  position: absolute;
  right: 0;
  bottom: calc(100% + 6px);
  padding: 3px 8px;
  border: 1px solid var(--border);
  border-radius: 6px;
  background: var(--bg-panel);
  color: var(--text);
  font-size: 11px;
  line-height: 1.5;
  white-space: nowrap;
  opacity: 0;
  transform: translateY(2px);
  pointer-events: none;
  transition: opacity .12s, transform .12s;
}
.stage-tool-btn:hover::after,
.stage-tool-btn:focus-visible::after {
  opacity: 1;
  transform: translateY(0);
}

/* 覆盖层：**居中卡片 70% 宽高**（背包 / 图鉴共用 ✓）
   `inset: 15%` ⇒ 15% × 2 + 70% = 100% ⇒ 想改大小**只动这一个数** ✓ */
.game-stage > .stage-card {
  position: absolute;
  inset: 15%;
  z-index: 18;
  padding: 8px 10px;
  /* 不透明面板：卡片里的字要清晰 ✓（世界从**四周**露出来 ✓） */
  background: var(--bg-panel);
  /* 卡片感：圆角 + 描边 + 投影（照 .unlock-toast 的做法 ✓） */
  border: 1px solid var(--accent);
  border-radius: 10px;
  box-shadow: 0 10px 34px rgb(0 0 0 / .45);
  /* 放不下时**必须自己滚**，不能溢出卡片 ✗ */
  overflow: auto;
}

/* ---- 卡片标题行（图鉴这类"单一列表"卡片用 ✓）----
   ⚠️ 背包不用它：背包的头部是**页签 + 搜索**（自带 .bag__head ✓）
      两者共用的是 `.stage-card` 外壳，不是内部结构 ✓
   ★**2026-09-19 改了滚动方式**（用户反馈：滚动条压住 ✕ ✗）：
      原来"整张卡滚 + 标题行 sticky"⇒ 滚动条长在**卡片右边缘**，
      而 ✕ 也在右边缘上 ⇒ 内容一多，条就**压在 ✕ 上** ✗
      ⇒ 改成 **头固定 + 正文独立滚**（见下面那两条 `:has()` 规则 ✓）
      ⇒ 滚动条只在**正文框内**，从头部**下方**开始 ⇒ 永远碰不到 ✕ ✓✓
      ⇒ 顺带**撤掉全部 sticky 补丁**（负 margin / `top: -8px` / 不透明底 ✓）——
        头部本来就参与不了滚动，那套补丁成了纯负担 ✗ */
.stage-card__head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  /* 固定行：不被正文挤扁 ✓（flex 布局下必须显式写 ✗） */
  flex: 0 0 auto;
  margin-bottom: 6px;
  padding-bottom: 6px;
  border-bottom: 1px solid var(--border);
}

/* ---- 有标题行的卡片：**头固定 + 正文独立滚** ✓（图鉴 / 手册 / 商店）----
   ⚠️ 用 `:has(> .stage-card__head)` 判断（**不是**给每张卡再加个类 ✓）：
      将来新加的卡片只要带标题行，自动拿到这套行为 ⇒ 少一件"记得加类"的事 ✓
      `.game-stage` 里已在用 `:has()` ✓（见 `.stage-tools` 那条，含降级说明 ✓）
   ⚠️ **背包刻意留在外面** ✓：它没有标题行（头是页签 + 搜索 ✓）、
      也**没有 ✕**（靠入口图标 / `Esc` 关 ✓）⇒ 老行为下没有这个冲突，
      而它的"网格 + 详情 + 底部"要改独立滚动是另一档改动 ✗（见 Plan/5 §9.24 待办 ✓） */
.stage-card:has(> .stage-card__head) {
  display: flex;
  flex-direction: column;
  /* 卡片自己**不再滚** ⇒ 滚动条交给正文 ✓ */
  overflow: hidden;
}
/* 正文：吃掉剩余高度并自己滚 ✓
   ⚠️ `min-height: 0` 是"flex 子项能滚"的前提 ✗ —— 缺了它子项只会**撑破卡片**，
      滚动条反而长到卡片上去（正是本次要修的现象 ✓） */
.stage-card:has(> .stage-card__head) > .book {
  flex: 1 1 auto;
  min-height: 0;
  overflow: auto;
  /* 正文与滚动条之间留一点缝，字不贴着条 ✓ */
  padding-right: 4px;
}

/* 矮舞台（`.is-compact`，由 main.js 按舞台高度加 ✓）⇒ **卡片放大一点** ✓
   ⚠️ 为什么非补不可（2026-09-19 实测）：手册的标题行（标题 + 页签换行）约 **97px**，
      而 `inset: 15%` 在 150px 高的舞台里只给卡片 **102px**
      ⇒ 改成"正文独立滚"之后，正文被算成 **0px** ⇒ 内容**看不见也滚不动** ✗✗
      （改之前是"整卡滚"，至少还能把内容滚上来 —— 所以这是本次改动**引入的退化** ✓）
   ⇒ 矮的时候少留白：`inset: 8% 6%` ⇒ 卡片 ≈126px、正文 ≈30~50px ⇒ 至少能用 ✓
   ⚠️ 只在 `.is-compact` 下生效 ⇒ 正常舞台（1920×1080 卡片 ~379px）的观感完全不变 ✓ */
.game-stage.is-compact .stage-card {
  inset: 8% 6%;
  /* ⚠️ 再留一条**与入口图标同高**的底边距（2026-09-19 实测）：
       矮舞台下卡片几乎贴到舞台底边，而四个入口图标固定在右下角
       （`z-index: 19` **压过卡片 18** ✓）⇒ 会盖住卡片底部的内容 ✗
       实测：背包底栏的「整理」按钮被 🎒 图标压掉一半 ✗（交互也一起坏 ✗）
       46px ≈ 图标 32px + 上下留白 ✓ ⇒ 底栏与图标不再相遇 ✓
       （三张有标题行的卡同样受益：最后一行不会被图标压住 ✓）
       ⚠️ 正常舞台**不加**这条：卡片 `inset: 15%` 本来就离底边 81px ✓（实测无重叠 ✓） */
  padding-bottom: 46px;
}

/* 两行式标题行（冒险手册：标题 + 类别页签 ✓）
   整块一起钉住 ⇒ 滚到列表底部也能直接切类别 ✓ */
.stage-card__head--stacked {
  flex-direction: column;
  align-items: stretch;
  gap: 6px;
}
.stage-card__toprow {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
}
/* 页签在标题行里不再需要外边距（.bag__tabs 自带 4px 0 8px ✓） */
.stage-card__head--stacked .bag__tabs { margin: 0; }
.stage-card__title {
  margin: 0;
  font-size: 13px;
  font-weight: 700;
  letter-spacing: 1px;
  color: var(--accent);
}
/* 关闭按钮：与入口同"胶囊"语言，但更紧凑（它在卡片里，不必抢眼 ✓） */
.stage-card__close {
  padding: 0 8px;
  border: 1px solid var(--border);
  border-radius: 6px;
  background: var(--bg-panel-2);
  color: var(--text);
  font-size: 12px;
  font-family: inherit;
  line-height: 20px;
  cursor: pointer;
  transition: all .15s;
}
.stage-card__close:hover { border-color: var(--accent); color: var(--accent); }

/* ---- 两档自适应（由 main.js 按舞台尺寸加 `.is-compact` ✓）----
   扁矮舞台（分屏答题 / 小窗）里"网格 + 右侧详情"会被 170px 详情吃掉横向空间 ✗
   ⇒ 改成**上下排**：网格在上、详情在下，**都在中间区里滚** ✓（头 / 底仍固定 ✓）
   ⚠️ 2026-09-19 网格改成**固定每行 10 格** ⇒ 列数不再随宽度变 ✓
      实测 1920×1080 舞台 933×541：卡片 ≈653px ⇒ 网格区 ≈453px
      ⇒ 10 列时格子约 **38~45px**（原先 auto-fill 是 48px ✓）——
      嫌小就缩窄右侧详情栏（170px）或加宽卡片 ✓（别改回 auto-fill ✗ 那会让列数又飘起来 ✓）*/
.game-stage.is-compact #itemBag .bag__main {
  flex-direction: column;
  /* ⚠️ **必须显式 stretch**（2026-09-19 实测修 ✗）：
       上面 `.bag__main` 写着 `align-items: flex-start` ✓ —— 那在**横排**时是对的
       （详情栏不跟着拉高 ✓），但一改成**竖排**，交叉轴就是**宽度**了
       ⇒ `flex-start` 让 `.bag__pane` **收缩到内容宽** ✗
       ⇒ 网格是 `1fr` 列 + 空槽位 ⇒ 内在宽度 = 10×4 + 间隙 = **112px** ✓
       ⇒ 实测格子被压成 **4×4px** ✗✗（数字与推断完全吻合 ✓）
       （这是**既有 bug**：与本轮"底栏/10 列"无关 ✓，只是这次量出来才看见 ✓） */
  align-items: stretch;
}
.game-stage.is-compact #itemBag .bag__detail {
  /* ⚠️ 竖排时详情栏落在网格**下方** ⇒ 必须**限高**（`max-height`）✗
     否则详情一长就把网格挤成 0 高 ✗（它自己也滚 ✓） */
  flex: 0 1 auto;
  width: 100%;
  max-height: 50%;
  overflow: auto;
}
.game-stage.is-compact #itemBag .bag__search { flex: 0 0 110px; }

/* ============================================================
  背包：网格 + 详情（设计定稿见 Plan/5 §9.22）
  ------------------------------------------------------------
  ★品质色**跨主题固定**（"史诗=蓝"必须在教室指令里稳定成立 ✓，
    与 --ok / --danger 同一条理由：语义色不能漂移 ✓）
    ⇒ 因此**写在这里、不放 theme.css** ✗（不变量一：那里的变量都会随主题变）
  ⚠️ 白色档在浅色主题（明亮/糖果/护眼/暖阳）下会消失 ✗
    ⇒ 一律加**深色描边/底衬** ✓（照世界名牌的做法 ✓）
  ============================================================ */
:root {
  --q-common: #ffffff;
  --q-rare: #3ddc84;
  --q-epic: #4a9dff;
  --q-legendary: #a97bff;
  --q-relic: #ff9f2e;
  /* 描边/角标统一用的深色（保证浅色主题下也看得见 ✓） */
  --q-ink: #10161f;
}
[data-q="common"] { --q: var(--q-common); }
[data-q="rare"] { --q: var(--q-rare); }
[data-q="epic"] { --q: var(--q-epic); }
[data-q="legendary"] { --q: var(--q-legendary); }
[data-q="relic"] { --q: var(--q-relic); }

.bag__head { display: flex; align-items: center; gap: 10px; margin: 4px 0 8px; }
.bag__head .bag__tabs { margin: 0; flex: 1; }

/* 搜索框：**右侧** ✓ 只搜名称 ✓（§9.22 ⑥）*/
.bag__search {
  flex: 0 0 150px;
  padding: 4px 9px;
  border: 1px solid var(--border);
  border-radius: 999px;
  background: var(--bg-panel-2);
  color: var(--text);
  font-size: 12px;
  font-family: inherit;
}
.bag__search:focus { outline: none; border-color: var(--accent); }

/* ---- 背包卡片：**头固定 · 网格自己滚 · 底栏固定 · 详情撑满** ✓
   （2026-09-19 按用户定稿的布局图改，见 Plan/5 §9.22 ⑧）----
   ⚠️ 为什么不再"整卡滚"：
      容量与「整理」原来跟在网格后面 ⇒ 往下一滚，**容量与整理一起滚走了** ✗
      （顺带解决：卡片右边缘的滚动条会压住右上角**搜索框** ✗ —— 条现在只在网格框里 ✓） */
.bag.stage-card {
  display: flex;
  flex-direction: column;
  overflow: hidden;
}
.bag.stage-card > .bag__head { flex: 0 0 auto; }

/* 中间：**左列（网格 + 底栏）｜右列（详情）** ✓ —— 这一层**自己不滚** ✓ */
.bag__main {
  display: flex;
  flex: 1 1 auto;
  gap: 10px;
  /* ★`stretch`：详情栏与左列**同高** ⇒ 一直延伸到卡片底部 ✓（布局图要的效果 ✓）
     ⚠️ 原来是 `flex-start`：那时详情栏**随内容高** ✗ */
  align-items: stretch;
  /* ⚠️ `min-height: 0` 是"flex 子项能滚"的前提 ✗（缺了它子项只会撑破卡片） */
  min-height: 0;
  overflow: hidden;
}

/* 左列：网格（滚）+ 底栏（固定）✓ */
.bag__pane {
  display: flex;
  /* ★**吃掉剩余宽度**（2026-09-20 **二改** ✓，两轮都是用户反馈 ✓）：
     第一轮（当天上午）用户反馈"格子右边空一大条、详情被钉在 170px 里疯狂折行" ✗
       ⇒ 把 `flex: 1` 让给了**详情栏** ✓
     第二轮（当天下午）用户反馈"**物品详情占用比例变大**" ✗ —— 实测卡片 652px 时
       详情栏 **339px（52%）**、左列只有 **281px**、格子被压成 **46px**（本该 56）✗
     ⇒ 真凶是**详情栏的 basis**（见 `.bag__detail` 的注释 ✓）：`flex: 1 1 auto`
       的 basis 取"内容宽"，而描述是一整行不折的长文本 ⇒ 内在宽度极大 ⇒
       它**抢**宽度、左列被 `flex-shrink` 压到内容宽以下 ✗
     ⇒ 现在：**左列吃掉剩余宽度** ✓（网格轨道自带上限 `minmax(0, 64px)` ⇒
       只会"最多长到 5×64+间隙"，不会真把宽度全吃光 ✓），详情栏固定理想宽 ✓
     ⚠️ `1 1 auto` = 有剩余则长 ✓、窄卡片时**仍能缩** ✓
        （`min-width: 0` 见下 ⇒ 极端窄时网格与详情一起按比例缩，不会溢出 ✓） */
  flex: 1 1 auto;
  flex-direction: column;
  /* ⚠️ `min-width: 0` 必须留着：否则窄卡片里网格会把详情栏**挤出卡片** ✗ */
  min-width: 0;
  min-height: 0;
  gap: 8px;
}

/* 网格：**每行 5 格 · 每格最多 56px · 间距 16px** ✓（2026-09-19 按布局图：列数 10 → 5 ✓）
   ⚠️ 为什么用 `minmax(0, 56px)` 而不是直接写 `56px` ✗：
      写死 `56px` 在窄卡片里会**横向溢出**（横向滚动条 + 右边几格看不见 ✗✗）
      `minmax(0, 56px)` = "**最多** 56px" ⇒ 宽裕时正好 56 ✓、放不下时按比例缩 ✓（绝不溢出 ✓）
   ★★**三改**（2026-09-20 · 用户要求 ✓）：上限 64 → **56**、间距 8 → **16**
      用户原话：「**适当缩小物品单元格的尺寸，或者是加入边距，保证单元格之间的距离，
      从而留出显示品质角标足够空间**」✓
      依据：角标（品质 Ⅰ~Ⅴ）是**非颜色线索** ✓ ⇒ 不能被旁边的格子挤到看不清 ✓
      实测卡片 652px：左列 380px ⇒ 5×56 + 4×16 = **344px** ⇒ 余量 36px（落进滚动条槽 ✓）
      ⚠️ 二改时曾把上限提到 64（为了吃回"右边空一大条"✗）⇒ 现按用户要求退回 56 ✓；
        余量远小于当初的 277px ✗ ⇒ 不会重现"空一大条" ✓
      ★**行高恰好不变** ✓：原 64+8 = 72，现 56+16 = **72** ⇒ 一屏能看的行数一样多 ✓
   ★★**四改**（2026-09-20 · 用户第二轮反馈 ✓）：网格四周内边距 = 格间距 = **16px** ✓
      用户原话：「**单元格与背包边缘也应该有相同边距**，当前左侧格子的品质角标依然会被遮挡」✓
      ⚠️ 为什么内边距能救"挂在左侧"的角标 ✓（这是解开整件事的那把钥匙 ✓）：
        · 网格**只在纵向滚动** ⇒ **左右内边距永远在** ✓ ⇒ 角标往左探出去的那 5px
          落在内边距里 ⇒ **任何滚动位置都不会被裁** ✓✓
        · 而**上下内边距会随滚动走掉** ✗ ⇒ 角标**不能往上探**（`top` 必须 ≥ 0 ✓ 见 `.bag-slot` 那条 ✓）
        ⇒ 于是定案：角标 **贴格子左边缘、挂在左上角**（像一枚名牌 ✓），
          纵向不外探（物理上救不了 ✗）
      ⚠️ 四周都是 16 = 与格间距一致 ⇒ **外圈留白 = 内部留白** ✓（视觉上才是一个整块网格 ✓）
   ⚠️ `justify-content: start`：格子**不拉伸** ⇒ 宽裕时多余宽度**留空** ✓
      （写清意图 —— grid 的初始值 `normal` 对 auto 轨道等价于 stretch ✓）
      ⚠️ 刻意**不居中** ✗：居中会让网格与底栏（容量 / 整理）左边缘错开 ✗；
        残留的那点余量正好落在**滚动条槽**里 ✓ 看不出来 ✓
   ★这一层就是"**网格自己滚**"的那个滚动框 ✓ ⇒ 头部与底栏都不会跟着动 ✓ */
.bag__grid {
  display: grid;
  grid-template-columns: repeat(5, minmax(0, 56px));
  justify-content: start;
  gap: 16px;
  align-content: start;
  flex: 1 1 auto;
  min-height: 0;
  overflow: auto;
  /* ★四周 16px = 与 `gap` 一致（四改 ✓ 见上）：
     ⚠️ 不能只留 `padding-right: 4px`（原来那样 ✗）——
        左侧没留白 ⇒ 最左一列的"挂牌角标"紧贴面板边缘，看着就像被切 ✗
     ⚠️ 也别把滚动条挤没：`padding-right: 16px` 里本来就含着滚动条的槽 ✓ */
  padding: 16px;
}

/* 一个格子：颜色（品质）+ 角标（非颜色线索 ✓）双线索 */
.bag-slot {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  aspect-ratio: 1 / 1;
  padding: 0;
  border: 2px solid var(--q, var(--border));
  border-radius: 8px;
  background: rgba(var(--accent-rgb), .06);
  cursor: pointer;
  font-family: inherit;
  /* ⚠️ 这层深色描边是**必须的**：白色档不靠它就看不清 ✓ */
  box-shadow: 0 0 0 1px rgba(16, 22, 31, .55);
}
/* 空位用**淡格**（虚线）—— 比纯空更有"待填"的感觉 ✓（§9.22 ②）*/
.bag-slot.is-empty {
  border-style: dashed;
  border-color: var(--border);
  background: transparent;
  box-shadow: none;
  cursor: default;
}
.bag-slot.is-sel { box-shadow: 0 0 0 2px var(--accent), 0 0 0 1px rgba(16, 22, 31, .55); }
.bag-slot.is-static { cursor: default; width: 44px; flex: 0 0 44px; }

/* ============================================================
  ★★「到手了」闪一下 ✗✓（★2026-10-02 加 ✓ 见 `Plan/15` §五·六 §3）
  ------------------------------------------------------------
  ★用在**两处**：★快捷栏那格 ✓ 与 ★背包那格 ✓（同一件事的两头 ✓）
  ⚠️★动画走 `outline` 而**不是** `box-shadow` ✗✓：★`box-shadow` 已经被占了
     （★基类的深色描边 ✓ ＋ `.is-sel` 的选中环 ✓）⇒ ★用它会**把选中框吃掉**✗
     （★症状："买到的那件**看不出自己选中了**"✓）★而 `outline` 二者都不碰 ✓
  ⚠️★它**不进布局** ✗✓（`outline` 本来就外挂 ✓）⇒ ★闪的时候格子**不会挪** ✓
  ⚠️★2.4 秒那条判据在 **JS** 里 ✗（★`slotHtml` / `paintHotbar` ✓）——
     ★这里只管"亮成什么样"✓，★这里**不判断**该不该亮 ✓
  ============================================================ */
@keyframes cc-gain-flash {
  /* ★★收小 ✗✓（★2026-10-03 ✓ 格子 44 ⇒ **32**、间隙 8 ⇒ **6** 之后 ✓）——
     ⚠️★原来 `offset` 到 **8px** ✗：★32px 的格子只隔 **6px** ⇒ ★闪的那一下会
       **溢到邻格上** ✓ ⇒ ★而"同种 +N"是**稀有事件** ✓ ⇒ ★更容易被当成"画面花了"✓
       而不是"设计如此"✗ —— ★所以它必须跟着格子一起收 ✓
     ★`3px / 5px` ⇒ ★外缘离格边 **5px** ✓ 仍在 **6px** 间隙里 ✓（留 1px 余量 ✓）*/
  0%   { outline: 3px solid rgba(255, 210, 60, .95); outline-offset: 1px; }
  100% { outline: 3px solid rgba(255, 210, 60, 0);   outline-offset: 5px; }
}
.hotbar__slot.is-gain,
.bag-slot.is-gain { animation: cc-gain-flash 1.1s ease-out 2; }

/* ★★页码 ＋ 翻页键提示 ✗（★栏自己那行文字的末尾 ✓）
   ⚠️★只在**真有第二页**时才出现 ✗✓（★一页时写"1/1"只是噪音 ✓ 见 `paintHotbar` ✓）*/
.hotbar__page {
  align-self: center;
  margin-left: 2px;
  font-size: 11px;
  line-height: 1.2;
  letter-spacing: .5px;
  color: rgba(255, 255, 255, .72);
  white-space: nowrap;
  /* ★★加一条描边 ✗✓（★2026-10-03 ✓ 用户选"② 轻纱"之后**必须**补 ✓）——
     ⚠️★为什么现在非补不可 ✗：★这行小字原来压在**不透明的深底**（`.62` ✓）上 ✓
       ⇒ ★底一旦减成 `.30`，它就直接落在**世界画面**上了 ✓
       ⇒ ★遇上浅色地图 / 亮主题 ⇒ **读不出来** ✓（★"减纱"最容易漏的一处 ✓）
     ⚠️★用 `text-shadow` 而不是加底色 ✗：★加底色 ＝ 又给这行加回一个"小面板" ✓
       ⇒ ★与"成族"的目标**正好相反** ✓（★同世界名牌用描边、而不是贴底 ✓ 那一手法 ✓）*/
  text-shadow: 0 1px 2px rgba(0, 0, 0, .9);
}

/* ★★背包里的"第 N 页 · 快捷栏"行头 ✗✓（★2026-10-02 加 ✓ 见 §五·六 §4）
   ⚠️★它**独占一整行**✗（`grid-column: 1 / -1` ✓）：★网格是 5 列 ＝ 一页 ✓
     ⇒ ★"整行头"放下去，底下的 5 格就正好是那一页 ✓
   ⚠️★为什么不写成格内角标 ✗：★① 左上角被品质角标占了 ✓；
     ★② 往上探会被**滚动裁掉**✗（★那条写在 `.bag-slot` 的注释里 ✓）*/
.bag-page {
  grid-column: 1 / -1;
  margin: 2px 0 -6px;
  font-size: 10px;
  line-height: 1.4;
  letter-spacing: .5px;
  color: var(--accent);
  opacity: .85;
}

.bag-slot__text { font-size: 16px; font-weight: 800; color: var(--text); }

/* ★**物品图标尺寸：统一 32px**（★2026-09-25 · 用户拍板 ✓）
   ⚠️ 回滚 = 把 `width/height` 换回原来的 **`68%`** ✓（老值就在这里 ✓ 其余三条不动 ✓）
   ⚠️ 原 `68%` 的**实际后果** ✗（两个容器的尺寸本来就不同 ⇒ 同一件物品两处不一样大 ✗）：
     · 格子：轨道最多 **56px**（见 `.bag__grid` ✓）⇒ 图标 ≈ **38px**
     · 详情：`.bag-slot.is-static` 固定 **44px**（见上 ✓）⇒ 图标 ≈ **30px**
     ⇒ 同一枚图，详情里像"**缩水版**"✓（2026-09-25 用户报"详情栏仍是文字"时顺带量的 ✓）
   ★为什么是 **32** 而不是别的（三条都是几何硬约束，不是审美 ✓）：
     · 源图是 **24×24**（`tools/gen-item-icons.mjs` 的 `SIZE = 24` ✓）⇒ 严格"不糊"只有
       **1 倍 = 24px** 与 **2 倍 = 48px** 两档 ✗，其余都是非整数倍缩放 ✓
     · **48px 放不下** ✗：图标左缘会落到格内 **4px** 处，而品质角标（宽 ≈16px ·
       `left: -5px` ⇒ 右缘在 **11px** 处 ✓ 见 `.bag-slot .bag-slot__mark`）⇒ 压住 7px ✗✗
       （"品质在左上角被遮挡"那两轮反馈说的就是这件事 ✓ 不能又把它请回来 ✗）
     · **24px 太空** ✗：在 56px 的格子里只占 43% ⇒ 像"色块里放了个小图"✓
     · **32px**：格内净空 **12px** · 详情净空 **6px** ✓ —— 三枚角标（品质 / 数量 / 仓库）**全让开** ✓
       数字口径（**推算**，非实测 ✗）：角标右缘 **11px** vs 32px 图标左缘 `(56−32)/2 = 12px`
       ⇒ 净空 **1px** ✓；而原 38px 图标左缘仅 ≈ **9px** ⇒ 与角标**框已重叠 2px** ✗
       （是否真"压住"还看那件道具左上角画的是什么 ✗ ⇒ 要肉眼核对就用 `--preview` 的 4 倍图 ✓）
   ⚠️ 32px 相对源图仍是 **1.333 倍**（32 ÷ 24）✗ **不是整数倍** —— 但比原来的
      **1.587 倍**（38 ÷ 24）**更接近** ✓；且尺寸**定死**后不再随卡片宽度浮动 ✓
      （原先卡片一窄，图标跟着缩 ⇒ 同一件物品在不同窗口下不一样大 ✗）
   ★★`min(32px, 100%)` 是**护栏** ✗ 不是花活：窄卡片 / 紧凑模式下轨道会被压到 32px 以下
      ⇒ 取"32px 与格子"里的**较小者** ⇒ **永不溢出格子** ✓
      （与 `.bag__grid` 的 `minmax(0, 56px)` 是**同一条思路**：最多这么大，放不下就跟着缩 ✓） */
.bag-slot__img {
  width: min(32px, 100%);
  height: min(32px, 100%);
  object-fit: contain;
  image-rendering: pixelated;
}

/* 品质角标（Ⅰ~Ⅴ 数码 + 品质色底）：**非颜色线索** ✓（色觉差异 / 换美术都仍可分 ✓ §6.7.5）
   ⚠️ 基类**默认行内** —— 它在**列表**里就是这个形态（图鉴的 .book__item ✓）
      绝对定位是**格子**独有的需求 ⇒ 移到下面那条 `.bag-slot .bag-slot__mark` ✓
   ⚠️ 为什么必须拆开（2026-09-19 实测的 bug ✗）：
      绝对定位靠父级 `position: relative` 才能落在"格子的角"。
      图鉴把角标放进了 `.book__item`，而**它没有定位** ✗
      ⇒ 角标逃到最近的定位祖先 = 舞台卡片 `.stage-card`（absolute ✓）
      ⇒ **卡片左上角出现一个白色小块** ✗（`--q-common` 就是 #ffffff ✓）
      实测：角标 (138.7, 83.2) vs 卡片左上角 (142.7, 89.2) —— 正好差 -5/-7px ✓ 坐实 ✓
   ⚠️ `.bag-slot__mark` 这个名字是历史的（它现在**也被图鉴用** ✗）
      ⇒ 正名要连改三处调用与 CSS，留到"抽公共零件"那一轮一起做 ✓ */
.bag-slot__mark {
  padding: 0 3px;
  /* 行内形态下与后面的名称拉开一点（绝对定位时这一条不影响位置 ✓） */
  margin-right: 3px;
  border-radius: 4px;
  background: var(--q, var(--border));
  color: var(--q-ink);
  font-size: 9px;
  font-weight: 800;
  line-height: 12px;
  box-shadow: 0 0 0 1px rgba(16, 22, 31, .55);
}

/* 只有**格子**里的角标才钉到左上角 ✓（`.bag-slot` 自带 `position: relative` ✓）
   ★★**角标必须落在格子内**（2026-09-20 用户反馈"品质在左上角有被遮挡"✓ 实测坐实 ✗）：
      原来是 `top: -7px; left: -5px`（挂在格子**外面**的左上角 ✓）——
      在格子中间看没问题（8px 间隙够容纳 ✓），但**首行 / 首列**不行 ✗：
      网格自己是**滚动框**（`overflow: auto` ✓）⇒ 溢出到框外的部分**一律被裁** ✗
      实测：首格角标比网格框**高 5px、左 3px** ⇒ 正好被切掉一条 ✓✓（与截图现象一致 ✓）
   ★下面这条是**物理限制**，不是偏好 ✗（用户第二轮反馈后想清楚的 ✓）：
      滚动容器**只在"滚动边界"上裁剪** ⇒ 任何"伸出格子之外"的角标，
      一旦它那一行滚到框的顶边，**必然被切掉** ✗ ——
      给网格加 `padding` 只能救"没滚动时"的第一行 ✓，往下滚一样切 ✗。
      ⇒ **唯一稳的做法：角标落在格子自己的盒子里** ✓（格子不是裁剪边界 ✓）
    ⇒ 定案 `(0, 0)` = 贴齐格子的**内侧角**（2px 边框之内 ✓）⇒ 任何滚动位置都完整可见 ✓
      副作用可接受 ✓：角标会压在图标左上角一点点（图标是 `object-fit: contain` ✓
      多半是透明边 ✓；首字占位更不受影响 —— 它是居中的 16px 字 ✓）
   ★**四改定案**（2026-09-20 · 用户第二轮反馈 ✓）：**横向可以外探、纵向绝对不可以** ✓
      · `left: -5px` ✓ —— 往左探出去，正好落进网格的 `padding: 16px` 里 ✓
        （**左右内边距不随滚动走** ✓ ⇒ 最左一列也永远裁不到 ✓✓）
      · `top: 0` ✓ —— **不往上探** ✗（上下内边距会随滚动走掉 ⇒ 那一行滚到框顶时必被切 ✗）
      ⇒ 观感 = 一枚**挂在格子左上角的"名牌"** ✓（左半压在格子的描边上 ✓），
        与"挂在外面又不会被切"这两个要求在**横向/纵向的差别**上同时成立 ✓
    ⚠️ 角标宽 ≈ 16px ⇒ 探出 5px 后仍在 `gap: 16px` 之内 ✓ 不会碰到左边的格子 ✓ */
.bag-slot .bag-slot__mark {
  position: absolute;
  top: 0;
  left: -5px;
  /* 格子里比列表（图鉴）里略大一点：9 → 10px ✓（它是"非颜色线索"✓ 宁大勿小 ✓） */
  font-size: 10px;
  line-height: 14px;
}
.bag-slot__count {
  position: absolute;
  right: 3px;
  bottom: 1px;
  font-size: 10px;
  font-weight: 800;
  color: var(--text);
  text-shadow: 0 1px 2px rgb(0 0 0 / .6);
}
/* 「仓库」标记 —— ⚠️ 仓库里的物品**必须看得见**，否则"永不丢件"就是空话 ✗（§9.22 ②）
   ★同一条"不许越出格子"的理由（2026-09-20 ✓）：`bottom: -7px` 会被滚动框裁掉 ✗
     ⇒ 收到**右上角内侧** ✓ —— 左上角让给品质角标 ✓、右下角让给数量 ✓，三者不打架 ✓ */
.bag-slot__stash {
  position: absolute;
  right: 1px;
  top: 1px;
  padding: 0 3px;
  border-radius: 4px;
  background: var(--accent);
  color: var(--on-solid);
  font-size: 9px;
  font-weight: 700;
  line-height: 12px;
}

/* 底部行（钉在**网格列**底部 ✓）：容量与临时仓库**同一行** ✓ + 整理
   ⚠️ 它在 `.bag__pane` 里 ⇒ **只占网格列宽** ✓（不横跨到详情栏下面 ✓，2026-09-19 布局图 ✓）
   ⚠️ 固定行：网格自己滚 ⇒ 它不会跟着跑掉 ✓
   ⚠️ `margin-top: 0`：与网格的间距统一由 `.bag__pane` 的 `gap: 8px` 给 ✓
      （留着的话会和 gap 叠成 16px ✓） */
.bag__foot {
  display: flex;
  flex: 0 0 auto;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  margin-top: 0;
  padding-top: 6px;
  border-top: 1px solid var(--border);
}
.bag__cap { font-size: 12px; color: var(--text-dim); }

/* 右侧详情（**撑满整列高** ✓ 见 `.bag__main` 的 `align-items: stretch` ✓）
   ★**固定理想宽 240px**（2026-09-20 **二改** ✓）：
     一改是 `1 1 auto`（"吃掉全部剩余"）✗ —— 两处都错在 **basis**：
     `flex-basis: auto` 取的是**内容宽**，而描述（`.bag-detail__desc`）是
     一整行不折的长文本 ⇒ 内在宽度极大 ⇒ 详情栏**先按内容抢宽**、
     再把左列（basis 取网格内容宽、可缩 ✓）压到 281px ✗（实测 652px 卡片 ✓）
     ⇒ 现在：basis 写死 **240px**（= 舒适读数宽，约 20 汉字/行 ✓）、
       `flex-grow: 0`（**不再抢** ✓）、`flex-shrink: 1`（窄卡片时能缩 ✓）
     `min-width: 170px` 是**保底** ✓：卡片很窄时不会再被压到读不下 ✓（这时该网格列缩 ✓）
   ⚠️ 竖排（`.is-compact`）时会改回 `flex: 0 1 auto` + `width: 100%` ✗ —— 见上面那两条 ✓
      （竖排里 flex 主轴是**纵向**，240px 会被当成"高度" ✗ ⇒ 必须由那条规则覆盖 ✓） */
.bag__detail {
  flex: 0 1 240px;
  min-width: 170px;
  padding: 10px 12px;
  border: 1px solid var(--border);
  border-radius: 10px;
  background: var(--bg-panel-2);
  /* ⚠️ 它自己也滚 ✗：详情长了不能把网格顶掉，也不能溢出卡片 ✓
     （与网格各自一条滚动条 —— 这是"底部栏只占网格列"的必然代价 ✓ 见 §9.22 ⑧） */
  overflow: auto;
}
.bag-detail__title { margin: 0 0 8px; font-size: 12px; letter-spacing: 1px; color: var(--accent); }
.bag-detail__head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; flex-wrap: wrap; }
.bag-detail__name { font-size: 13px; font-weight: 700; color: var(--text); }
/* 品质的**文字**线索（角标之外的另一半 ✓）—— 浅色主题下靠描边保证可读 ✓ */
.bag-detail__q {
  font-size: 11px;
  font-weight: 700;
  color: var(--q, var(--text-dim));
  text-shadow: 0 0 2px rgba(16, 22, 31, .85), 0 0 4px rgba(16, 22, 31, .6);
}
.bag-detail__stats { display: grid; grid-template-columns: auto 1fr; gap: 2px 8px; margin: 0 0 8px; }
.bag-detail__stats dt { font-size: 11px; color: var(--text-dim); }
.bag-detail__stats dd { margin: 0; font-size: 11px; color: var(--text); }
.bag-detail__desc { margin: 0 0 8px; font-size: 11px; line-height: 1.7; color: var(--text-dim); }
/* 空态：详情栏**始终占位** ✓ ⇒ 没选中时要有一句话，不能是一个空框 ✗ */
.bag-detail__empty { margin: 0; font-size: 11px; line-height: 1.7; color: var(--text-dim); }
.bag-detail__ops { display: flex; gap: 6px; flex-wrap: wrap; }
.bag-detail__hint { margin: 6px 0 0; font-size: 10px; color: var(--text-dim); }

/* ---- 新解锁横幅（舞台上的浮层）----
   ⚠️ `pointer-events: none` 是必须的：它只是提示，不能挡住 NPC 的点击 ✗ */
.unlock-toast {
  position: absolute;
  top: 12px;
  left: 50%;
  z-index: 20;
  max-width: 84%;
  padding: 10px 14px;
  border: 1px solid var(--accent);
  border-radius: 10px;
  background: var(--bg-panel);
  box-shadow: 0 8px 28px rgb(0 0 0 / .35);
  transform: translateX(-50%);
  pointer-events: none;
  animation: unlock-in .32s ease-out;
}
.unlock-toast[hidden] { display: none; }
.unlock-toast__title {
  margin-bottom: 4px;
  font-size: 12px;
  font-weight: 700;
  letter-spacing: 1px;
  color: var(--accent);
}
.unlock-toast__item { font-size: 12px; line-height: 1.7; color: var(--text); }
.unlock-toast__item b { color: var(--accent); }

@keyframes unlock-in {
  from { opacity: 0; transform: translate(-50%, -8px); }
  to   { opacity: 1; transform: translate(-50%, 0); }
}

/* ============================================================
  音效开关 + 反馈演出（音效见 game/sfx.js，演出见 game/fx.js）
  ------------------------------------------------------------
  配色一律走主题变量（不变量二：不得写死色值 ✓）
  涨 / 跌用**语义色** --ok / --danger（跨主题一致 —— 与结果区同一条原则 ✓）
  ============================================================ */

/* ---- 工具栏「音效」开关（外观对齐 .theme-trigger，见 theme.css ✓）---- */
.sfx-anchor { position: relative; display: inline-block; }

.sfx-trigger {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 3px 10px;
  border: 1px solid var(--border);
  border-radius: 999px;
  background: var(--bg-panel-2);
  color: var(--text);
  font-size: 12px;
  font-family: inherit;
  cursor: pointer;
  white-space: nowrap;
  transition: all .15s;
}
.sfx-trigger:hover { border-color: var(--accent); color: var(--accent); }
/* 静音时压暗（一眼看出当前是关的 ✓） */
.sfx-trigger.is-off { color: var(--text-dim); }
.sfx-trigger__icon { font-size: 12px; line-height: 1; }

/* HUD 数字必须是 inline-block，否则 transform 对 inline 元素不生效（跳动会看不见 ✗） */
.toolbar__stats b { display: inline-block; }

/* ---- HUD 飘字层（★2026-10-04 起**在舞台内** ✗✓ 见 `Plan/19` §七-7.1 ✓）----
   ★★为什么从 body 搬进舞台 ✗：★"全屏游戏模式"下**舞台外一律不渲染** ✓
     ⇒ ★留在 body ⇒ 全屏时"＋金币 / ＋经验"**一个都看不见** ✗
   ⚠️★★它**仍必须是 `position: fixed`** ✗✗（★这是本层唯一容易改错的地方 ✓）：
     · `fx.js` 的 `floatFrom()` 把起点写成 **viewport 坐标** ✓
       （`target.getBoundingClientRect()` ＋ `host.appendChild(span)` ✓）
     · ★`fixed` 的参照是**屏幕** ⇒ ★与"舞台在不在全屏"无关 ⇒ ★坐标永远对 ✓✓
     · ★前提（★已核 ✓）：★祖先链上**没有** `transform` / `filter` ✗ ——
       `game.css` 与 `theme.css` 里的 transform 全在 `.btn` / `.modal` / `.theme-card` /
       内部小元素上 ✓；`.game-stage` / `.right` / `.layout` **都没有** ✓
       ⇒ ★哪天给舞台加了 `transform`（如缩放动画 ✗）⇒ **本层坐标会整体偏** ✗✗
         那时要么撤掉那个 transform ✓ 要么把本层改回 body 级 ＋ 换坐标口径 ✓
   ⚠️★`.game-stage` 是 `overflow: hidden` ✗ —— ★但**不影响本层** ✓：
     ★`fixed` 元素的包含块是**视口** ⇒ **不被祖先的 overflow 裁剪** ✓（★但加了
     `transform` 就会 ✗ —— 见上一条 ✓）
   ⚠️ z-index 仍取 900：★高于舞台内一切（最高 24 ✓）与工具栏 ✓、
     低于 Bootstrap 弹窗（1055 ✓）—— 飘字不该压在登录弹窗上面 ✓ */
.fx-hud {
  position: fixed;
  inset: 0;
  z-index: 900;
  pointer-events: none;
}

/* ---- 舞台内两格统计（💰 金币 / ⏳ 时长 · 2026-10-04 从顶栏搬进来 ✓ 见 `Plan/19` §七-7.1 ✓）----
   ⚠️★为什么搬 ✗：★"全屏游戏模式"下**舞台外一律不渲染** ⇒ ★留在顶栏
     ⇒ 全屏里"我有多少钱 / 攒了多少时长"**直接消失** ✗（★时长还是**全屏自己的解锁条件** ✗✓）
   ⚠️★位置 = **左上角、`Lv.` 徽标下方一行** ✗✓：
     · ★不跟 `Lv.`（top:10 / left:10 ✓）抢位 ✓ —— `top: 48px` 是**量出来**的安全值 ✓
       （徽标 ≈ 2px padding×2 ＋ 7px 边框×2 ＋ 13px 字 ≈ **31px** ✓ ⇒ 48 留了约 17px 间隔 ✓）
     · ★舞台四角已被占满 ✗：右上 = 全屏按钮 `#fsBtn`（10/10 ✓）· 底部 = 经验条 ✓
       · 右下 = 入口排 `.stage-tools`（bottom:16 ✓）⇒ ★只剩这里 ✓
   ⚠️★`z-index: 19` ＝ 与 `Lv.` / 右下入口**同层** ✓ ★而在**覆盖卡片（18）之下** ✗✓ ——
     ★卡片翻开时被盖住是**对的** ✓（那是"翻开一页"✓ 不必再显示 HUD ✓）
   ⚠️★emoji 图标（用户 2026-10-04 拍板 ✓）：★💰 金币 · ⏳ 时长（**沙漏** ✓）——
     ★沙漏与"用课堂产出换游戏时长"那套（沙漏道具 ✓）**同一个意象** ✓
   ⚠️★文字仍在**原来那两个 `<b>`** 里 ✗✓（`id` 保持 `hudGold` / `hudTime` ✓）
     ⇒ ★`main.js` **零改动** ✓（★这也是"必须搬家、不能复制"的原因 ✓）*/
.stage-stats {
  position: absolute;
  top: 48px;
  left: 10px;
  z-index: 19;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 4px;
}
/* 舞台里没有工具栏的横向余量 ⇒ chip 收窄一点（观感：与 `Lv.` 那枚同族 ✓） */
.stage-stats .chip { font-size: 11px; padding: 1px 7px; }

.fx-float {
  position: absolute;
  font-size: 13px;
  font-weight: 800;
  white-space: nowrap;
  text-shadow: 0 1px 3px rgb(0 0 0 / .5);
  pointer-events: none;
  animation: fx-rise 1.4s ease-out forwards;
}
.fx-float--gain { color: var(--ok); }
.fx-float--spend { color: var(--danger); }
.fx-float--info { color: var(--accent); }

@keyframes fx-rise {
  from { opacity: 0; transform: translate(-50%, -30%) scale(.85); }
  20%  { opacity: 1; }
  to   { opacity: 0; transform: translate(-50%, -280%) scale(1.05); }
}

/* ---- 数值跳动（金币 / 经验 / 等级变了）---- */
.fx-pulse { animation: fx-pop .42s ease-out; }

@keyframes fx-pop {
  0%   { transform: scale(1); }
  35%  { transform: scale(1.32); color: var(--accent); }
  100% { transform: scale(1); }
}

/* ---- 舞台演出层（.game-stage 内部 · 全屏时要跟着进去 ✓）----
   ⚠️ `pointer-events: none` 与 overflow: hidden 都是必须的：
      它只是演出，不能挡住 NPC 的点击，也不该溢到舞台外 ✗ */
.fx-layer {
  position: absolute;
  inset: 0;
  z-index: 24;
  overflow: hidden;
  pointer-events: none;
}

/* ---- 道具入手卡 ----
   top 取 64px 是为了**让开顶部的「新解锁」横幅**（它占 top:12px ✓） */
.fx-reward {
  position: absolute;
  top: 64px;
  left: 50%;
  min-width: 150px;
  padding: 9px 13px;
  border: 1px solid var(--accent);
  border-radius: 10px;
  background: var(--bg-panel);
  box-shadow: 0 8px 28px rgb(0 0 0 / .35);
  transform: translateX(-50%);
  animation: fx-drop .3s ease-out;
}
.fx-reward__title {
  margin-bottom: 3px;
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 1px;
  color: var(--accent);
}
.fx-reward__item { font-size: 12px; line-height: 1.6; color: var(--text); }
.fx-reward__item b { color: var(--accent); }

@keyframes fx-drop {
  from { opacity: 0; transform: translate(-50%, -10px); }
  to   { opacity: 1; transform: translate(-50%, 0); }
}

/* ---- 升级演出（舞台正中，最隆重的一个 ✓）---- */
.fx-levelup {
  position: absolute;
  top: 50%;
  left: 50%;
  text-align: center;
  transform: translate(-50%, -50%);
  pointer-events: none;
  animation: fx-burst 1.8s ease-out forwards;
}
.fx-levelup__label {
  font-size: 13px;
  font-weight: 800;
  letter-spacing: 4px;
  color: var(--accent);
  text-shadow: 0 2px 10px rgba(var(--accent-rgb), .6);
}
.fx-levelup__level {
  margin-top: 2px;
  font-size: 46px;
  font-weight: 900;
  line-height: 1;
  color: var(--accent);
  text-shadow: 0 0 18px rgba(var(--accent-rgb), .8), 0 2px 6px rgb(0 0 0 / .5);
}

@keyframes fx-burst {
  0%   { opacity: 0; transform: translate(-50%, -50%) scale(.6); }
  18%  { opacity: 1; transform: translate(-50%, -50%) scale(1.12); }
  30%  { transform: translate(-50%, -50%) scale(1); }
  75%  { opacity: 1; }
  100% { opacity: 0; transform: translate(-50%, -130%) scale(1); }
}

/* ---- 减动画模式（前庭敏感的学生）----
   **只去掉位移与缩放，信息照给**：
   升级块会静态停在台上 2 秒（由 fx.js 的兜底定时器收走），
   而不是"什么都不出现"—— 那等于把反馈一起拿掉了 ✗ */
@media (prefers-reduced-motion: reduce) {
  .fx-float,
  .fx-reward,
  .fx-levelup,
  .fx-pulse { animation: none !important; }
  /* ★"到手了"这一条**不拿掉，改成静止地亮着**✗✓ —— ★与上面同一用意：**信息照给** ✓
     ⚠️★直接 `animation: none` 会连"亮"一起拿掉 ✗（★那条动画的**终态是透明**的 ✓）
       ⇒ ★学生"买了却一点反馈都没有"✓（★本块最忌讳的那一类 ✓）
     ★⇒ ★改成一个**常驻**的描边 ✓（★它比动画还显眼 ✓ 只是不闪 ✓）*/
  .hotbar__slot.is-gain,
  .bag-slot.is-gain {
    animation: none !important;
    outline: 3px solid var(--accent);
    outline-offset: 2px;
  }
}

/* ============================================================
  四、右侧：上游戏区 / 下结果区（上下 6 : 4）
   ============================================================ */
.right {
  grid-area: right;
  display: grid;
  grid-template-columns: minmax(0, 1fr);              /* 显式单列 */

  /* ⭐ 上下 6 : 4（原为 1fr 1fr 二等分）
     ⚠️ 必须写成 minmax(0, Nfr)：裸 `60fr` 等于 minmax(auto, 60fr)，
        其 auto 下限取内容的 min-content 高度，会被结果区撑破。

     ── 为什么从 50 : 50 改成 6 : 4 ──────────────────────────
     左栏宽度固定时：
        游戏区比例 = (窗口宽 − 30) / [ (窗口高 − 86) × 占比 × 2 ]
     宽屏扁窗口下二等分会把游戏区压成"条状"（1912×662 → 3.27 : 1），
     改成 6 : 4 后为 2.72 : 1，观感明显改善，
     而**横向可见格数完全不变** —— 这很关键：NPC 沿横向铺开
     （col 12~34，跨 23 格），横向视野决定"能否一眼看到所有 NPC"，
     不能拿它换高度（改左右分栏比例就会损失，故不做）。

     ⚠️ 比例随窗口形状变化：同一分档在标准 16:9 屏上反而偏竖
        （1920×1080 → 1.43 : 1）。若将来要在大屏也贴近 16:9，
        需按窗口长宽比分档；当前刻意不做 —— 为一个观感问题
        引入一套断点不划算。详见《实施计划》6.6。 */
  grid-template-rows: minmax(0, 60fr) minmax(0, 40fr);

  gap: 10px;
  min-width: 0;
  min-height: 0;
}

/* ---- 右上：游戏舞台（类比 Scratch 舞台区）----
   ⚠️ 类名用 .game-stage 而非 .stage：
      后者在演示页样式里是三栏 Grid，重名会被覆盖（本次踩过的坑） */
.game-stage {
  position: relative;
  min-width: 0;
  min-height: 0;
  background: var(--stage-bg);
  border: 1px solid var(--border);
  border-radius: 10px;
  overflow: hidden;
}

#game-root {
  /* RESIZE 模式下画布尺寸 = 容器尺寸，无需居中处理 */
  width: 100%;
  height: 100%;
}
#game-root canvas { display: block; }

/* ---- NPC 对话（DOM 覆盖层）----
   为什么用 DOM 而不是画进 canvas，见 dialog.js 头部。
   ⚠️ 必须留在 .game-stage 内部：全屏只渲染全屏元素及其后代。
   ⚠️ 两条 [hidden] 规则不能省：本块用了 display:flex / 默认块级显示，
      不显式声明会被反压，导致 hidden 属性失效 —— 表现为
      「对话已结束但整个舞台点不动」。 */
.dialog {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: flex-end;
  padding: 12px;              /* 面板距舞台边缘 */
  z-index: 10;
}
.dialog[hidden] { display: none; }

.dialog__panel {
  position: relative;
  flex: 1;
  min-width: 0;
  /* 最小高度 —— 消除「有头像 / 无头像」之间的高度跳动
     ⚠️ 为什么是 clamp 三层，而不是单纯 30%：
        · 矮舞台（≈290px）时 30% ≈ 87px —— 放不下 64px 头像 + 上下留白 ✗ ⇒ 用 96px 兜底 ✓
        · 高舞台时封顶 128px ⇒ 不出现大片空白 ✓
        · 常规舞台（≈336px）30% ≈ 101px —— 正好等于「有头像」那一档 ⇒ 两者一样高 ✓ 不再跳 ✓
     ⚠️ 前提：`.dialog` 是 absolute + inset:0（**高度确定** ✓）
        ⇒ 面板作为 flex item 的百分比 min-height 才解析得对 ✓（否则会退化成 auto ✗）*/
  min-height: clamp(96px, 30%, 128px);
  max-height: 100%;
  overflow: auto;
  /* ⚠️ 底部比别人厚 22px：给**绝对定位**的提示行让位（见 .dialog__hint ✓）
     少了这行，正文最后一行会压到提示文字上 ✗ */
  padding: 14px 14px 36px;
  border: 1px solid var(--border);
  border-radius: 10px;
  background: var(--bg-panel);
  box-shadow: 0 6px 24px rgb(0 0 0 / .22);
}
/* 左侧主色条：与题面小标题同一种视觉语言 */
.dialog__panel::before {
  content: "";
  position: absolute;
  left: 14px;
  top: 14px;
  bottom: 14px;
  width: 3px;
  border-radius: 1.5px;
  background: var(--accent);
  opacity: .7;
}

/* ---- 头像（方形 + 人名首字占位；见 dialog.js 的 E1）----
   颜色**全部走主题变量** ⇒ 6 套主题自动跟随（不变量 1）
   图按约定放 public/assets/npc/<speakerId>.png；缺图 ⇒ 只看到占位 ✓ */
.dialog__avatar {
  position: absolute;
  left: 27px;                 /* 14 面板内边距 + 13 = 与正文同一起点 */
  top: 12px;
  width: 64px;
  height: 64px;
  border-radius: 6px;
  overflow: hidden;
  /* ⚠️ 尺寸上限：64 + top 12 = 76px，仍小于面板内容高（约 99px）
     ⇒ 不会把下方「提示行」挤出面板 ✓ 要再放大就得同时缩进/缩高度 ✗ */
}
/* 占位底衬（缺图时可见；有图时被 <img> 盖住） */
.dialog__avatar::before {
  content: "";
  position: absolute;
  inset: 0;
  background: var(--accent);
  opacity: .16;
}
.dialog__avatar-img {
  position: relative;         /* 盖在底衬之上 */
  width: 100%;
  height: 100%;
  object-fit: cover;
  image-rendering: pixelated; /* 将来换像素风素材不糊（CC0 像素素材见 §9）*/
}
.dialog__avatar-text {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  font-size: 26px;            /* 随头像放大（42 ⇒ 18 / 64 ⇒ 26）*/
  font-weight: 700;
  color: var(--accent);
}
/* ⚠️ 本元素设了 display:grid ⇒ 必须显式声明 [hidden]，否则 hidden 会被反压失效 ✗ */
.dialog__avatar-text[hidden] { display: none; }

/* 有头像时，人名与正文整体右移，给方形头像让位
   正文起点 = 27（头像左）+ 64（头像宽）+ 6（间距）= 97 ⇒ margin-left = 97 − 14（面板内边距）= 83 */
.dialog__panel--avatar .dialog__speaker,
.dialog__panel--avatar .dialog__body { margin-left: 83px; }

.dialog__skip {
  position: absolute;
  top: 11px;
  right: 14px;
  padding: 2px 6px;
  border: none;
  background: none;
  font: inherit;
  font-size: 12px;
  color: var(--text-dim);
  cursor: pointer;
}
.dialog__skip:hover { color: var(--accent); }

.dialog__speaker {
  margin: 0 0 0 13px;         /* 13 = 正文起点 27 - 面板内边距 14，让开左侧色条 */
  font-size: 13px;
  font-weight: 700;
  line-height: 21px;
  color: var(--accent);
}
.dialog__speaker[hidden] { display: none; }

.dialog__body {
  margin: 0 0 0 13px;
  min-height: 23px;
  font-size: 14px;
  line-height: 23px;
  color: var(--text);
  /* 中文断行由浏览器原生完成 —— canvas 版需逐字测宽，这里免费 */
  overflow-wrap: anywhere;
}
/* 任务页（objective 收尾）：主色强调 */
.dialog__body--accent { color: var(--accent); }

/* ---- 提示行（「▸ 继续　(空格)」等）----
   ⚠️ 绝对定位（与 .dialog__skip 同一手法 ✓）：**钉在面板右下角**
      ⇒ 不再随正文行数上下跳（1 行文案 vs 3 行文案，位置完全相同 ✓）
   ⚠️ 为什么它原来会跳 ✗：它排在正文**之后**（流内）⇒ 正文多折一行它就下移 23px ✓
   ⚠️ 为什么钉住之后仍然"不跳"✓：面板是 `.dialog` 的 flex 子项且 `align-items: flex-end`
      ⇒ 面板**底边固定**、变高时只向上长 ✓ ⇒ 距底 12px 的提示行**屏幕位置不变** ✓
   ⚠️ 代价：若内容超高触发面板滚动，它会随内容一起滚出去 ✗（罕见，文案通常 1~2 行 ✓）*/
.dialog__hint {
  position: absolute;
  right: 14px;
  bottom: 12px;
  margin: 0;
  text-align: right;
  font-size: 12px;
  line-height: 19px;
  color: var(--text-dim);
}

/* ============================================================
   登录 / 注册 弹窗
   组件全部用 Bootstrap（.modal / .nav-tabs / .form-control），
   主题跟随由 theme.css 的 --bs-* 映射负责（含页面签配色）。
   本块只补这个弹窗自己的版式，不含任何行为。

   ⚠️ 若其他页面也要放同一个弹窗：本块整体移到 theme.css 即可，
      .auth* 类名本身与页面无关。
   ============================================================ */
#authModal { --bs-modal-width: 380px; }   /* 默认 620px 是给判定卡用的，登录表单太宽 */

.auth__close {
  position: absolute;   /* .modal-content 自带 position: relative */
  top: 14px;
  right: 16px;
  z-index: 1;
}

/* 页签居中；选中态配色由 theme.css 的 .nav-tabs 变量统一定义 */
.auth__tabs { justify-content: center; margin-bottom: 20px; }

/* 表单靠左、标题居中 —— 两者不冲突：
   text-align 直接写在元素上永远强于继承 */
.auth__panes {
  text-align: left;
  display: grid;
}

/* ---- 两个面板等高 ----
   「登录」2 个字段、「注册」4 个，内容量不同。默认行为下容器高度跟着
   当前面板走，于是**从注册切回登录时弹窗会骤然缩小**，观感很差。
   做法：两个面板叠进同一个 Grid 单元格，容器高度自动取较高者。

   为什么不用 min-height: 换主题或改字号都会让写死的值失效；
   为什么不用高度过渡动画: 高度由内容撑开，height:auto 根本无法过渡。

   ⚠️ 非激活面板必须保留盒子 —— Bootstrap 的 `.tab-content > .tab-pane`
      是 display:none，会把它整个移出布局，容器就又只剩当前面板了。
      这里显式写回 display:flex（同为 (0,2,0)，靠本文件后加载生效）。
      用 visibility 隐藏而非 display：既保留占位，又让隐藏面板里的
      输入框不可聚焦、不进无障碍树。 */
.auth__panes > .tab-pane {
  grid-area: 1 / 1;
  display: flex;
  flex-direction: column;   /* 配合下方 .auth__submit 的 margin-top:auto，把按钮组压到面板下沿 */
}
.auth__panes > .tab-pane:not(.active) { visibility: hidden; }

.auth__title {
  margin: 0 0 4px;
  text-align: center;
  font-size: 17px;
  letter-spacing: 1px;
}
.auth__subtitle {
  margin: 0 0 20px;
  text-align: center;
  font-size: 12px;
  color: var(--text-dim);
}

/* label 包住 input：点文字也能聚焦输入框 */
.auth__field { display: block; margin-bottom: 14px; }
.auth__label {
  display: block;
  margin-bottom: 6px;
  font-size: 12px;
  color: var(--text-dim);
}

/* 按钮 + 脚注作为**一个整体**压到面板下沿。
   两个面板已在同一 Grid 单元格里、且被拉伸到等高，所以 auto 会把两个面板的
   按钮组推到**同一个 y 坐标** —— 切换标签时主操作纹丝不动。
   ⚠️ auto 必须写在按钮上，不能写在脚注上：
      写在脚注上只会让脚注贴底、按钮悬在半空，两个面板的底部观感反而不一致。 */
.auth__submit { width: 100%; margin-top: auto; }

.auth__note {
  margin: 18px 0 0;    /* 与按钮固定 18px —— 它不再承担撑开空白的作用 */
  text-align: center;
  font-size: 11px;
  color: var(--text-dim);
}

/* 行内报错（由 game/auth.js 写入并显隐）
   —— 默认 hidden，所以不会占用高度、不会把按钮顶起来 */
.auth__error {
  margin: 0 0 10px;
  font-size: 12px;
  line-height: 18px;
  color: var(--bs-danger, #dc3545);
}

/* 提交中：只做视觉与拦截，不改变按钮宽度（否则点一次会跳一下） */
.auth__submit[data-busy] {
  opacity: .7;
  pointer-events: none;
}

/* ---- 右下：结果区 / 游戏提示区（标签切换）---- */
.output {
  display: flex;
  flex-direction: column;
  min-height: 0;
  background: var(--bg-panel);
  border: 1px solid var(--border);
  border-radius: 10px;
  overflow: hidden;
}

.tabs {
  display: flex;
  flex-shrink: 0;
  border-bottom: 1px solid var(--border);
}

.tab {
  padding: 9px 16px;
  border: none;
  background: transparent;
  color: var(--text-dim);
  font-size: 12px;
  font-family: inherit;
  cursor: pointer;
  border-bottom: 2px solid transparent;
  transition: all .15s;
}
.tab:hover { color: var(--text); }
.tab.is-active {
  color: var(--accent);
  border-bottom-color: var(--accent);
}

.tab-body {
  flex: 1;
  min-height: 0;
  padding: 12px 14px;
  overflow-y: auto;   /* ⭐ 内部可滚动 */
}

/* ⚠️ 刻意不叫 .placeholder：
   Bootstrap 有同名类（骨架屏：display:inline-block + background:currentcolor + opacity:.5），
   重名会把「尚未提交。」渲染成一个灰色实心块 —— 实测截图发现的坑。 */
.empty-hint {
  color: var(--text-dim);
  font-size: 12px;
  line-height: 1.85;
  /* 提示文案用 \n\n 分段（见 main.js 的 setGameHint），需保留换行 */
  white-space: pre-wrap;
}

/* ---- 职业面板（2026-09-21 加 ✓ 见 `Plan/6`）----
   ⚠️ 卡片外壳复用 `.stage-card` / `.book` ✓ ⇒ 这里只管**里面那四张职业卡** ✓
   ⚠️ 状态**必须"颜色 + 形状"双线索** ✗（色觉差异 ⇒ 不能只靠"灰的不能点" ✓
      与 NPC 头顶标记、石门开/锁同一口径 ✓）
   ⚠️ 未解锁的也要**读得清** ✓（灰显 ≠ 看不清：`opacity` 只压一档 ✓ 别压到发糊 ✗） */
.prof-lead {
  margin: 0 0 10px;
  color: var(--text-dim);
  font-size: 12px;
  line-height: 1.8;
}
/* ★★一行一职业 ⇒ **整段改用图鉴那套** `.book__item` ✗✗（2026-09-24 用户拍板 ✓）
   ⇒ ★`prof-grid` / `prof-card*` 那一整套**已删除** ✗（窄卡正是"两条路被压成小字"的根源 ✓）
   ⚠️ 这里只剩**职业专有的四条** ✓，别的全部沿用 `.book*` / `.bag__btn` ✓
      （★与 背包 / 图鉴 / 地图 / 公会 **同一个骨架** ✓ ⇒ 以后改一处大家一起受益 ✓）*/
/* ① 状态文字（"✓ 已解锁" / "🔒 通关后解锁"）—— 接在职业名后面 ✓ */
.prof-row__state { font-size: 11px; letter-spacing: .5px; }
.prof-row__state--on { color: var(--ok); }
.prof-row__state--off { color: var(--text-dim); }
/* ② 未解锁：补回**虚线边** ✓（形状线索 ✓ 不靠颜色也能认 ✓）
   ⚠️★刻意**只加在 `.prof-item` 上** ✗ —— 去改全局 `.book__item.is-locked` 会**连带改掉图鉴** ✓（越界 ✓）
   ⚠️ `opacity` 只压到 **.72**（`.book__item.is-locked` 是 .5 ✓）：
      ★"解锁条件"是**要读的正文** ✓ 不是装饰 ⇒ 压到 .5 会发糊 ✓（灰显 ≠ 看不清 ✓）*/
.prof-item.is-locked {
  border-style: dashed;
  opacity: .72;
}
/* ③ 当前职业：内描一圈 ✓（与"已解锁"叠加时仍看得出"就是它" ✓） */
.prof-item.is-current { box-shadow: inset 0 0 0 2px var(--ok); }
/* ④ ★转职那两条路**竖排** ✗（A2 · 2026-09-21 的取舍 ✓ ——
   宁可占两行，也别把"花 500 金币转职"压成一行小字：花钱的动作，价格必须一眼看得见 ✓）*/
.bag__ops--stack {
  flex-direction: column;
  align-items: flex-start;
}
/* 金币那条路 = **首选** ✓（与 §7.2 的行文一致："需要 N 金币，或 1 个凭证" ✓）
   凭证那条路 = **次要** ✓ ⇒ 不加修饰 ✓（但仍是可点的 ✓ —— `.bag__btn` 自带 hover/手型 ✓）*/
.bag__btn--primary {
  border-color: var(--ok);
  color: var(--ok);
  font-weight: 700;
}
/* ★失败原因（服务端给的人话 ✓）—— 与题目的失败反馈**同一套语汇**（左边一道红竖线 ✓）：
   ⚠️ 别做成弹窗 ✗：面板开着，学生要能一边看"还差 120 金币"一边看卡片 ✓ */
.prof-note {
  margin: 0 0 10px;
  padding: 6px 10px;
  border-left: 3px solid var(--bs-danger, #dc3545);
  border-radius: 0 6px 6px 0;
  background: rgb(220 53 69 / .12);
  color: var(--text);
  font-size: 12px;
  line-height: 1.7;
}
/* 底部的"下一步"—— 与提示栏的引导**同一句话** ✓（两处各造一套必然漂移 ✗） */
.prof-foot__step {
  margin: 12px 0 0;
  padding-top: 10px;
  border-top: 1px solid var(--border);
  color: var(--text);
  font-size: 12px;
  line-height: 1.8;
}
.prof-actions { margin-top: 12px; text-align: right; }
.prof-btn {
  padding: 6px 16px;
  border: 1px solid var(--border);
  border-radius: 6px;
  background: var(--bg-panel);
  color: var(--text);
  font-size: 13px;
  cursor: pointer;
  transition: all .15s;
}
.prof-btn--primary {
  border-color: var(--ok);
  color: var(--ok);
  font-weight: 700;
}
.prof-btn--primary:hover { background: rgb(255 255 255 / .06); }

/* ---- 捏人 / 外观（B 轮 · 2026-09-21 加 ✓ 见 `Plan/6` §九）----
   ⚠️★预览必须 `image-rendering: pixelated` ✗ —— 32×32 的图放大到 96 会糊成一团 ✓
      （与全站 Phaser 的 `pixelArt` 同一条：像素图放大只能**最近邻** ✓） */
.avatar-wrap {
  display: flex;
  gap: 16px;
  align-items: flex-start;
}
.avatar-preview {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 6px;
  padding: 10px;
  border: 1px solid var(--border);
  border-radius: 10px;
  background: rgb(255 255 255 / .04);
  color: var(--text-dim);
  font-size: 11px;
}
.avatar-preview canvas {
  width: 96px;
  height: 96px;
  image-rendering: pixelated;
}
.avatar-preview__hint {
  max-width: 96px;
  color: var(--text-dim);
  font-size: 11px;
  line-height: 1.6;
}
.avatar-rows {
  display: flex;
  flex: 1;
  flex-direction: column;
  gap: 12px;
  min-width: 0;
}
.avatar-row__head {
  display: flex;
  align-items: baseline;
  gap: 8px;
  margin-bottom: 6px;
  color: var(--text);
  font-size: 12px;
}
.avatar-row__head span { color: var(--text-dim); font-size: 11px; }
.avatar-opts {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
}
.avatar-opt {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 4px 10px;
  border: 1px solid var(--border);
  border-radius: 999px;
  background: var(--bg-panel);
  color: var(--text);
  font-size: 12px;
  cursor: pointer;
  transition: all .15s;
}
.avatar-opt:hover { background: rgb(255 255 255 / .07); }
/* ⚠️ 选中态**不能只靠颜色** ✗（色觉差异 —— 与职业卡 / NPC 头顶标记同一条口径 ✓）
   ⇒ 边框 + 加粗 + **对勾**三重线索 ✓ */
.avatar-opt.is-on {
  border-color: var(--ok);
  color: var(--ok);
  font-weight: 700;
}
.avatar-opt.is-on::after { content: '✓'; font-size: 12px; }
.avatar-swatch {
  width: 12px;
  height: 12px;
  border: 1px solid rgb(0 0 0 / .45);
  border-radius: 3px;
}
.avatar-foot {
  margin: 10px 0 0;
  color: var(--text-dim);
  font-size: 11px;
  line-height: 1.7;
}
/* 好消息复用 `.prof-note` 的形态 ✓（换个色即可 ⇒ 不为它再造一套组件 ✓） */
.prof-note.is-good {
  border-left-color: var(--ok);
  background: rgb(47 158 99 / .12);
}

/* ---- 结果展示（判定卡 / 指标卡 / 编译输出 / 奖励）----
   已抽到 theme.css 第十一节，与演示页的结果弹层**共用同一套组件**。
   本文件不再重复定义，避免两处各写一份、日久走形。 */

/* ============================================================
   五、分屏比例分档（6.6：≥1920 用 3/5，较小屏幕降到 1/2）
   ============================================================ */
@media (max-width: 1919px) {
  .layout { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
}

/* 窄屏：改为上下堆叠，优先保证游戏区高度 */
@media (max-width: 900px) {
  .layout {
    grid-template-columns: minmax(0, 1fr);
    grid-template-rows: auto minmax(260px, 1fr) minmax(300px, 1.2fr);
    grid-template-areas:
      "toolbar"
      "right"
      "left";
  }
  .task { max-height: 30%; }
}

/* ============================================================
   ★2026-09-25：环节面板头部重排（**覆盖式新规则** ✓ 老规则一行未动 ✓）

   背景：头部从「标题 + 进度」变成「环节 chips + 进度 + 本环节奖励」三件套 ✓
   （DOM 见 `game.html` 里 `#questSteps` / `#questRewards` 两处的说明 ✓
     改动的缘由见"提示信息冗余且不统一"那轮讨论 ✓）

   为什么写在文件**末尾**、而不是原地改老规则 ✗：
     ① 按本次约定（回滚双保险）：老规则一个字不动 ✓ —— 回滚只需删掉本段 ✓
     ② 同为 (0,1,0) 特异性 ⇒ "后加载者胜" ✓，写在这里才压得住上面第 310 / 325 行 ✓
        ⚠️ 别把本段往上挪、也别拆到别的文件里 ✗ —— 一挪就失效 ✓

   ── 三块能不能"保证一行" ✓？答：谁都不能保证，但可以**两级降级**：
     放不放得下由**数据**决定（题型名长短 / 经验金币位数 / 主题字号 / 分屏宽度 ✓）——
     `game.css` 第 1919px 断点把布局改成"两栏等宽" ⇒ 1280px 窗口时右栏内宽仅约 600px ✓
     ① `#questSteps` 自己会换行（老规则里已有 `flex-wrap: wrap` ✓）
        ⇒ 给它 `flex: 1 1 auto; min-width: 0`，把**折行压力全部塞进 chips 内部** ✓
          另两块（进度 / 奖励）于是保持单行、稳稳贴右 ✓
     ② `.quest__head` 也允许换行 ⇒ 极窄时进度 + 奖励**整体掉到第二行右端** ✓
        ⚠️ 不补这条就会**横向溢出**：老 head 没有 `flex-wrap`，而 `.quest` 也没有
           `overflow: hidden` ⇒ 内容直接穿出面板边框 ✓（本次必须补的一环 ✗）
     ③ 两块固定短语加 `white-space: nowrap` ✓（本段已加 ✓）：
        不加会被折在"经验"和"/"之间，读起来像两个数字 ✓
   ============================================================ */

/* ---- 头部容器：成员变了 ⇒ 对齐方式跟着变 ---- */
.quest__head {
  /* 老值 `baseline` 是给"标题 + 进度"两个 span 算的 ✓
     ⚠️ 而 `#questSteps` 是 `<ol>`、自身也是 flex 容器 ⇒ 它的基线取的是
        **第一个 chip** 的基线，chip 又带 `padding: 4px 9px` ⇒ 与 11px 小字对不齐 ✓ */
  align-items: center;
  /* ★[降级②] 极窄时整体折行，绝不横向溢出 ✓（理由见本节抬头 ✓） */
  flex-wrap: wrap;
}

/* ---- 进度：禁止折行 + ★字号抬到 12px（2026-09-25 ✓）---- */
.quest__progress {
  /* ★新增（老规则里没有这一条 ✓）：加 `nowrap` 以后，两块右侧文案的
     `min-width: auto` 就等于各自**整串文字** ⇒ 挤不下时的**收缩压力全部落到
     chips 身上** ✓（这正是 [降级①] 想要的方向 ✓）
     ⚠️ 不写的话：`1/5 环节　·　必做 1/5` 会在中间断开成两行，
        而 chips 反而保持两行 ⇒ 头部变成高低参差 ✓ */
  white-space: nowrap;
  /* ★★2026-09-25：11px → 12px ✓（"6.6 骨架"里的老规则**一行没动** ✓ 这里覆盖 ✓）
     —— 与 `.quest__rewards` 统一到同一档 ✓：两块是"一对"，此前 11px / 14px 差三档、
        字族还不同（本块 mono、奖励块继承 sans）⇒ 看着毫无血缘关系 ✓
     ⚠️ 取 12px 而不是 13px：13px 是 `.problem-body` 的**正文档** ⇒ 统计比右下方
        "要读的提示"（`.quest__meta` 11px）还大 = 层级倒挂 ✓
        12px 正好是 `.problem-note` / `.empty-hint` / `.navlink` 那一档 ✓
     ⚠️ 颜色 / 字重不动 ✗：`--text-dim` 让统计"沉下去"，与奖励的 `--text` + 700 分层 ✓ */
  font-size: 12px;
}

/* ---- 环节 chips：进 head 后从"兄弟"变成"子" ---- */
.quest__steps {
  /* ⚠️ 老值 `flex-shrink: 0` 是它在 `.quest` 里**纵向**排布时"防压扁"用的 ✗ ——
        现在躺在**横向** flex 里，那个值会让它**拒不让位** ⇒ 把另两块顶出面板 ✓ */
  /* ★[降级①] 可伸可缩 + 允许比内容更窄 ⇒ chips 会在自己内部换行 ✓ */
  flex: 1 1 auto;
  min-width: 0;
  /* ⚠️ 老值 `margin: 10px 0 0` 是"与 head 拉开 10px"用的 ✗ ——
        它自己现在就是 head 的内容，这 10px 会让 chips 相对右侧两块文字下沉 ✓ */
  margin: 0;
}

/* ---- 本环节奖励（原 `#questTitle`，回滚说明见 `game.html` 该元素处 ✓）----
   ⚠️ 颜色**不写死 `#000`** ✗：本页 7 套主题里 `dark` 的面板底色是 `#161b26`，
      黑字在上面**看不见** ✓（默认主题虽是 `light`，但主题由学生自选 ✓）
      `var(--text)` = "本主题下最实的前景色"：light ⇒ `#16324f`（近黑）；
      dark ⇒ `#d7dee8`（亮）✓ —— 这才是"黑色字体"在各主题里的等价物 ✓
      若确要写死黑，只能限定浅色主题内（不推荐 ✗） */
.quest__rewards {
  /* ⚠️ 颜色 / 字重**刻意不动** ✗（2026-09-25 已确认 ✓）：`var(--text)` + `700` 是
     这两块**唯一的层级差** —— 奖励要"跳出来"、进度要"沉下去" ✓
     若把颜色也统一，等于把"能拿什么"混进统计里 ⇒ 学生分不清哪块是要争取的 ✓ */
  color: var(--text);
  font-weight: 700;
  /* ★★2026-09-25 两处调整（与 `.quest__progress` 配成一对 ✓）：
     ① 14px → 12px：与进度**同档** ✓（差值改由上面的颜色/字重表达 ✓）
     ② 补 `font-family: var(--mono)`：与进度**同字族** ⇒ `1/5`、`·`、`20`、`30`
        这些数字与分隔符宽度一致，两块才真像一对 ✓（中文都走同一套 CJK 回退 ✓） */
  font-size: 12px;
  font-family: var(--mono);
  white-space: nowrap;
  /* letter-spacing: 1px;   ★2026-09-25 去掉（老写法注释保留 ✓ 方便回滚）：这串
     「完成可得 20 经验 / 30 金币」≈17 个字符位 ⇒ 字距一去掉省回 ≈17px，
     正好抵掉"mono 的空格比 sans 宽"（4 个空格 ≈ +13px）——
     整体仍比原来窄 ≈20px ⇒ 对"三块一行"是顺风 ✓ */
}
/* ★奖励缺省（剧情任务 / 未配奖励）⇒ 整块藏掉 ✗：
   ⚠️ 不藏的话，`.quest__head` 的 `gap: 10px` 会替这个空 span 留出一段空隙 ✓
   （span 未声明 `display` ⇒ `[hidden]` 本就生效 ✓ 这里显式写一遍，防将来有人
     给它加 `display` ✗ —— 同一个坑见上面 `.quest[hidden]` 与主题样式 ✓）*/
.quest__rewards[hidden] { display: none; }
