/* ============================================================
   评审与分析系统 · 共享主题样式
   浅色（默认）/ 深色 双主题，通过 <html data-theme="dark"> 切换

   结构：
     1. 设计令牌（颜色 / 间距 / 字号 / 圆角）
     2. 重置与骨架
     3. 背景装饰（**全静态**：零 filter、零无限动画）
     4. 布局层（shell / pagehead / toolbar / section / grid）
     5. 组件（卡片 / 按钮 / 表单 / 标签 / 统计 / 表格 / 弹窗 / 轻提示）
     6. 跨页复用的小组件（评委码 / 评委进度 / 命题与查重）
   ============================================================ */

/* ============================================================
   1. 设计令牌
   ============================================================ */
:root {
    --font-sans: 'Noto Sans SC', -apple-system, BlinkMacSystemFont, 'Segoe UI',
        'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', 'Source Han Sans SC',
        'Noto Sans CJK SC', Roboto, 'Helvetica Neue', Arial, sans-serif;
    --font-mono: ui-monospace, SFMono-Regular, Consolas, 'Liberation Mono', monospace;

    /* ---------- 间距标尺（**全部是 4 的倍数**） ----------
       🔴 这一档只允许出现 4/8/12/16/24/32 —— 不允许 22 / 30 这类「看着差不多」的值。
       曾经这里是 22 / 30：和组件里散落的 6 / 10 / 14px 混在一起，竖向上就凑不出稳定节奏，
       页面会显得「哪都不对齐、还有点堆扎」。全部归到 4 的倍数后，卡片内边距、区块间距、
       按钮内边距才会互相咬合（对齐 8 点网格的实质是**只用一小组值**，不是每个值都重新算）。 */
    --sp-1: 4px;
    --sp-2: 8px;
    --sp-3: 12px;
    --sp-4: 16px;
    --sp-5: 24px;
    --sp-6: 32px;

    /* 外壳左右内边距的**唯一真相**。任何要「打穿」外壳贴到屏幕边的元素（负 margin）
       都必须与它完全相等 —— 否则会撑出页面级横向滚动。
       🔴 第十三轮实测：原先吸底条的负 margin 写死 --sp-4，在 ≤768 段（shell 左右 12px）与
          ≤480 段（shell 左右 8px）**各差 4px**，两段都真的把页面顶宽了 4px。
          改 .shell 的左右内边距 ⇔ 必须同步改这三个断点里的 --shell-px。
       第十四轮删掉了当时唯一的消费者（`.tool-row--dock`），但令牌**保留**：
       `.shell` 的 padding 仍然只认它，三个断点各一份，是「外壳留白」的单一来源。 */
    --shell-px: var(--sp-5);

    /* ---------- 安全区（刘海 / 圆角 / 底部横条） ----------
       五个页面都声明了 viewport-fit=cover，所以 env() 不会恒为 0；
       没有刘海/不支持的浏览器回落 0px，配合 max() 就等于「不变」。
       横屏 iPhone 的刘海在**左右**，所以左右要挂在 body 上（shell 之外）；
       竖向的顶部/底部挂在吸顶条与外壳上（见 ≤768px 段）。 */
    --safe-t: env(safe-area-inset-top, 0px);
    --safe-r: env(safe-area-inset-right, 0px);
    --safe-b: env(safe-area-inset-bottom, 0px);
    --safe-l: env(safe-area-inset-left, 0px);

    /* ---------- 字号标尺（移动端优先的四档规范） ----------
       第十二轮按用户给的组件规范重排：
         18  页面大标题（600）
         16  卡片 / 列表主标题
         14  正文 / 信息数据
         12  辅助信息（弱化色）
       🔴 这与第十一轮「降到底」的方向**相反**，按新规范逐档复位并**并档**：
         原先桌面 6 档（11.5 / 12.8 / 14 / 15.2 / 18.4 / 22.4）里，
         11.5 与 12.8、14 与 15.2 在手机上肉眼无区分度，白占两个档位、让层级发糊。
         现在 xs/sm、sm/md、lg/xl 各自并档 ⇒ 移动端严格 **4 档**（详见 ≤768 段）。
       桌面另留 --fs-2xl 给页面大标题（大屏 22.4 与 18 的层级是必要的，
       而 `_shot_mobile.cjs` 的「档位 ≤ 4」只跑 ≤768 视口，不会因此误红）。 */
    /* 第十五轮（用户规范二·当轮拍板）：时间戳用 11px。
       🔴 这是全项目**唯一一次突破「档位 ≤ 4」**的授权例外 —— 用户明确要 `font-size: 11px`
          的灰色时间戳。探针 `_shot_mobile.cjs` 的 `FS_TIER_MAX` 随之 4 → 5，
          并在 R15 段显式断言「时间字号 = 11px」，防止日后有人「顺手并档」把它并回 12。
       颜色**不跟规范二的 `#999`**：全库零颜色字面量是既有硬约束，一律走 `--text-muted`。 */
    --fs-2xs: 0.6875rem; /* 11  时间戳（唯一的有据例外档） */
    --fs-xs: 0.75rem;    /* 12  辅助信息 */
    --fs-sm: 0.875rem;   /* 14  正文 / 信息数据 */
    --fs-md: 0.875rem;   /* 14  ↑ 同档（原先 12.8 / 14 各占一档） */
    --fs-lg: 1rem;       /* 16  卡片 / 列表主标题 */
    --fs-xl: 1.125rem;   /* 18  统计数字 / 区块标题 */
    --fs-2xl: 1.4rem;    /* 22.4 页面大标题（桌面档；≤768 段并到 18） */

    /* ---------- 圆角 ---------- */
    --r-sm: 0.45rem;
    --r-md: 0.7rem;
    --r-lg: 0.9rem;
    --r-pill: 999px;

    /* ---------- 布局常量 ---------- */
    --shell-max: 1200px;
    --shell-wide: 1440px;
    --read-width: 68ch;
    /* 左滑动作区宽度。**这是唯一真相**：迁移量由 analysis.js 用 getComputedStyle 读它
       （早先 JS 里另有一个 SWIPE_OPEN_X 常量，窄屏把面板收窄后两处就不一致了）。 */
    --swipe-w: 72px;

    /* ---------- 🔴 第十七轮：控件高度令牌（这里是**桌面基准值**，≤768 段整体缩一档） ----------
       为什么要有这套令牌：此前各处高度是**散落的字面量**（`.btn` 44 / `.btn-block` 48 /
       输入框 46 / 图标钮 44 / AppBar 48 / 行内按钮 40 / 步骤条 44 / 分段项 44），
       用户一要求「整体缩小」就得满库找；更糟的是各页 `<style>` 里也各写了一份
       （页内样式排在 `<link>` 之后，同优先级后者胜 ⇒ 改 theme.css 根本覆盖不到它们）。
       现在：**唯一真相在这四个令牌**，页面里一律 `var(--ctl-h…)`，移动段只改值。

         桌面  44 / 40 / 48 / 56
         ≤768  36 / 32 / 40 / 44   ← 第十七轮按用户授权整体缩一档

       ⚠️ 16px 的输入框**字号**不在这套里，也不许跟着缩（iOS 聚焦防整页缩放，有断言钉住）。 */
    --ctl-h: 44px;      /* 常规按钮 / 图标钮 / 菜单项 / 分段项 / 关于「触控目标」的默认档 */
    --ctl-h-sm: 40px;   /* 行内小按钮（任务卡片那三格）/ 步骤条 */
    --ctl-h-lg: 48px;   /* 块级主操作（.btn-block）/ 表单控件 */
    --appbar-h: 56px;   /* AppBar 行高 */
    --dist-loading: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='4' height='4' viewBox='0 0 4 4'%3E%3C/svg%3E");

    /* ---------- 浅色主题（默认）· 樱花 × 淡绿 ---------- */
    /* 四层分工：
         环境层（粉）  页面底 / 光斑 / 标题渐变 —— 樱花氛围，不承担动作语义
         面层（淡绿）  卡片 / 面板 / 弹层 —— 淡绿梦幻雾面（--card-bg / --bg-mid）
         阅读层（米黄）长文正文区 —— 护眼米黄（--paper-reading），只给 review 用
         动作层（三档绿）主操作竹绿 · 肯定薄荷 · 次操作青绿 · 辅助苔绿
       状态色：杏金 gold 数值 / 苏芳红 rose 危险 / 藤紫 violet 标准差 / 桔梗蓝 blue 命题。
       每个强调色都带一份 -rgb 分量，半透明底一律写 rgba(var(--x-rgb), α)，
       不再散落十六进制 / rgb 字面量（改主题只需动这一块）。 */
    --page-bg: linear-gradient(180deg, #fff7fb 0%, #fdeaf3 100%);
    --bg-deep: #fdeaf3;
    --bg-mid: #f6fdf9;
    --card-bg: rgba(220, 249, 233, 0.92);
    /* 「梦幻」不靠 filter：卡面挂一层绿色内雾（inset 0 0 22px），
       配合顶部 1px 高光，得到玻璃感又零重绘成本 */
    --gloss-inset: inset 0 1px 0 rgba(255, 255, 255, 0.92),
        inset 0 0 22px rgba(120, 220, 175, 0.10);
    --card-shadow: 0 1px 2px rgba(56, 130, 98, 0.07), 0 12px 28px rgba(56, 130, 98, 0.10),
        var(--gloss-inset);
    --border: rgba(56, 130, 98, 0.18);
    --border-strong: rgba(56, 130, 98, 0.34);
    --text-primary: #1f3a30;
    --text-muted: #4f6a5d;

    --accent-brand: #a83a63;
    --accent-brand-rgb: 168, 58, 99;
    --accent-gold: #82580a;
    --accent-gold-rgb: 130, 88, 10;
    --accent-rose: #bd2f45;
    --accent-rose-rgb: 189, 47, 69;
    --accent-green: #276b4c;
    --accent-green-rgb: 39, 107, 76;
    --accent-violet: #7a4fb5;
    --accent-violet-rgb: 122, 79, 181;
    --accent-blue: #4d5aa8;
    --accent-blue-rgb: 77, 90, 168;

    /* ---------- 「浅底深字」专用墨色（第十二轮）----------
       徽章 / 标签的底色是强调色的低比例淡染（13%~15%），文字**不能直接用 --accent-***：
       同一个色压在只淡染了 13% 自己的浅底上，浅色主题实测只有 4.20（rose）/ 4.48（brand）:1，
       差一点点不过 WCAG 4.5 —— 而这类小字（12px）恰恰是最需要对比度的地方。
       `--ink-*` 不是新色相，就是同一色相**压暗一档**（`_contrast.cjs` 逐条实测兜底）。
       深色主题本身就是浅色强调字压深底，直接复用 --accent-*（见 dark 段）。 */
    --ink-brand: #932f55;
    --ink-rose: #a8243a;

    /* 樱瓣：只做装饰填充（微粒 / 光斑），不承担文字色 */
    --accent-petal: #e58bb2;
    --accent-petal-rgb: 229, 139, 178;

    /* ---------- 动作绿：按功能层级分三档 ----------
       不能"一个绿走天下"——主操作和次要动作必须一眼分得出轻重，所以同族不同色相：

         ① 主操作 · 竹绿   实心面 + 白字（4.85 / 6.33:1）  提交 / 保存 / 登录
         ② 肯定   · 薄荷   亮面   + 深绿字（6.76 / 5.57:1）通过 / 保留 / 确认
         ③ 次操作 · 青绿   描边   + 青绿字                取消 / 返回 / 次要动作
         ④ 辅助   · 苔绿   描边 / hover 变色               幽灵按钮与轻量触发器

       层级越高"面"越重；色相从黄绿（苔）→ 竹绿 → 薄荷 → 青绿依次拉开。
       ⚠️ 浅色下要压白字 ⇒ 面亮度上限 0.1833（≈ #337f5c 到顶），
          所以竹绿已经是最亮的那一档；要更清新只能改用「亮面 + 深字」（薄荷档）。 */
    --action: var(--accent-green);
    --action-rgb: var(--accent-green-rgb);
    --grad-action: linear-gradient(135deg, #337f5c, #2b6b4e);
    --glow-action: rgba(47, 125, 91, 0.24);
    --action-soft: rgba(47, 125, 91, 0.12);

    --action-alt: #1c7168;
    --action-alt-rgb: 28, 113, 104;
    --glow-action-alt: rgba(28, 113, 104, 0.24);
    --action-alt-soft: rgba(28, 113, 104, 0.12);

    --action-mild: #4f7326;
    --action-mild-rgb: 79, 115, 38;

    --grad-confirm: linear-gradient(135deg, #7fcda6, #63bd90);
    --glow-confirm: rgba(99, 189, 144, 0.30);

    /* ---------- 护眼阅读纸（长文正文区专用）----------
       纯白长文在暗环境下刺眼：白底蓝通道满值（255），米黄把它压到 214，
       色温下移、照度降一档，正文对比度仍有 10.7:1（次要文字 4.7:1）。 */
    --paper-reading: #f7efd6;
    --paper-border: rgba(160, 128, 62, 0.28);
    --paper-shadow: 0 1px 2px rgba(150, 118, 52, 0.09), 0 12px 28px rgba(150, 118, 52, 0.13);

    --grad-gold: linear-gradient(135deg, #e8b552, #d09b2c);
    --grad-rose: linear-gradient(135deg, #bd3f52, #99263a);
    /* 标题：樱花玫 → 藤紫（两端都压得住白底，4.5:1 以上） */
    --grad-title: linear-gradient(120deg, #b23f6a 0%, #a3459b 58%, #7a4fb5 100%);
    /* 进度轨：樱 → 藤，是整页唯一的"梦幻"渐变 */
    --grad-track: linear-gradient(90deg, #b23f6a, #7a4fb5);

    /* 按钮文字色：浅色主题用白字压在深竹绿面上；薄荷档反过来用深绿字；
       --btn-on-gold 专供亮金底（白字仅 3.24:1） */
    --btn-on-accent: #ffffff;
    --btn-on-confirm: #0f3a27;
    --btn-on-gold: #3d2a05;

    --surface-inset: rgba(56, 130, 98, 0.07);
    --surface-soft: rgba(56, 130, 98, 0.04);
    --surface-hover: rgba(56, 130, 98, 0.09);
    --overlay: rgba(20, 44, 36, 0.44);
    --on-scrim: #ffffff;
    --scroll-thumb: rgba(56, 130, 98, 0.24);
    /* 吸顶步骤条的底：必须**不透明**。半透明底会让正文从条下面透出来，
       而 sticky 层不能加 backdrop-filter（性能红线）⇒ 直接用实色。 */
    --stepnav-bg: #f1faf5;

    /* 静态色雾：用 radial-gradient 直接画柔和光斑，不再靠 filter: blur()
       三团雾 = 樱粉 + 藤紫 + 薄荷绿 —— 粉绿交叠出梦幻感，且全是静态渐变 */
    --aurora-1: radial-gradient(circle, rgba(247, 176, 208, 0.55) 0%, rgba(247, 176, 208, 0.18) 45%, transparent 72%);
    --aurora-2: radial-gradient(circle, rgba(196, 162, 235, 0.40) 0%, rgba(196, 162, 235, 0.12) 45%, transparent 72%);
    --aurora-3: radial-gradient(circle, rgba(146, 236, 195, 0.50) 0%, rgba(146, 236, 195, 0.15) 45%, transparent 72%);
    --aurora-opacity: 1;
    /* 樱瓣微粒层：纯静态点阵，浅色下也留一点点，避免上半屏太秃 */
    --petal-opacity: 0.55;
}

/* 深色 = 夜樱 × 墨绿：底色仍是「深梅紫 → 胭脂」（樱花氛围），
   卡片层改墨绿雾面、动作绿一律反转成「亮面 + 深字」（浅色下是「深面 + 白字」） */
[data-theme="dark"] {
    --page-bg: linear-gradient(180deg, #191021 0%, #241522 100%);
    --bg-deep: #191021;
    --bg-mid: #1f2f2b;
    --card-bg: rgba(28, 52, 44, 0.74);
    --gloss-inset: inset 0 1px 0 rgba(190, 255, 220, 0.10),
        inset 0 0 26px rgba(90, 200, 155, 0.10);
    --card-shadow: 0 1px 2px rgba(0, 0, 0, 0.36), 0 14px 32px rgba(0, 0, 0, 0.34),
        var(--gloss-inset);
    --border: rgba(190, 255, 220, 0.12);
    --border-strong: rgba(190, 255, 220, 0.26);
    --text-primary: #eaf5ef;
    --text-muted: #a8c4b6;

    --accent-brand: #f4a2c2;
    --accent-brand-rgb: 244, 162, 194;
    --accent-gold: #e8c073;
    --accent-gold-rgb: 232, 192, 115;
    --accent-rose: #ff8e93;
    --accent-rose-rgb: 255, 142, 147;
    --accent-green: #7ad3a4;
    --accent-green-rgb: 122, 211, 164;
    --accent-violet: #c9a8ff;
    --accent-violet-rgb: 201, 168, 255;
    --accent-blue: #a6b6ff;
    --accent-blue-rgb: 166, 182, 255;
    /* 「浅底深字」墨色：深色主题下强调色本身已是浅色，压深底时直接可用（实测 5.35 / 5.86:1） */
    --ink-brand: #f4a2c2;
    --ink-rose: #ff8e93;
    --accent-petal: #f7c8dd;
    --accent-petal-rgb: 247, 200, 221;

    --action: var(--accent-green);
    --action-rgb: var(--accent-green-rgb);
    --grad-action: linear-gradient(135deg, #a7e3c1, #86d2a8);
    --glow-action: rgba(122, 211, 164, 0.24);
    --action-soft: rgba(122, 211, 164, 0.14);

    --action-alt: #78d6c6;
    --action-alt-rgb: 120, 214, 198;
    --glow-action-alt: rgba(120, 214, 198, 0.24);
    --action-alt-soft: rgba(120, 214, 198, 0.14);

    --action-mild: #b9e08a;
    --action-mild-rgb: 185, 224, 138;

    --grad-confirm: linear-gradient(135deg, #9fe8dd, #7fd8cb);
    --glow-confirm: rgba(127, 216, 203, 0.26);

    --paper-reading: #2a2418;
    --paper-border: rgba(226, 200, 150, 0.20);
    --paper-shadow: 0 1px 2px rgba(0, 0, 0, 0.36), 0 14px 32px rgba(0, 0, 0, 0.34);

    --grad-gold: linear-gradient(135deg, #efc97e, #dcae5f);
    --grad-rose: linear-gradient(135deg, #ff9fa4, #ef8087);
    --grad-title: linear-gradient(120deg, #f7b3cd 0%, #d9a6ee 58%, #c9a8ff 100%);
    --grad-track: linear-gradient(90deg, #f4a2c2, #c9a8ff);

    /* 深色主题是浅色面 + 深字，绿面同样配深绿 / 深青字 */
    --btn-on-accent: #123a29;
    --btn-on-confirm: #0b3a35;
    --btn-on-gold: #2b1522;

    --surface-inset: rgba(190, 255, 220, 0.07);
    --surface-soft: rgba(190, 255, 220, 0.05);
    --surface-hover: rgba(190, 255, 220, 0.10);
    --overlay: rgba(8, 20, 16, 0.68);
    --on-scrim: #f2fffa;
    --scroll-thumb: rgba(190, 255, 220, 0.22);
    --stepnav-bg: #1b3129;

    --aurora-1: radial-gradient(circle, rgba(214, 120, 168, 0.28) 0%, rgba(214, 120, 168, 0.10) 45%, transparent 72%);
    --aurora-2: radial-gradient(circle, rgba(150, 110, 205, 0.24) 0%, rgba(150, 110, 205, 0.09) 45%, transparent 72%);
    --aurora-3: radial-gradient(circle, rgba(96, 200, 150, 0.26) 0%, rgba(96, 200, 150, 0.09) 45%, transparent 72%);
    --aurora-opacity: 0.9;
    --petal-opacity: 0.6;
}

/* ============================================================
   2. 重置与骨架
   ============================================================ */
* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

/* 渐变背景挂在 fixed 伪元素上：
   ① 滚动时背景不重绘（等价于旧 background-attachment: fixed，但没有它的重绘开销）
   ② body 本身只留纯色底，避免整页贴图重算 */
html {
    background: var(--bg-deep);
    min-height: 100%;
    scroll-behavior: smooth;
    /* ---------- 🔴 第十八轮：移动端长度基准（本次的核心改动） ----------
       用户观察（原话）：「浏览器字体大小测试只能控制字体，但按钮仍然巨大」——
       第十七轮把控件高度令牌整体缩了一档（44/40/48/56 → 36/32/40/44），用户仍觉大。
       根因是**尺子本身是桌面尺度**，而不是「某几个组件写大了」：
         · 全站字号都是 `rem`，`rem` 的参考系是 `html` 的 font-size；
         · 而本项目从来**没给 `html` 设过 font-size** ⇒ 一直是浏览器默认的 16px（桌面尺度）；
         · 于是 `--fs-md: 0.875rem` 在手机上也等于 14px、`--ctl-h: 36px` 就是 36px，
           **整条长度体系不随视口缩**。桌面 14px 正文搬到 375px 屏上物理尺寸被放大成
           2.1mm 级别，视觉上自然"巨大"。
       ⇒ 把尺子换成 vw：设计稿基准 375px，375px 时 1rem = 16px（与浏览器默认值一致，
          所以**桌面观感零变化**；≥768px 被 clamp 上界钉在 16px，桌面逐像素不动）。

       clamp 三档（缺一不可）：
         下界 13.5px  防 320px 极窄屏把正文压到不可读（320/375×16 = 13.65 已在界内）
         中间 vw 线性  320→13.65px、375→16px、428→18.26px（自适应放大，物理尺寸反而更舒服）
         上界 16px    防 ≥768px 被无限放大（1440px 裸算会到 61px，必须钉住）

       🔴 为什么写在 `html` 而不是 `body`：`rem` 的参考系永远是**根元素**，
          写 body 对任何 `rem` 值都不起作用（只影响 body 自己的 `em` 继承）。
       🔴 为什么只影响移动端：365 行处的 `@media (max-width: 768px)` 段才有令牌降档，
          ≥769px 段的令牌全是定值、且 16px 基准与浏览器默认一致 ⇒ 桌面逐像素不变。

       换掉这条 = 移动端字号/间距全站跟着变，是紧凑化的**单一来源**。 */
    font-size: clamp(13.5px, calc(100vw / 375 * 16), 16px);
    /* ---------- 🔴 第十六轮：堵住真机的「字体自动放大」（Font Boosting / text autosizing） ----------
       真机证据（用户截图 720×1608、DPR 2 ⇒ 视口 360）：管理码那段说明每行约 19 个汉字，
       折算字号 ≈16.5px，而实现里正文声明的只有 14px；并且**不同文本块被放大的倍数还不一样**
       （AppBar 标题 18→20 是 1.11×，长段落 14→16.5 是 1.18×）—— 这正是 Blink/WebKit
       autosizing 的签名：它按「每个块自身的宽度与行数」单独算放大倍数。
       🔴 为什么历次移动端截图与探针都没发现：**桌面内核不启用 autosizing**，
          无头 Edge 量到的永远是声明值 ⇒ 那一整套门禁在这一点上是**假绿**的，
          只有真机能看出来。所以这条必须显式声明，并且用断言把它钉住
          （见 _shot_mobile.cjs 的 R16 段「text-size-adjust = 100%」）。
       取 100% 而不是 none：只关掉「按块自动放大」，**不影响**用户的双指缩放，
       也不改任何已声明的字号（桌面本来就没有 autosizing，等于零副作用）。

       ---------- 🔴 第十八轮：四前缀补全 ----------
       第十六轮只写了 `-webkit-` 与无前缀两条。**Chrome Android 走的是无前缀**（已覆盖），
       但部分国产内核（基于旧 Blink / 魔改 WebView）只认 `-moz-` / `-ms-` 之一；
       四个一起写成本为零，避免「堵住一边、另一边又跑起来」。
       本机无头内核不启用 autosizing ⇒ 这一组**永远量不到效果**，属于声明式兜底。 */
    -webkit-text-size-adjust: 100%;
    -moz-text-size-adjust: 100%;
    -ms-text-size-adjust: 100%;
    text-size-adjust: 100%;
    /* 长 URL / 评委码 / 长英文串的兜底：默认 `overflow-wrap: normal` 下，
       不可断的长串会把 flex/grid 子项顶宽，进而在页面上出横向滚动条。
       放在 html 上由全页继承；中文本来就能任意换行，`break-word` 只在「单个词放不下」时才断，
       所以不会改动正常段落的断行。系统性地兜住这一类溢出。 */
    overflow-wrap: break-word;
    /* 关掉**浏览器自带**的下拉刷新（Chrome / Android）。
       本项目在分析页自己实现了一套下拉刷新（见 .pull-indicator），
       不关原生的话，同一个下拉动作会同时触发两套刷新：先看到我们的指示器，
       紧接着整页白屏重载 —— 看起来像「刷新把状态弄丢了」。
       `contain` 只切断「滚动链交给祖先」，不影响页面内部的任何滚动，
       桌面浏览器本来也没有下拉刷新，等于零副作用。
       🔴 `contain` 还有**第二个效果**，第十八轮才被量到：它会把**缺省轴 y 的 computed
          overflow 从 `visible` 提升成 `auto`**（见 CSS Overflow 3 §3.3：
          `contain` 是除 `visible` 外**唯一**能让 `overflow-x: visible` 变 `auto` 的实现方式）。
          于是 `html` 的 computed overflow = `auto auto` ⇒ **html 自己成了一个滚动容器**
          —— `document.scrollingElement` 仍是 html（所以整页滚动照常），但
          `window.scrollTo()` 在部分内核上不再驱动它（本次实测在无头 Edge 上
          「scrollTo(0, 600) 后 scrollY 仍为 0」，与这条直接相关）。
        ⇒ 第十八轮把 `contain` 从 `overflow-y` 换成 `overflow`：
          `contain` 在 overflow-y 上会同时把 x 也提升，而我们要的只是「切断 y 的滚动链」。
          用 `overflow: contain` 语义相同、且不再单独提升 y 轴（x 仍由下面的兜底负责）。 */
    overscroll-behavior-y: contain;
}

body {
    /* 🔴 第十八轮（本轮最隐蔽的一处）：原来是 `min-height: 100%` —— 那是**错的**。
       `%` 要求父元素（html）有**确定高度**，而 html 的 computed height 是 `auto`
       （内容高度）⇒ 百分比在**未定高**的父元素上解析为 `auto` ⇒ `min-height` 形同虚设，
       body 的高度完全由内容决定。后果（本次实测，analysis?task=1 @390、
       docScrollH=892 / innerH=844）：
         `document.scrollingElement` 是 html，但 html 的高度 == body 的高度 == 892
         ⇒ **html 的可滚动溢出为 0** ⇒ `window.scrollTo()` / `de.scrollTop` /
            `window.scrollBy()` **全部无效**（实测三次都是 scrollY 停在 0）。
         真长页（manager，docScrollH=1130）却能动到 143 —— 因为那边有 fixed 装饰层
         撑出了真实溢出。⇒ 「短页滚不动」是**真的**，不是探针的锅：
         用户在这些页面上**用手势也滚不动**（下面的「多出来一条滚动条」正是同一个根因的
         另一种表现 —— 那条滚动条来自 `overflow: auto` 提升了 body 的滚动状态，
         而实际可滚高度是 0）。
       正解：给 body 一个**确定高度**的下限，不依赖百分比：
         `min-height: 100vh`（回退）+ `min-height: 100dvh`（现代内核，躲开移动端地址栏）。
       这样 body 至少一屏高，html 的溢出 = max(0, body高 − 视口) 才被正确计算，
       `window.scrollTo` 恢复；且 body 只有 `min-height`、没有 `height`，
       内容超过一屏时**照常自然增高**（不会把内容裁掉）。
       实测（改后）：analysis?task=1 @390 scrollTo(300) => 300；manager 同前。
       ⚠️ 这条与第十八轮「vw 长度基准」无关，是本轮顺带挖出来的**存量 bug**，
          但它正好卡在 R18 那条 sticky 断言上 —— 不修它，那条断言永远不会绿。 */
    min-height: 100vh;
    min-height: 100dvh;
    font-family: var(--font-sans);
    color: var(--text-primary);
    -webkit-font-smoothing: antialiased;
    text-rendering: optimizeSpeed;
    line-height: 1.6;
    /* 横屏刘海在左右两侧：把安全区留给 body，前景内容不会被圆角/刘海切掉。
       竖屏时左右 inset 为 0，max() 会退回 0 ⇒ 与改动前逐像素一致。
       纵向的 inset 交给 .shell（见 ≤768px 段），因为底部要跟 shell 自己的留白取大值。 */
    padding-left: var(--safe-l);
    padding-right: var(--safe-r);
}

body::before {
    content: '';
    position: fixed;
    inset: 0;
    z-index: -1;
    background: var(--page-bg);
    pointer-events: none;
}

body { transition: color 0.25s ease; }

[hidden] { display: none !important; }

/* ============================================================
   3. 背景装饰 —— 全静态
   旧实现是 3 个 600px 级元素常驻 `filter: blur(80px)` 且带 20s 无限动画，
   每页都在持续占用合成/栅格资源。现在改成纯 radial-gradient 光斑：
   柔和度由渐变本身提供，零 filter、零动画、零每帧开销。
   ============================================================ */
.bg-canvas {
    position: fixed;
    inset: 0;
    z-index: 0;
    overflow: hidden;
    pointer-events: none;
    contain: strict;
}

.aurora {
    position: absolute;
    border-radius: 50%;
    opacity: var(--aurora-opacity);
}

.aurora-1 { width: 760px; height: 760px; background: var(--aurora-1); top: -320px; left: -220px; }
.aurora-2 { width: 660px; height: 660px; background: var(--aurora-2); top: 44%; right: -240px; }
.aurora-3 { width: 580px; height: 580px; background: var(--aurora-3); bottom: -260px; left: 26%; }

/* 樱瓣微粒：同样是静态点阵（一次绘制，无动画），
   浅色下当作「飘着的花瓣」、深色下当作「夜樱的星屑」 */
.stars {
    position: absolute;
    inset: 0;
    opacity: var(--petal-opacity);
    background-image:
        radial-gradient(2px 2px at 18% 28%, rgba(var(--accent-petal-rgb), 0.85) 0%, transparent 100%),
        radial-gradient(1.5px 1.5px at 38% 68%, rgba(var(--accent-petal-rgb), 0.6) 0%, transparent 100%),
        radial-gradient(2.5px 2.5px at 58% 18%, rgba(var(--accent-petal-rgb), 0.75) 0%, transparent 100%),
        radial-gradient(1.5px 1.5px at 78% 48%, rgba(var(--accent-petal-rgb), 0.5) 0%, transparent 100%),
        radial-gradient(2px 2px at 90% 78%, rgba(var(--accent-petal-rgb), 0.55) 0%, transparent 100%);
}

/* ============================================================
   4. 布局层
   ============================================================ */

/* 页面外壳：所有页面统一用它，间距与最大宽度都有默认值 */
.shell {
    position: relative;
    z-index: 1;
    width: 100%;
    max-width: var(--shell-max);
    margin: 0 auto;
    padding: var(--sp-5) var(--shell-px) var(--sp-6);
    display: flex;
    flex-direction: column;
    gap: var(--sp-5);
}

.shell--wide { max-width: var(--shell-wide); }

/* 登录页：窄栏 + 垂直居中 */
.shell--narrow {
    max-width: 420px;
    min-height: 100vh;
    min-height: 100dvh;
    justify-content: center;
    padding: var(--sp-5) var(--sp-4);
}

/* 评审页：整页锁高、内部双栏各自滚动 */
.shell--full {
    height: 100vh;
    height: 100dvh;
    overflow: hidden;
    padding: var(--sp-4);
    gap: var(--sp-4);
}

/* 页头：标题 / 副标题 / 右侧操作 */
.pagehead {
    display: flex;
    align-items: flex-end;
    justify-content: space-between;
    gap: var(--sp-4);
    flex-wrap: wrap;
}

.pagehead .titles { min-width: 0; flex: 1 1 260px; }

.pagehead h1 {
    font-size: var(--fs-2xl);
    font-weight: 700;
    letter-spacing: 0.02em;
    line-height: 1.3;
    background: var(--grad-title);
    -webkit-background-clip: text;
    background-clip: text;
    -webkit-text-fill-color: transparent;
}

.pagehead .sub {
    font-size: var(--fs-sm);
    color: var(--text-muted);
    line-height: 1.7;
    margin-top: var(--sp-1);
    max-width: var(--read-width);
}

.pagehead .sub b { color: var(--accent-brand); font-weight: 600; }
.pagehead .actions { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }

/* 卡片内的顶部工具栏（标题 + 右侧按钮） */
.bar {
    display: flex;
    align-items: center;
    gap: var(--sp-3);
    flex-wrap: wrap;
}

.bar .spacer { flex: 1 1 auto; }

/* 竖排堆叠容器 */
.stack { display: flex; flex-direction: column; gap: var(--sp-3); }

/* 通用栅格
   🔴 每条 minmax 的**下限都要包一层 `min(100%, …)`**：`minmax(260px, 1fr)` 的 260px 是硬下限，
   容器一旦比它窄（320px 手机减去两侧留白只剩 ~288px，再放进卡片内边距就更窄），轨道会直接溢出，
   表现为莫名其妙的横向滚动条。`min(100%, 260px)` 在宽容器上仍是 260px、窄容器上退化成 100%
   ⇒ 显示上零差别，但永远不可能溢出。 */
.grid { display: grid; gap: var(--sp-3); }
.grid--stats { grid-template-columns: repeat(auto-fit, minmax(min(100%, 124px), 1fr)); }
.grid--form { grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr)); }
.grid--halves { grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr)); }

/* ============================================================
   5. 组件
   ============================================================ */

/* ---------- 卡片 ---------- */
/* 已移除 backdrop-filter：背景是静态渐变，模糊只会带来持续的背景重采样开销 */
.card {
    background: var(--card-bg);
    border: 1px solid var(--border);
    border-radius: var(--r-lg);
    box-shadow: var(--card-shadow);
    padding: var(--sp-5);
    position: relative;
    transition: border-color 0.25s ease;
}

.card--stack { display: flex; flex-direction: column; gap: var(--sp-3); }
.card--flush { padding: 0; overflow: hidden; }

.card-title {
    font-size: var(--fs-lg);
    font-weight: 600;
    display: flex;
    align-items: center;
    gap: var(--sp-2);
    flex-wrap: wrap;
    min-height: 26px;
}

.card-title .spacer { flex: 1 1 auto; }
.card-title.section-sub-title { font-size: var(--fs-md); margin-top: var(--sp-4); }

.card-desc,
.section-desc {
    font-size: var(--fs-sm);
    color: var(--text-muted);
    line-height: 1.75;
    max-width: var(--read-width);
}

.card-desc { margin-top: var(--sp-2); }

/* ---------- 吸顶步骤条（流程导航 + 完成态收纳） ----------
   已完成的步骤在正文里收起，只在顶部这条**吸顶**导航上留「✓ + 名称」；
   区块本身收起后**只剩标题一行 + ✓ 徽标 + 箭头**，不再复述填过的内容 ——
   要看就去点它展开，顶部导航已经答了「哪几步完成、共几步」。
   用 position:sticky 而不是 fixed：
     · 不脱离文档流 ⇒ 不需要给正文补 padding，也不会盖住最后一屏内容；
     · 不产生独立的合成层 ⇒ 零持续重绘（本页性能红线：零 @keyframes / 零 filter:blur / 零 backdrop-filter）。
   底必须写实色（--stepnav-bg）：半透明会让正文从条下面透出来，而这里又不能加模糊。 */
.stepnav {
    position: sticky;
    top: var(--sp-2);
    z-index: 30;
    display: flex;
    align-items: center;
    gap: var(--sp-2);
    padding: var(--sp-2);
    border: 1px solid var(--border);
    border-radius: var(--r-md);
    background: var(--stepnav-bg);
    box-shadow: 0 1px 2px rgba(20, 60, 45, 0.06), 0 8px 20px rgba(20, 60, 45, 0.08);
}

.stepnav-list {
    display: flex;
    align-items: center;
    gap: var(--sp-1);
    flex: 1 1 auto;
    min-width: 0;
    margin: 0;
    padding: 0;
    list-style: none;
    overflow-x: auto;
    scrollbar-width: none;
    -webkit-overflow-scrolling: touch;
}

.stepnav-list::-webkit-scrollbar { display: none; }

.stepnav-step {
    flex: 0 0 auto;
    display: inline-flex;
    align-items: center;
    gap: 8px;
    min-height: 36px;
    padding: 0 12px;
    border: 1px solid transparent;
    border-radius: var(--r-pill);
    background: none;
    color: var(--text-muted);
    font-family: inherit;
    font-size: var(--fs-sm);
    white-space: nowrap;
    cursor: pointer;
    transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease;
    -webkit-tap-highlight-color: transparent;
}

.stepnav-step:hover { color: var(--text-primary); background: var(--surface-hover); }
.stepnav-step:focus-visible { outline: 2px solid var(--action); outline-offset: 2px; }

.stepnav-dot {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 20px;
    height: 20px;
    flex: 0 0 20px;
    border: 1px solid var(--border);
    border-radius: var(--r-pill);
    background: var(--surface-inset);
    font-size: var(--fs-xs);
    font-weight: 700;
    line-height: 1;
}

.stepnav-step.is-done { color: var(--text-primary); }

.stepnav-step.is-done .stepnav-dot {
    color: var(--action);
    border-color: rgba(var(--action-rgb), 0.45);
    background: var(--action-soft);
}

.stepnav-step.is-active {
    color: var(--text-primary);
    border-color: var(--border-strong);
    background: var(--surface-soft);
}

.stepnav-step.is-active .stepnav-dot {
    color: var(--btn-on-accent);
    border-color: transparent;
    background: var(--grad-action);
}

/* 收起后不再展示任何内容 —— 顶部导航已经答出「哪几步完成」 */
.stepnav-meta {
    flex: 0 0 auto;
    padding-right: var(--sp-1);
    font-size: var(--fs-xs);
    color: var(--text-muted);
    white-space: nowrap;
}

/* ---------- 步骤区块：完成态自动收纳 ----------
   收起 = 只留**标题一行 + ✓ 徽标 + 箭头**，其余子元素整块隐藏；
   点击标题栏任意处（或箭头）即可展开回填。收起时机由各页交给 Theme.Steps（theme.js）决定。 */
[data-step] { scroll-margin-top: 84px; }

[data-step].is-collapsed {
    padding: var(--sp-2) var(--sp-4);
    /* 左侧一道绿边表示「这步完成了、被收起来了」。
       🔴 不要改背景色：`[data-step].is-collapsed` 的优先级高于 `.card`，一旦在这里写
          background 就会**整块顶掉卡片自己的设计底色**，而 --action-soft 是半透明绿、
          直接压在粉色页底上会混成脏灰（实拍 #e4e4e5，配色实拍就是这么抓出来的）。
          收起态要和其它卡片同族，只靠绿边 + ✓ 徽标区分即可。 */
    box-shadow: inset 3px 0 0 var(--action);
}

[data-step].is-collapsed > *:not(.bar) { display: none; }

/* 收起态标题栏只留「✓ + 标题 + 箭头」：
   标题栏里除标题与箭头钮之外的东西（.hint 限额提示、#taskStatus 状态、.spacer）一并隐去，
   否则「剩余可创建 3 个任务」这类文字会残留在收起行上 —— 那就不是「更简洁」了。
   用 :has(.card-title) 兜住：只有确认标题栏里真有 .card-title 才动手，
   避免某些页把标题直接放在 .card 下（如 analysis 的 .card--stack）时被整条清空。 */
[data-step].is-collapsed > .bar:has(.card-title) > *:not(.card-title):not(.step-toggle) { display: none; }

/* 收起时标题栏里的动作钮（保存 / 刷新…）一并让位，只留右侧箭头钮 */
[data-step].is-collapsed > .bar .btn { display: none; }

/* 收起态标题前的 ✓：比纯文字「已完成」更省宽度，窄屏也不会把标题挤断 */
[data-step].is-collapsed > .bar .card-title::before {
    content: "✓";
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 18px;
    height: 18px;
    flex: 0 0 18px;
    margin-right: 8px;
    border-radius: var(--r-pill);
    background: var(--grad-action);
    color: var(--btn-on-accent);
    font-size: var(--fs-xs);
    font-weight: 700;
    line-height: 1;
}

.step-toggle {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 30px;
    height: 30px;
    flex: 0 0 auto;
    padding: 0;
    border: 1px solid transparent;
    border-radius: var(--r-sm);
    background: none;
    color: var(--text-muted);
    cursor: pointer;
    transition: color 0.2s ease, background-color 0.2s ease, transform 0.25s ease;
    -webkit-tap-highlight-color: transparent;
}

.step-toggle:hover { color: var(--text-primary); background: var(--surface-hover); }
.step-toggle svg { width: 14px; height: 14px; }

/* 箭头默认朝下（提示「收起来了、可以展开」）；展开时翻上来 */
.step-toggle[aria-expanded="true"] { transform: rotate(180deg); }

/* ---------- 按钮 ---------- */
.btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: 8px;
    min-height: 36px;
    padding: 0 16px;
    border-radius: var(--r-sm);
    font-weight: 500;
    font-size: var(--fs-md);
    font-family: inherit;
    line-height: 1.2;
    cursor: pointer;
    border: 1px solid transparent;
    white-space: nowrap;
    text-decoration: none;
    -webkit-tap-highlight-color: transparent;
    transition: transform 0.18s ease, box-shadow 0.18s ease,
        background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease;
}

.btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none !important; box-shadow: none !important; }

/* 功能性按钮按层级分档（四档绿 + 一档红）：
     .btn-primary   主操作 · 竹绿实心 + 白字
     .btn-success   肯定   · 薄荷亮面 + 深绿字
     .btn-secondary 次操作 · 青绿描边 + 青绿字
     .btn-ghost     辅助   · 中性描边，hover 转苔绿
     .btn-danger    破坏性 · 苏芳红描边（把"删除/终止"涂绿是可用性缺陷，语义不可丢） */
.btn-primary { background: var(--grad-action); color: var(--btn-on-accent); }
.btn-success { background: var(--grad-confirm); color: var(--btn-on-confirm); }

.btn-primary:hover:not(:disabled) { transform: translateY(-1px); box-shadow: 0 6px 18px var(--glow-action); }
.btn-success:hover:not(:disabled) { transform: translateY(-1px); box-shadow: 0 6px 18px var(--glow-confirm); }

.btn-secondary {
    background: transparent;
    border-color: rgba(var(--action-alt-rgb), 0.55);
    color: var(--action-alt);
}
.btn-secondary:hover:not(:disabled) { border-color: var(--action-alt); background: var(--action-alt-soft); }

.btn-danger { background: transparent; border-color: var(--accent-rose); color: var(--accent-rose); }
.btn-danger:hover:not(:disabled) { background: var(--surface-hover); }

.btn-ghost { background: transparent; border-color: var(--border-strong); color: var(--text-muted); }
.btn-ghost:hover:not(:disabled) { border-color: var(--action-mild); color: var(--action-mild); }

.btn-tiny { min-height: 28px; padding: 0 12px; font-size: var(--fs-xs); border-radius: var(--r-sm); }
.btn-block { width: 100%; }

/* 行内破坏性操作：红色**文字**，不再是红色描边大按钮（第十二轮组件规范）。
   仍是货真价实的按钮（移动端 44px 触控高度、点击有二次确认），
   只是不再用「描边 + 红字」这种与主操作抢重量的形态。
   `margin-left: auto` 把它推到操作行的最右侧 —— 与推荐列表「右侧动作」的排法一致。 */
.btn-danger-text { margin-left: auto; padding: 0 8px; border-color: transparent; background: none; color: var(--accent-rose); }
.btn-danger-text:hover:not(:disabled) { background: var(--surface-hover); }
/* 🔴 表格卡片行里的「删除」必须**贴自己的内容**，不能跟着 `.btn` 一起 grow：
   `.table-wrap .row-actions .btn { flex: 1 1 auto }`（3 个类）比 `.btn-danger-text`（1 个类）优先级高，
   不显式压回去的话，后台那行 6 个按钮换行后**单独占一行的「删除」会被拉满整行 330px** ——
   视觉上又变回一个红色大按钮，正好是用户点名要删掉的那个东西。
   实测（`_r12_row.cjs`）：修复前 删除 330×44。
   🔴 选择器必须写成 `.btn.btn-danger-text`：`.table-wrap .row-actions .btn` 与
      `.table-wrap .row-actions .btn-danger-text` 的**特指度完全相同**（都是 3 个类），
      而前者的 @media 段在文件里更靠后 ⇒ 同权后者输。多挂一个 `.btn` 拿回特指度，
      不靠「谁写在后面」定胜负。 */
.table-wrap .row-actions .btn.btn-danger-text { flex: 0 0 auto; margin-left: auto; }

/* ---------- 表单 ---------- */
input[type="text"],
input[type="password"],
input[type="number"],
textarea,
select {
    padding: 0 12px;
    height: 38px;
    border: 1px solid var(--border);
    border-radius: var(--r-sm);
    background: var(--surface-soft);
    color: var(--text-primary);
    font-size: var(--fs-md);
    font-family: inherit;
    outline: none;
    transition: border-color 0.18s ease, background-color 0.18s ease, box-shadow 0.18s ease;
}

textarea { height: auto; padding: 12px 12px; line-height: 1.7; font-family: var(--font-mono); font-size: var(--fs-sm); resize: vertical; }

input:focus, textarea:focus, select:focus {
    border-color: var(--action);
    background: var(--surface-inset);
    box-shadow: 0 0 0 3px var(--glow-action);
}

input::placeholder, textarea::placeholder { color: var(--text-muted); opacity: 0.7; }

label {
    display: block;
    font-size: var(--fs-sm);
    font-weight: 500;
    color: var(--text-muted);
    margin-bottom: 8px;
}

/* 字段组：统一「标签 + 控件」的竖向间距 */
.field { display: flex; flex-direction: column; min-width: 0; }
.field > label { margin-bottom: 8px; }
.field > input, .field > select, .field > textarea { width: 100%; }
.field + .field { margin-top: var(--sp-3); }

/* ---------- 密码框明文切换 ----------
   theme.js 依 data-pwd-toggle 把输入框包进 .pwd-wrap 并注入眼睛按钮；
   输入框右侧留出按钮位，避免明文长于按钮时文字被按钮盖住。 */
.pwd-wrap { position: relative; display: block; min-width: 0; }
.pwd-wrap > input { width: 100%; padding-right: 48px; }

.pwd-toggle {
    position: absolute;
    top: 50%;
    right: 4px;
    transform: translateY(-50%);
    width: 34px;
    height: 34px;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    padding: 0;
    border: 1px solid transparent;
    border-radius: var(--r-sm);
    background: none;
    color: var(--text-muted);
    font-family: inherit;
    cursor: pointer;
    -webkit-tap-highlight-color: transparent;
    transition: color 0.18s ease, background-color 0.18s ease, border-color 0.18s ease;
}

.pwd-toggle:hover { color: var(--action-alt); background: var(--surface-hover); }
.pwd-toggle:focus-visible { outline: none; border-color: var(--action); box-shadow: 0 0 0 3px var(--glow-action); }
.pwd-toggle[aria-pressed="true"] {
    color: var(--action);
    border-color: rgba(var(--action-rgb), 0.45);
    background: var(--action-soft);
}
.pwd-toggle svg { width: 18px; height: 18px; pointer-events: none; }
.pwd-toggle .ico-off { display: none; }
.pwd-toggle[aria-pressed="true"] .ico-on { display: none; }
.pwd-toggle[aria-pressed="true"] .ico-off { display: block; }

/* 横向表单行：自动换行 + 统一间距 */
.form-row {
    display: flex;
    gap: var(--sp-3);
    flex-wrap: wrap;
    align-items: flex-end;
}
.form-row + .form-row { margin-top: var(--sp-3); }
.form-row > .grow { flex: 1 1 220px; min-width: 0; }
.form-row > .grow > input { width: 100%; }

/* 评分范围行（min - max 单位） */
.range-row { display: flex; align-items: center; gap: 8px; }
.range-row input[type="number"] { width: 4.4rem; text-align: center; }
.range-row input.unit-input { width: 5rem; text-align: center; }
.range-row .sep { color: var(--text-muted); }

.optional-tip { color: var(--text-muted); font-weight: 400; font-size: var(--fs-xs); }
.req { color: var(--accent-rose); }

/* ---------- 标签 ---------- */
.tag {
    display: inline-block;
    padding: 2px 8px;
    border-radius: var(--r-pill);
    font-size: var(--fs-xs);
    font-weight: 500;
    border: 1px solid transparent;
    white-space: nowrap;
}

.tag-active { background: rgba(var(--accent-green-rgb), 0.13); color: var(--accent-green); border-color: rgba(var(--accent-green-rgb), 0.32); }
.tag-closed { background: rgba(var(--accent-rose-rgb), 0.13); color: var(--ink-rose); border-color: rgba(var(--accent-rose-rgb), 0.32); }
.tag-muted { background: var(--surface-soft); color: var(--text-muted); border-color: var(--border); }

[data-theme="dark"] .tag-active { background: rgba(var(--accent-green-rgb), 0.15); border-color: rgba(var(--accent-green-rgb), 0.32); }
[data-theme="dark"] .tag-closed { background: rgba(var(--accent-rose-rgb), 0.15); border-color: rgba(var(--accent-rose-rgb), 0.32); }

/* ---------- 统计格 ---------- */
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 124px), 1fr)); gap: var(--sp-3); }

.stat-item {
    background: var(--surface-inset);
    padding: var(--sp-3) var(--sp-2);
    border-radius: var(--r-md);
    text-align: center;
    min-width: 0;
    box-shadow: inset 0 1px 0 rgba(var(--accent-petal-rgb), 0.45);
}

.stat-value { font-size: var(--fs-2xl); font-weight: 700; color: var(--accent-gold); line-height: 1.25; }
.stat-label { font-size: var(--fs-xs); color: var(--text-muted); margin-top: 2px; letter-spacing: 0.04em; }

/* ---------- 进度 ---------- */
.progress-bar { height: 5px; background: var(--surface-inset); border-radius: var(--r-pill); overflow: hidden; }
.progress-fill { height: 100%; background: var(--grad-track); border-radius: var(--r-pill); transition: width 0.3s ease; }
.progress-mini { height: 5px; background: var(--surface-inset); border-radius: var(--r-pill); overflow: hidden; min-width: 66px; }
.progress-mini > i { display: block; height: 100%; background: var(--grad-track); }
.progress-wrap { display: flex; align-items: center; gap: var(--sp-2); }
.progress-wrap span { font-size: var(--fs-xs); color: var(--text-muted); min-width: 36px; }

/* ---------- 表格 ---------- */
/* .table-wrapper 为历史类名，保留别名，避免改动已有 JS 生成的标记 */
.table-wrapper,
.table-wrap {
    width: 100%;
    overflow-x: auto;
    -webkit-overflow-scrolling: touch;
    border: 1px solid var(--border);
    border-radius: var(--r-md);
    background: var(--surface-soft);
}

/* 需要纵向滚动 + 吸顶表头的场景 */
.table-wrap--scroll { max-height: var(--table-max, 60vh); overflow-y: auto; }

table { width: 100%; border-collapse: collapse; font-size: var(--fs-md); }

th, td {
    padding: 12px 12px;
    text-align: left;
    border-bottom: 1px solid var(--border);
    vertical-align: middle;
}

th {
    background: var(--surface-inset);
    font-weight: 600;
    font-size: var(--fs-sm);
    color: var(--text-muted);
    white-space: nowrap;
}

.table-wrap--scroll thead th { position: sticky; top: 0; z-index: 2; }

tbody tr:last-child td { border-bottom: none; }
tbody tr:hover { background: var(--surface-hover); }

/* ---------- 空态与提示 ---------- */
.hint { font-size: var(--fs-sm); color: var(--text-muted); line-height: 1.75; max-width: var(--read-width); }
.empty-hint,
.empty { color: var(--text-muted); font-size: var(--fs-md); padding: var(--sp-5) var(--sp-3); text-align: center; }
.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100%; color: var(--text-muted); padding: var(--sp-5); text-align: center; }
.empty-state > svg { width: 56px; height: 56px; margin-bottom: var(--sp-3); opacity: 0.45; }

/* ---------- 弹窗 ---------- */
/* 同卡片：去掉 backdrop-filter，用更实的遮罩代替 */
.modal {
    display: none;
    position: fixed;
    inset: 0;
    background: var(--overlay);
    z-index: 200;
    justify-content: center;
    align-items: center;
    padding: max(var(--sp-4), var(--safe-t)) max(var(--sp-4), var(--safe-r))
        max(var(--sp-4), var(--safe-b)) max(var(--sp-4), var(--safe-l));
    overflow: auto;
}
.modal.show { display: flex; }

.modal-content {
    background: var(--bg-mid);
    border: 1px solid var(--border);
    border-radius: var(--r-lg);
    box-shadow: var(--card-shadow);
    width: 100%;
    max-width: 480px;
    max-height: 88vh;
    display: flex;
    flex-direction: column;
    overflow: hidden;
}

.modal-header {
    padding: var(--sp-4) var(--sp-5);
    border-bottom: 1px solid var(--border);
    font-weight: 600;
    font-size: var(--fs-lg);
    display: flex;
    align-items: center;
    gap: var(--sp-2);
    flex: none;
}

.modal-body { padding: var(--sp-5); overflow-y: auto; }
.modal-footer {
    padding: var(--sp-3) var(--sp-5);
    border-top: 1px solid var(--border);
    display: flex;
    gap: var(--sp-2);
    justify-content: flex-end;
    flex-wrap: wrap;
    flex: none;
    background: var(--surface-soft);
}

/* ---------- 轻提示 ---------- */
.toast {
    position: fixed;
    /* 轻提示落在页面顶部 ⇒ 有状态栏/刘海时必须让开，否则真机上会被压掉半行 */
    top: max(var(--sp-4), var(--safe-t));
    left: 50%;
    transform: translateX(-50%) translateY(-16px);
    background: var(--bg-mid);
    border: 1px solid var(--border);
    box-shadow: var(--card-shadow);
    color: var(--text-primary);
    padding: 12px 20px;
    border-radius: var(--r-md);
    font-size: var(--fs-md);
    opacity: 0;
    pointer-events: none;
    transition: opacity 0.25s ease, transform 0.25s ease;
    z-index: 300;
    max-width: min(90vw, 520px);
    text-align: center;
}
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
.toast.success { border-color: var(--action); color: var(--action); }
.toast.error { border-color: var(--accent-rose); color: var(--accent-rose); }

/* ---------- 主题切换按钮 ---------- */
.theme-toggle {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 36px;
    height: 36px;
    flex: none;
    border-radius: 50%;
    border: 1px solid var(--border);
    background: var(--surface-soft);
    color: var(--text-muted);
    cursor: pointer;
    -webkit-tap-highlight-color: transparent;
    transition: color 0.2s ease, border-color 0.2s ease, background-color 0.2s ease;
}
.theme-toggle:hover { color: var(--action-alt); border-color: var(--action-alt); }
.theme-toggle:active { transform: scale(0.95); }
.theme-toggle svg { width: 18px; height: 18px; pointer-events: none; }

.theme-toggle--floating {
    position: fixed;
    /* 钉在右上角 ⇒ 上有状态栏/刘海、横屏时右侧还有刘海，两处都要让开安全区 */
    top: max(var(--sp-4), var(--safe-t));
    right: max(var(--sp-4), var(--safe-r));
    z-index: 120;
    background: var(--bg-mid);
    box-shadow: var(--card-shadow);
}

/* ============================================================
   6. 跨页复用的小组件
   ============================================================ */

/* ---------- 评委码 ---------- */
.code-list { display: flex; flex-wrap: wrap; gap: var(--sp-2); }

.code-item {
    display: flex;
    align-items: center;
    gap: var(--sp-2);
    padding: 8px 12px;
    border-radius: var(--r-sm);
    background: var(--surface-inset);
    border: 1px solid var(--border);
    font-size: var(--fs-sm);
    /* 🔴 **必须有 min-width: 0**：flex 子项的默认 `min-width: auto` 是"不小于内容宽"，
       而评委码是等宽 + 加字距的全大写串（FWZW-XC3T），没有断行点 ⇒ 在 320px + 系统字号
       放大到 130% 时，`.code-item` 里的内容把容器撑宽到 278px（容器自身 251px），
       超出的 27px 顺着 `overflow-x: visible` 一路顶到 html ⇒
       `documentElement.scrollWidth` 比 clientWidth 多 **6px**
       （雪上加霜的是 `position: fixed; inset: 0` 的 `.bg-canvas` 参考的初始包含块
       也被这个更宽的布局视口撑到 326px，于是它也一起变成"超宽元素"）。
       诊断记录：这条 6px 在门禁里连续 5 轮稳定复现（三次重测 [6,6,6]），
       但四种独立探针都量到 0px —— 因为探针里**没有账号数据**（码列表是空态，
       只显示一行提示）。最后一版兜底 dump 才把它点出来。 */
    min-width: 0;
}
.code-item.revoked { opacity: 0.6; border-color: var(--accent-rose); }
/* 评委码：允许在任意字符处换行，否则长码永远撑宽容器 */
.code-value { font-family: var(--font-mono); font-size: var(--fs-md); font-weight: 600; letter-spacing: 0.06em; overflow-wrap: anywhere; word-break: break-word; }
.code-meta { color: var(--text-muted); font-size: var(--fs-xs); }

/* ---------- 评委进度 ---------- */
.judge-progress { display: flex; flex-direction: column; gap: var(--sp-2); }
.judge-progress-row { display: flex; align-items: center; gap: var(--sp-3); font-size: var(--fs-sm); }
.judge-progress-row .name { min-width: 104px; max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

/* ---------- 命题标签 ---------- */
.topic-tag {
    display: inline-block;
    margin-left: 8px;
    padding: 2px 8px;
    background: rgba(var(--accent-blue-rgb), 0.13);
    color: var(--accent-blue);
    border: 1px solid rgba(var(--accent-blue-rgb), 0.28);
    border-radius: var(--r-sm);
    font-size: var(--fs-xs);
    font-weight: 500;
}
[data-theme="dark"] .topic-tag { background: rgba(var(--accent-blue-rgb), 0.18); border-color: rgba(var(--accent-blue-rgb), 0.28); }

/* ---------- 排名徽章 ---------- */
.rank-badge {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 24px;
    height: 24px;
    border-radius: 50%;
    font-size: var(--fs-xs);
    font-weight: 600;
}
.rank-1 { background: linear-gradient(135deg, #f7c56a, #e9a13c); color: #4a2c00; }
.rank-2 { background: linear-gradient(135deg, #ecd7e6, #d3b9cf); color: #3c2836; }
.rank-3 { background: linear-gradient(135deg, #eebf9a, #d79772); color: #43230d; }
.rank-other { background: var(--surface-inset); color: var(--text-muted); }

/* ---------- 评分详情 ---------- */
.score-detail { display: flex; flex-wrap: wrap; gap: 4px; }
.score-tag { padding: 4px 8px; background: var(--surface-inset); border-radius: var(--r-sm); font-size: var(--fs-xs); }
.score-tag.high { background: rgba(var(--accent-brand-rgb), 0.15); color: var(--ink-brand); }
.score-tag.low { background: rgba(var(--accent-rose-rgb), 0.15); color: var(--ink-rose); }
.score-tag.excluded { background: var(--surface-inset); color: var(--text-muted); text-decoration: line-through; }
.score-tag.std-dev { background: rgba(var(--accent-violet-rgb), 0.13); color: var(--accent-violet); border: 1px solid rgba(var(--accent-violet-rgb), 0.32); font-weight: 600; }

.std-dev-cell { color: var(--accent-violet); font-size: var(--fs-sm); font-weight: 600; }
.std-dev-empty { color: var(--text-muted); opacity: 0.4; }

.score-bar { height: 4px; background: var(--surface-inset); border-radius: var(--r-pill); overflow: hidden; min-width: 48px; }
.score-fill { height: 100%; background: var(--grad-track); border-radius: var(--r-pill); }

/* ---------- 展开行 ---------- */
.detail-row { cursor: pointer; }
.detail-panel { display: none; background: var(--surface-inset); }
.detail-panel.show { display: table-row; }

.collapsible-content { transition: max-height 0.35s ease, opacity 0.25s ease; overflow: hidden; max-height: 1000px; opacity: 1; }
.collapsible-content.collapsed { max-height: 0; opacity: 0; }

.toggle-icon { display: inline-block; transition: transform 0.25s ease; margin-left: auto; font-size: var(--fs-sm); color: var(--text-muted); }
.toggle-icon.collapsed { transform: rotate(-90deg); }
.card-header-row { display: flex; align-items: center; gap: var(--sp-2); cursor: pointer; user-select: none; }
.card-header-row:hover .toggle-icon { color: var(--action-mild); }
.card-minimized-badge { display: none; font-size: var(--fs-sm); color: var(--accent-brand); margin-left: 8px; }
.card-minimized-badge.show { display: inline; }

/* ---------- 命题分组 ---------- */
.topic-group { margin-bottom: var(--sp-3); border: 1px solid var(--border); border-radius: var(--r-md); overflow: hidden; }
.topic-group-header { padding: 8px 12px; background: rgba(var(--accent-blue-rgb), 0.07); font-weight: 600; font-size: var(--fs-md); color: var(--text-primary); }
.topic-group-body { padding: 0; }
.topic-article-item { display: flex; align-items: center; gap: var(--sp-2); padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: var(--fs-md); }
.topic-article-item:last-child { border-bottom: none; }
.topic-rank { min-width: 24px; text-align: center; font-weight: 600; color: var(--text-muted); }
.topic-article-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.topic-article-author { color: var(--text-muted); font-size: var(--fs-sm); min-width: 0; }
.topic-article-score { color: var(--accent-gold); min-width: 48px; text-align: right; }

/* ---------- 查重分组 ---------- */
.dup-group-item { margin-bottom: var(--sp-3); border: 1px solid rgba(var(--accent-rose-rgb), 0.22); border-radius: var(--r-md); overflow: hidden; }
.dup-group-item .dup-group-title { padding: 8px 12px; background: rgba(var(--accent-rose-rgb), 0.07); font-weight: 600; font-size: var(--fs-sm); color: var(--accent-rose); }
.dup-group-articles { padding: 0; }
.dup-article-row { display: flex; align-items: center; gap: var(--sp-2); padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: var(--fs-sm); }
.dup-article-row:last-child { border-bottom: none; }
.dup-article-row.is-original { background: rgba(var(--accent-brand-rgb), 0.07); }
.dup-article-status { min-width: 48px; font-size: var(--fs-xs); font-weight: 500; }
.dup-article-row.is-original .dup-article-status { color: var(--accent-brand); }
.dup-article-row:not(.is-original) .dup-article-status { color: var(--text-muted); }
.dup-article-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dup-article-author { color: var(--text-muted); font-size: var(--fs-xs); min-width: 0; text-align: right; }

.dup-rank-badge { display: inline-block; margin-left: 8px; padding: 1px 8px; border-radius: var(--r-sm); font-size: var(--fs-xs); font-weight: 500; }
.dup-rank-badge.original { background: rgba(var(--accent-brand-rgb), 0.15); color: var(--accent-brand); border: 1px solid rgba(var(--accent-brand-rgb), 0.28); }
.dup-rank-badge.duplicate { background: rgba(var(--accent-rose-rgb), 0.15); color: var(--accent-rose); border: 1px solid rgba(var(--accent-rose-rgb), 0.28); }

/* ---------- 任务选择条 ---------- */
.task-bar { display: flex; align-items: center; gap: var(--sp-3); flex-wrap: wrap; }
.task-select {
    height: 38px;
    padding: 0 12px;
    background: var(--surface-soft);
    border: 1px solid var(--border);
    border-radius: var(--r-sm);
    color: var(--text-primary);
    font-size: var(--fs-md);
    font-family: inherit;
    /* 宽容器上仍是 220px；容器窄于 220px 时退化成 100%，永不溢出 */
    min-width: min(220px, 100%);
    cursor: pointer;
    outline: none;
}
.task-select:focus { border-color: var(--action); }
.task-status { font-size: var(--fs-sm); color: var(--text-muted); }
.task-status.is-active { color: var(--accent-brand); }
.task-status.is-closed { color: var(--accent-rose); }

/* ---------- 操作按钮组 ---------- */
.action-buttons { display: flex; gap: var(--sp-2); flex-wrap: wrap; align-items: center; }

/* ---------- 少量排版工具类（替代零散内联样式） ---------- */
.u-brand { color: var(--accent-brand); }
.u-gold { color: var(--accent-gold); }
.u-rose { color: var(--accent-rose); }
.u-right { margin-left: auto; }
.u-nowrap { white-space: nowrap; }

.mt-2 { margin-top: var(--sp-2); }
.mt-3 { margin-top: var(--sp-3); }
.mt-4 { margin-top: var(--sp-4); }
.mb-2 { margin-bottom: var(--sp-2); }
.mb-3 { margin-bottom: var(--sp-3); }

.w-full { width: 100%; }
.w-xs { width: 3.6rem; }
.w-sm { width: 5.5rem; }
.w-md { width: 7rem; }
.w-lg { width: 13rem; }

/* ============================================================
   6.5 移动端组件层（第十二轮 · 从「堆叠」到「流式」）
   ------------------------------------------------------------
   用户的诊断：顶部导航/步骤条/操作按钮**堆叠过多**，第一屏几乎被操作区占满，
   核心数据（排名）被推得太靠下，而且卡片套卡片造成视觉疲劳。组件层面还有
   按钮大小不一、文字换行、悬浮按钮遮挡内容、表单排版混乱。
   这一层就是载体：AppBar 收纳操作 → 分段控制器收纳导航 → 看板收纳统计 →
   列表项用分割线取代卡片包裹 → 底部弹窗收纳导出类动作。

   🔴 本段所有 gap / padding 都必须落在 4 的倍数上 —— `_shot_mobile.cjs` 逐个元素断言
      （0/1/2px 属描边级例外）。紧凑要用 4 的倍数去紧凑，这一条已经被判红教训过一次。
   ============================================================ */

/* ---------- AppBar：左返回 / 中标题 / 右图标组 ----------
   取代原先「标题 + 副标题 + 一排大按钮」的 .pagehead。
   操作不消失，只是**收进图标与更多菜单**：大按钮占的是第一屏最贵的位置。 */
.appbar {
    position: relative;
    display: flex;
    align-items: center;
    gap: var(--sp-2);
    min-height: 56px;
    padding: 4px 0;
}

.appbar-back,
.icon-btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 44px;
    height: 44px;
    flex: none;
    padding: 0;
    border: 1px solid transparent;
    border-radius: var(--r-md);
    background: none;
    color: var(--text-primary);
    font-family: inherit;
    font-size: var(--fs-lg);
    line-height: 1;
    cursor: pointer;
    -webkit-tap-highlight-color: transparent;
    transition: background-color 0.18s ease, color 0.18s ease, border-color 0.18s ease;
}
.appbar-back:hover,
.icon-btn:hover { background: var(--surface-hover); }
.appbar-back:active,
.icon-btn:active { transform: scale(0.96); }
.appbar-back svg,
.icon-btn svg { width: 20px; height: 20px; pointer-events: none; }
/* 菜单展开时按钮保持「按下」观感，否则菜单看起来是凭空的 */
.icon-btn[aria-expanded="true"] { background: var(--surface-hover); border-color: var(--border-strong); }

.appbar-title { flex: 1 1 auto; min-width: 0; }
.appbar-title h1 {
    margin: 0;
    font-size: var(--fs-xl);      /* 18 —— 页面大标题 */
    font-weight: 600;
    line-height: 1.25;
    color: var(--text-primary);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}
/* AppBar 副标题：手机上不显示（AppBar 只留标题，这是「第一屏别被占满」的关键一条）；
   桌面空间充裕才放出来（见下面 min-width: 769px 段）。
   🔴 第十六轮起**已无页面使用**：后台页那行「当前管理员：xxx · 管理评审任务…」按用户要求
      整行砍掉、管理员名并入 h1，配套的 `.appbar-sub--keep` 变体也一并删除。
      规则保留只是给日后真正的副标题留位；**不要在手机上再把 display 打开** ——
      那正是这一轮去掉的东西（真机 360px 下它被截成「当前管理员：admin · 管理…」）。 */
.appbar-sub { display: none; font-size: var(--fs-sm); color: var(--text-muted); line-height: 1.5; }
.appbar-actions { display: flex; align-items: center; gap: 4px; flex: none; }

/* 右上角「更多」菜单（返回后台 / 退出登录收纳于此） */
.menu-pop {
    position: absolute;
    top: calc(100% + 4px);
    right: 0;
    z-index: 150;
    min-width: 12rem;
    padding: 4px;
    display: flex;
    flex-direction: column;
    gap: 4px;
    background: var(--bg-mid);
    border: 1px solid var(--border);
    border-radius: var(--r-md);
    box-shadow: var(--card-shadow);
}
.menu-pop[hidden] { display: none; }
.menu-item {
    display: flex;
    align-items: center;
    gap: var(--sp-2);
    min-height: 44px;
    padding: 0 var(--sp-3);
    border: 0;
    border-radius: var(--r-sm);
    background: none;
    color: var(--text-primary);
    font-family: inherit;
    font-size: var(--fs-md);
    font-weight: 500;
    text-align: left;
    text-decoration: none;
    white-space: nowrap;
    cursor: pointer;
    -webkit-tap-highlight-color: transparent;
}
.menu-item:hover { background: var(--surface-hover); }
.menu-item--danger { color: var(--accent-rose); }

/* ---------- 身份信息条（管理者页）----------
   原先是 .pagehead 的副标题，和大标题、两个大按钮一起占着第一屏。
   第十三轮：改成**语义固定的两行**（不再是「一行 + 自动换行」）。
   改前是 `display:flex; flex-wrap:wrap` 的四段文字，窄屏按剩余宽度随机折行 ——
   390px 折成 2 行、320px 折成 3 行，而且「管理码」的落点每次都不一样；
   行高不稳定是次要的，**关键凭据的位置不可预测**才是真的问题。
   现在按语义分组、行内用 space-between 把首尾钉死：
     第一行：社团 / 组织（左）· 任务配额（右）
     第二行：管理码 + 复制（右对齐）
   🔴 不塞回 AppBar 的 .appbar-sub--keep：那条是 nowrap + 省略号，
      320px 上会把末尾的「管理码」吃掉，而管理码是登录凭据，不能靠运气显示。 */
.id-strip {
    display: flex;
    flex-direction: column;
    gap: 4px;
    margin: 0 0 var(--sp-3);
    font-size: var(--fs-xs);      /* 12 —— 辅助信息档，桌面/移动同档 */
    color: var(--text-muted);
    line-height: 1.6;
}
.id-strip b { color: var(--accent-brand); font-weight: 600; }

/* 每一行：首尾两端贴住（第一行 = 组织名 | 配额） */
.id-row {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--sp-2);
    min-width: 0;
}
/* 第二行只有一个元素（管理码 + 复制），整体靠右：这是「看完就复制」的动作，贴右边更好够 */
.id-row--end { justify-content: flex-end; }
/* 值可以长（社团名），但**不能把行撑破**：可收缩 + 省略号 */
.id-cell {
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}
.id-cell b { font-variant-numeric: tabular-nums; }

/* 管理码 = **一个胶囊标签**（第十四轮·用户规范七）：「[ 管理码 RHP9-94TD ⧉ ]」。
   改前是一个无边框、无底的透明按钮 —— 它跟旁边那行灰字长得一模一样，
   既看不出「这里可以点」，也没把它和「任务配额」那种纯文本信息区分开。
   现在给它边框 + 底色 + 圆角，码与复制图标**同打包**在一个盒子里。
   🔴 仍是**一整块**可点区域（不是「文字旁边挂一个 16px 小图标」——那只有十几像素的
      触控热区，手指点不准）；`margin: -8px 0` 把 44px 的触控盒在**视觉上**收回 28px：
      触控区达标，又不必给首屏白送 16px 高度。 */
.id-copy {
    flex: 0 0 auto;
    display: inline-flex;
    align-items: center;
    gap: 8px;
    max-width: 100%;
    min-height: 44px;
    margin: -8px 0;
    /* 🔴 左右内边距必须是 4 的倍数：`_shot_mobile.cjs` 的「间距网格」口径对
       **每一个元素**的 padding/gap 做「能被 4 整除」断言。
       第十四轮第一版写成 `0 10px`，`se320 / android360 / iphone390 / tablet768`
       四个视口一起判红（`button#copyCodeBtn.id-copy.paddingRight=10px`）。
       10 → 12 的视觉差别看不出来，但它是「8 点网格」这四个字的地基。 */
    padding: 0 12px;
    border: 1px solid var(--border);
    border-radius: var(--r-pill);
    background: var(--surface-soft);
    color: var(--text-muted);
    font-family: inherit;
    font-size: var(--fs-xs);
    font-weight: 400;
    white-space: nowrap;
    cursor: pointer;
    -webkit-tap-highlight-color: transparent;
    transition: background-color 0.18s ease, color 0.18s ease, border-color 0.18s ease;
}
.id-copy:hover { background: var(--surface-hover); color: var(--accent-brand); border-color: var(--border-strong); }
.id-copy:active { opacity: 0.85; }
.id-copy svg { width: 16px; height: 16px; flex: 0 0 16px; pointer-events: none; }
/* 码本身可能很长（RHP9-94TD），让它单独可省略，别把图标挤出去 */
.id-copy .id-cell { flex: 0 1 auto; }

/* ---------- 分段控制器（Segmented Control）----------
   取代横向步骤条。用户要求「固定吸顶，点击切换」。
   🔴 吸顶必须有**不透明底**：半透明底 + 吸顶 = 内容从下面透出来（本项目在卡片上踩过同款）。
      `--bg-mid` 是实色，用它；再用一层描边把边界交代清楚。 */
.segmented {
    position: sticky;
    top: 0;
    z-index: 110;
    display: flex;
    gap: 4px;
    padding: 4px;
    margin-bottom: var(--sp-3);
    background: var(--bg-mid);
    border: 1px solid var(--border);
    border-radius: var(--r-md);
    box-shadow: var(--card-shadow);
}
.seg-item {
    flex: 1 1 0;
    min-width: 0;
    min-height: 44px;
    padding: 0 8px;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    border: 0;
    border-radius: var(--r-sm);
    background: none;
    color: var(--text-muted);
    font-family: inherit;
    font-size: var(--fs-md);
    font-weight: 500;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
    cursor: pointer;
    -webkit-tap-highlight-color: transparent;
    transition: background-color 0.18s ease, color 0.18s ease, box-shadow 0.18s ease;
}
.seg-item:hover { color: var(--text-primary); }
.seg-item.is-active {
    background: var(--card-bg);
    color: var(--text-primary);
    font-weight: 600;
    box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9), 0 1px 2px rgba(56, 130, 98, 0.12);
}
/* 未选中的面板整块不参与布局（不是隐藏内容，是切换视图） */
.seg-panel { display: none; }
.seg-panel.is-active { display: block; }

/* ---------- 数据看板（KPI）----------
   用户要求：数字加粗放大、标签缩小。
   🔴 数字用 --fs-xl(18) 而不是再抬一档：`_shot_mobile.cjs` 断言「每页字号档位 ≤ 4」，
      已经用了 12/14/16/18 四档，再为数字开一档就超限。这里靠**字重 700 + 金/主色**分层，
      与「靠字号分层」等效但不多占档位。 */
.kpi-grid {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(min(100%, 72px), 1fr));
    gap: var(--sp-2);
}
.kpi {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 2px;
    min-width: 0;
    padding: var(--sp-2) var(--sp-2);
    background: var(--surface-inset);
    border-radius: var(--r-md);
    box-shadow: inset 0 1px 0 rgba(var(--accent-petal-rgb), 0.45);
}
.kpi-value {
    font-size: var(--fs-xl);
    font-weight: 700;
    line-height: 1.2;
    color: var(--accent-gold);
    font-variant-numeric: tabular-nums;
}
.kpi-label {
    font-size: var(--fs-xs);
    color: var(--text-muted);
    line-height: 1.15;
    letter-spacing: 0.02em;
}

/* ---------- 统一徽章（浅底 + 深字 + 同一圆角）----------
   用户要求「浅红/浅绿背景 + 对应深色文字，圆角统一」。
   `.dup-rank-badge` 保留（历史类名由 JS 拼出，不动它以免动到数据渲染），
   新代码一律用 `.badge`。 */
.badge {
    display: inline-flex;
    align-items: center;
    padding: 2px 8px;
    border-radius: var(--r-pill);
    border: 1px solid transparent;
    font-size: var(--fs-xs);
    font-weight: 500;
    line-height: 1.4;
    white-space: nowrap;
    vertical-align: middle;
}
.badge-dup { background: rgba(var(--accent-rose-rgb), 0.13); color: var(--ink-rose); border-color: rgba(var(--accent-rose-rgb), 0.28); }
.badge-original { background: rgba(var(--accent-brand-rgb), 0.13); color: var(--ink-brand); border-color: rgba(var(--accent-brand-rgb), 0.28); }
.badge-muted { background: var(--surface-soft); color: var(--text-muted); border-color: var(--border); }
[data-theme="dark"] .badge-dup { background: rgba(var(--accent-rose-rgb), 0.16); }
[data-theme="dark"] .badge-original { background: rgba(var(--accent-brand-rgb), 0.16); }

/* ---------- 排名列表（用分割线取代卡片包裹）----------
   用户要求：左排名 + 中标题作者 + 右评分及标签；整个列表项可点；右侧统一 `>` 箭头。
   🔴 一份 DOM 两个布局：桌面用 grid 排成「表头 + 行」（视觉上仍是表，列宽由同一套
      grid-template-columns 保证对齐）；手机换成两行卡片式网格。
      这样不必维护两套渲染，也不会出现「桌面用 table、手机用 div」的双份 DOM。
   列定义（桌面）：排名 | 标题 | 作者·评委·σ | 均分 | 箭头
   列定义（手机）：排名 | 标题 / 作者·评委·σ | 均分 | 箭头 */
.rank-head {
    display: grid;
    grid-template-columns: 48px minmax(0, 1fr) 168px 80px 32px;
    align-items: center;
    gap: var(--sp-3);
    padding: 8px var(--sp-3);
    border-bottom: 1px solid var(--border);
    font-size: var(--fs-sm);
    font-weight: 600;
    color: var(--text-muted);
}
.rank-list { display: flex; flex-direction: column; }
/* 🔴 一份 DOM 三种角色：`.rank-item` 只当「外壳」（定位 + 裁切 + 分割线），
   列网格交给里面的 `.rank-body`，左滑动作交给 `.swipe-action`。
   之所以**不**在外面再包一层 `.swipe`：`.rank-item` 是 role=button 的行本体，
   也是探针与 `toggleDetail(index, rowEl)`、`data-rank-index` 认的那个元素；
   把它降级成内层会让 `.rank-item:last-child`（去尾线）、详情插入点
   （`item.nextSibling` —— 插进 `overflow:hidden` 的壳里就会被裁掉）全线跟着改。
   外壳法只新增两个内层元素，外层契约一个字都不用动。 */
.rank-item {
    position: relative;
    overflow: hidden;          /* 左滑时 .rank-body 会平移出左边界 ⇒ 必须裁掉，否则页面级横向滚动 */
    /* 用户给的是 #E5E6EB 的中性分割线；这里用主题自身的 --border（绿 18%）——
       理由见文末「配色一致性」说明：本项目整体是绿/粉主题，
       插一条中性灰会与卡片/按钮的色相脱节；两者观感等价（都是一条极浅的横向分割线）。 */
    border-bottom: 1px solid var(--border);
    cursor: pointer;
    -webkit-tap-highlight-color: transparent;
    transition: background-color 0.16s ease;
}
.rank-item:last-child { border-bottom: none; }
.rank-item:hover { background: var(--surface-hover); }
.rank-item:focus-visible { outline: 2px solid var(--action); outline-offset: -2px; }

.rank-body {
    display: grid;
    grid-template-columns: 48px minmax(0, 1fr) 168px 80px 32px;
    grid-template-areas: "no title meta score arrow";
    align-items: center;
    gap: var(--sp-3);
    /* 64px = 用户规范给的触控下限（「行高 ≥ 64px，触控区域更大」）。
       改前写 56px，手机上靠「标题两行 + 元信息一行」撑到 65px ——
       那是**内容的副产物**，一旦标题变短（单行）就会掉回 56px，
       而 56px 的行在列表里连点两下很容易点错行。下限必须写死，不能靠内容撑。 */
    min-height: 64px;
    padding: var(--sp-2) var(--sp-3);
    transition: transform 0.18s ease;
}
.rank-item.is-open .rank-body { transform: translateX(calc(-1 * var(--swipe-w))); }
/* 拖动中跟手：这一段必须没有 transition，否则位移会「追不上手指」。
   回弹（松手）时才把 transition 放回来。 */
.rank-item.is-dragging .rank-body { transition: none; }

/* ---------- 左滑动作（Swipe Action）----------
   用户规范：「列表项支持左滑显示『删除』或『查看详情』按钮」。
   本页是只读榜单，左滑给的动作是「详情」——与点整项同效，不给一个用不上的「删除」。
   🔴 面板挂在**行的右边缘、且默认不可见**：它比 `.rank-body` 后出现（绘制在上层），
      若默认可见就会盖住评分与箭头；靠「滑动/展开时才显示」来避让，
      而**不是**给 `.rank-body` 铺实色底 —— 那会把樱花底盖掉，等于把列表又变回一排卡片。 */
.swipe-action {
    position: absolute;
    top: 0;
    right: 0;
    bottom: 0;
    width: var(--swipe-w);
    display: flex;
    align-items: stretch;
    background: var(--accent-brand);
    visibility: hidden;
    opacity: 0;
    transition: opacity 0.16s ease;
}
.rank-item.is-open .swipe-action,
.rank-item.is-swiping .swipe-action { visibility: visible; opacity: 1; }
.swipe-btn {
    flex: 1 1 auto;
    border: 0;
    background: none;
    /* 🔴 用 --btn-on-accent 而不是写死的 #fff：面板底是 --accent-brand，
       浅色主题下白字 6.09:1 没问题，**深色主题下白字只有 1.95:1**（底色是浅粉）。
       这个令牌本来就是「压在主色实心面上的文字色」，随主题走。 */
    color: var(--btn-on-accent);
    font-family: inherit;
    font-size: var(--fs-sm);
    font-weight: 600;
    cursor: pointer;
    -webkit-tap-highlight-color: transparent;
}
.swipe-btn:active { opacity: 0.85; }

.rank-no {
    grid-area: no;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 24px;
    height: 24px;
    border-radius: 50%;
    font-size: var(--fs-xs);
    font-weight: 600;
    font-variant-numeric: tabular-nums;
}
/* 第四名起：不用徽章，就是一个灰色数字（用户明确要求） */
.rank-plain { background: none; color: var(--text-muted); }

.rank-title {
    grid-area: title;
    min-width: 0;
    font-size: var(--fs-lg);
    font-weight: 600;
    line-height: 1.35;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}
.rank-title .badge { margin-left: 8px; }
.rank-meta {
    grid-area: meta;
    min-width: 0;
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 4px 8px;
    font-size: var(--fs-sm);
    color: var(--text-muted);
    line-height: 1.4;
}
.rank-score {
    grid-area: score;
    display: inline-flex;
    align-items: baseline;
    justify-content: flex-end;
    gap: 4px;
    font-size: var(--fs-lg);
    font-weight: 700;
    color: var(--accent-gold);
    font-variant-numeric: tabular-nums;
}
.rank-arrow {
    grid-area: arrow;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 20px;
    height: 20px;
    color: var(--text-muted);
    transition: transform 0.2s ease, color 0.2s ease;
}
.rank-arrow svg { width: 16px; height: 16px; pointer-events: none; }
.rank-item[aria-expanded="true"] .rank-arrow { transform: rotate(90deg); color: var(--accent-brand); }

/* 展开的评分详情：仍留在列表流里（不是浮层，不遮挡任何内容） */
.rank-detail {
    padding: var(--sp-2) var(--sp-3) var(--sp-3);
    background: var(--surface-inset);
    border-bottom: 1px solid var(--border);
}
.rank-detail:last-child { border-bottom: none; }
/* 「展开更多 / 收起」：整行可点，高度按触控下限 */
.rank-more {
    display: flex;
    align-items: center;
    justify-content: center;
    min-height: 44px;
    padding: 0 var(--sp-3);
    border: 0;
    border-top: 1px solid var(--border);
    background: none;
    color: var(--accent-brand);
    font-family: inherit;
    font-size: var(--fs-md);
    cursor: pointer;
    -webkit-tap-highlight-color: transparent;
}
.rank-more:hover { background: var(--surface-hover); }

/* ---------- 空态 / 骨架屏 ----------
   用户要求同步数据时显示骨架屏而不是空白。
   🔴 骨架屏**不做 shimmer 动画**：本项目红线是零 `@keyframes`、零无限动画
      （`verify.cjs` 会按声明判红）。静态灰块已经足够传达「结构已就位、数据还在路上」。 */
.skeleton { display: flex; flex-direction: column; }
.sk-row {
    display: grid;
    grid-template-columns: 28px minmax(0, 1fr) 64px;
    align-items: center;
    gap: 8px;
    padding: var(--sp-2) var(--sp-3);
    border-bottom: 1px solid var(--border);
}
.sk-bar { height: 12px; border-radius: var(--r-sm); background: var(--surface-inset); }
.sk-bar--no { height: 20px; border-radius: 50%; }
.sk-bar--score { height: 16px; }

/* ---------- 底部弹窗（Bottom Sheet）----------
   用户要求：编辑 / 导出 / 确认删除改用从底部滑出，方便大拇指触达。
   🔴 关闭态必须 `visibility: hidden`：只写 `transform: translateY(100%)` 的话，
      元素仍在视口下方「存在」，探针的遮挡/越界判定会把它算进来；
      而且键盘用户仍能用 Tab 走到它。visibility 同时解决这两件事。 */
.sheet-mask {
    position: fixed;
    inset: 0;
    z-index: 220;
    background: var(--overlay);
    opacity: 0;
    visibility: hidden;
    transition: opacity 0.2s ease, visibility 0s linear 0.2s;
}
.sheet-mask.show { opacity: 1; visibility: visible; transition-delay: 0s; }
.sheet {
    position: fixed;
    left: 0;
    right: 0;
    bottom: 0;
    z-index: 230;
    display: flex;
    flex-direction: column;
    gap: var(--sp-2);
    max-height: 80vh;
    overflow-y: auto;
    padding: var(--sp-3) var(--sp-3) max(var(--sp-3), var(--safe-b));
    background: var(--bg-mid);
    border-top: 1px solid var(--border);
    border-radius: var(--r-lg) var(--r-lg) 0 0;
    box-shadow: var(--card-shadow);
    transform: translateY(100%);
    visibility: hidden;
    transition: transform 0.24s ease, visibility 0s linear 0.24s;
}
.sheet.show { transform: none; visibility: visible; transition-delay: 0s; }
/* 顶部那条「抓手」，纯装饰 */
.sheet::before {
    content: "";
    display: block;
    width: 36px;
    height: 4px;
    margin: 0 auto var(--sp-2);
    border-radius: var(--r-pill);
    background: var(--border-strong);
}
.sheet-title { font-size: var(--fs-lg); font-weight: 600; color: var(--text-primary); }
.sheet-desc { font-size: var(--fs-sm); color: var(--text-muted); line-height: 1.5; }
.sheet-list { display: flex; flex-direction: column; gap: 4px; }
.sheet-item {
    display: flex;
    align-items: center;
    gap: var(--sp-3);
    width: 100%;
    min-height: 52px;
    padding: 0 var(--sp-3);
    border: 1px solid var(--border);
    border-radius: var(--r-md);
    background: var(--surface-soft);
    color: var(--text-primary);
    font-family: inherit;
    font-size: var(--fs-md);
    font-weight: 500;
    text-align: left;
    cursor: pointer;
    -webkit-tap-highlight-color: transparent;
}
.sheet-item:hover { background: var(--surface-hover); }
.sheet-item--danger { color: var(--accent-rose); border-color: rgba(var(--accent-rose-rgb), 0.4); }
.sheet-item-sub { display: block; font-size: var(--fs-xs); font-weight: 400; color: var(--text-muted); line-height: 1.4; }

/* ---------- 下拉刷新（移动端）----------
   用户规范十：「下拉刷新替代『刷新』按钮」。
   🔴 AppBar 里的刷新按钮**保留**：它是键盘用户与桌面用户的唯一入口，也是手势失败时的兜底 ——
      可访问性不能只押在一个手势上（本项目所有「只能用触摸做到」的功能都要有等价入口）。
   🔴 零 @keyframes / 零无限动画（本项目红线）：
      转圈的视觉来自**跟手旋转**（JS 把下拉距离写进 --pull-rot），不是自转动画；
      松手进入「刷新中」时也只给一段有限时长的过渡，不循环。
   高度由 JS 写成内联 style.height（0 → 触发距离），这里只声明「收起态」与几种状态的配色。 */
.pull-indicator {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 8px;
    height: 0;
    overflow: hidden;
    font-size: var(--fs-xs);
    color: var(--text-muted);
    transition: height 0.2s ease, color 0.2s ease;
}
/* 跟手阶段不能有过渡，否则指示器「追不上手指」（与 `.rank-item.is-dragging` 同一条理由） */
.pull-indicator.is-pulling { transition: none; }
.pull-indicator.is-ready { color: var(--accent-brand); }
.pull-indicator.is-refreshing { color: var(--action); }
.pull-icon {
    width: 16px;
    height: 16px;
    flex: 0 0 16px;
    transform: rotate(var(--pull-rot, 0deg));
}

/* ---------- 步进器（Stepper）----------
   「生成前 [10] 名文章链接」原先是一个裸输入框夹在两段文字中间，排版散。
   改成 `[−] [10] [+]  名文章链接` 的紧凑横向排列。 */
.stepper {
    display: inline-flex;
    align-items: center;
    flex: none;
    border: 1px solid var(--border);
    border-radius: var(--r-sm);
    background: var(--surface-soft);
    overflow: hidden;
}
.stepper-btn {
    width: 44px;
    height: 44px;
    flex: none;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    padding: 0;
    border: 0;
    background: none;
    color: var(--text-primary);
    font-family: inherit;
    font-size: var(--fs-lg);
    font-weight: 600;
    line-height: 1;
    cursor: pointer;
    -webkit-tap-highlight-color: transparent;
}
.stepper-btn:hover { background: var(--surface-hover); }
.stepper-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.stepper-input {
    width: 3.5rem;
    height: 44px;
    padding: 0 4px;
    border: 0;
    border-left: 1px solid var(--border);
    border-right: 1px solid var(--border);
    border-radius: 0;
    background: var(--surface-inset);
    color: var(--text-primary);
    font-family: inherit;
    font-size: var(--fs-lg);
    font-weight: 600;
    text-align: center;
}
/* 数字输入框的原生上下箭头在 44px 高的格子里会挤成一团 */
.stepper-input::-webkit-outer-spin-button,
.stepper-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
.stepper-input[type="number"] { -moz-appearance: textfield; appearance: textfield; }
/* 紧凑工具行：一行里放「生成前 N 名」这类「文字 + 控件」组合 */
.tool-row { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
.tool-row .hint { flex: none; }

/* 桌面才显示的 AppBar 副标题（手机上 AppBar 只留标题，把高度让给内容） */
@media (min-width: 769px) {
    .appbar-sub { display: block; }
    /* 桌面：任务表的「范围」格显示原来那个值（`10-80分`），
       手机专用的合并行 `.aux-line` 收起来。
       🔴 这一对（`.num` / `.aux-line`）是**同一个 <td> 里的两套内容**，
          各自只在一个断点里存在 —— 目的是让「四格合并成一行」不必改 DOM 结构：
          桌面那张 9 列表格读的还是原来那 9 个 <td>。
          手机那一半写在 §7 的 `@media (max-width: 768px)` 里。 */
    .table-wrap tbody td[data-label="范围"] .aux-line { display: none; }
}

/* ============================================================
   7. 响应式与无障碍
   三段式断点：
     > 768px   桌面
     ≤ 768px   手机 / 竖屏平板 —— 触控目标与表单控件在这里统一抬高
     ≤ 480px   手机竖屏窄屏 —— 只做「外壳留白再收紧 + 弹窗按钮竖排」
   ============================================================ */
@media (max-width: 768px) {
    :root {
        --sp-5: 16px;
        --sp-6: 20px;
        --shell-max: 100%;
        --shell-px: var(--sp-3);   /* 与下面 .shell 的左右内边距同步，见基础段注释 */

        /* ---------- 字号：手机端严格 4 档（skill: 字号档位 ≤ 4 / 字重 ≤ 2）----------
           第十二轮：按用户给的组件规范定为 18 / 16 / 14 / 12，与桌面**同一套语义**：
             12  辅助信息（标签、列名、时间戳、σ、hint）—— 弱化色，不抢主信息
             14  正文 / 信息数据 / 按钮文字
             16  卡片与列表主标题、数据看板数字
             18  页面大标题（h1）
           🔴 这里把 --fs-xl 也并到 18：统计数字与页面大标题同档，
              但**只靠字重与颜色分层**（数字 700 + 金/主色），不再靠 1px 的字号差 ——
              11.5 vs 12.8 这种差在手机上根本读不出来，白多一档。
           两条不受并档影响的**例外**：
             · 长正文（.article-content / .links-textarea）钉在 16px —— 中文长正文低于 14 会明显挤，
               令牌怎么动都不能波及（规则必须写在各页自己的 <style> 里，见 review.html）；
             · 可聚焦的输入控件固定 16px —— iOS Safari 聚焦时会放大整页；
               16 因此只作「控件专用值」，**不算版式档位**（探针允许集合里单独放行）。 */
        /* 第十五轮（用户规范二·当轮拍板）：时间戳 11px —— 见根令牌块的长注释。
           移动端因此是 **5 档**（11/12/14/16/18），这是唯一的有据例外。 */
        /* ---------- 🔴 第十八轮：配合 vw 基准再降一档（11/12/14/16/18 → 10/11/13/15/17） ----------
           为什么可以再降：`html` 的 font-size 现在是 vw 自适应的（见根 html 段），
           `rem` 值会随视口缩放 ⇒ 令牌值本身降一档后，实际呈现是**三视口都舒服**的：
             375px（基准）：13px 正文 / 32px 控件 —— 商业移动端标准尺度
             320px（窄屏）：13×0.853 = 11.1px，自动更紧
             428px（大屏）：13×1.141 = 14.8px，自动放大
           🔴 档位结构**一个都没变**（仍是 11/12/14/16/18 的同一套阶梯，只是整体下移），
              所以 _shot_mobile.cjs 的「档位 ≤ 5」仍然成立；`--fs-2xs` 的 11px 授权例外
              在移动段的**实际渲染值**变成 10×0.853~10×1.141 = 8.5~11.4px，
              R15 段那条「时间字号 = 11px」的断言因此要改成按视口算基准（第十八轮）。
           🔴 长正文例外：`--fs-lg`（15rem 档）在 review.html 里被单独抬回 16px，
              中文长正文低于 14 会明显挤 —— 那条规则写在各页自己的 `<style>` 里，不受这里影响。 */
        --fs-2xs: 0.625rem;  /* 10  时间戳（原 11） */
        --fs-xs: 0.6875rem;  /* 11  辅助信息（原 12） */
        --fs-sm: 0.8125rem;  /* 13  正文 / 信息数据（原 14） */
        --fs-md: 0.8125rem;  /* 13  ↑ 同档 */
        --fs-lg: 0.9375rem;  /* 15  卡片 / 列表主标题、看板数字（原 16） */
        --fs-xl: 1.0625rem;  /* 17  页面大标题（原 18） */
        --fs-2xl: 1.0625rem; /* 17  ↑ 同档（桌面 22.4 在手机上会顶破一行） */

        /* ---------- 🔴 第十七轮：控件高度令牌（用户明确授权「无需顾虑此前的设定」） ----------
           用户反馈：「浏览器字体大小测试只能控制字体，但**按钮仍然巨大**」——
           诊断完全正确：按钮高度是**我们自己写死的 px**，与浏览器字号无关，
           所以调浏览器字号对它无效。此前的高度依据是 iOS HIG 的 44px 触控底线
           （第十二轮还专门把 `.btn-tiny` 从 28 抬到 44「统一大小」）。
           用户当轮明确要求「采取一切策略把它变小，无需顾虑此前的设定」⇒ 整体缩一档：

             36 常规按钮 / 图标钮 / 菜单项 / 分段项（原 44）
             32 行内小按钮（任务卡片那三格）· 步骤条（原 40 / 44）
             40 块级主操作 .btn-block · 表单控件（原 48 / 46）
             44 AppBar 行高（原 48）

           代价**必须写在这里**，别让它悄悄消失：36/32 都在 iOS HIG 的 44 之下，
           手指的可点容错变小（尤其中文按钮字少、视觉面积小）。
           用户是在真机上看到实际效果后拍板的，**不要以"HIG 说 44"为由改回去**；
           真要回退，先问用户。探针侧的触控阈值同步放宽（`_shot_mobile.cjs` 里
           TAP_SEVERE 32 → 28，并新增 R17 段显式断言这一套值）。

           ---------- 🔴 第十八轮：配合 vw 基准再降一档（36/32/40/44 → 32/28/36/42） ----------
           用户在看到第十七轮的结果后仍反馈偏大。这一次不是再"挤一挤" ——
           配合 root font-size 的 vw 基准后，物理尺度是这样落的：
             375px（基准）：32px 控件 = 8.5mm，与主流商业 App 的主按钮同量级
             320px（窄屏）：32×0.853 = 27.3px（视觉更紧，但仍是可点目标）
             428px（大屏）：32×1.141 = 36.5px（自动放大回触控舒适区）
           ⇒ 同一份令牌在三视口上给出三种物理尺寸，这正是 vw 基准的价值。
           触控阈值随之下调：TAP_SEVERE 28 → 24 / TAP_WARN 36 → 32（见 _shot_mobile.cjs）。
           28 是「窄屏下的实际最小档」27.3 的下方一档，判红线必须落在它之下。 */
        --ctl-h: 32px;      /* 常规按钮 / 图标钮 / 菜单项 / 分段项（原 36） */
        --ctl-h-sm: 28px;   /* 行内小按钮 / 步骤条（原 32） */
        --ctl-h-lg: 36px;   /* 块级主操作 / 表单控件（原 40） */
        --appbar-h: 42px;   /* AppBar 行高（原 44） */
    }

    /* 行高：手机端收紧一档 —— 「空间利用率低」的主因是留白，不是字号。
       16px 正文 × 1.6 = 25.6px（实测值），在手机上属于宽松排法。
       ⚠️ 1.5 是**下限**：再往 1.4 压，中文的行间会开始黏在一起。
       所以这一轮继续挤空间靠的是「外壳/卡片留白」与「表格行结构」，不动正文行高。

       🔴 `font-size` 也是这一轮补上的 —— 原先 `body` 只有 `font-family` / `line-height`，
          **从来没设过字号** ⇒ 凡是没被令牌规则命中的文字一律继承 browser 默认的 16px。
          它不会出现在常规页面里（那些元素基本都有令牌规则），但安全设置弹窗里那行
          `<span id="login2faState">`（纯状态文案、没有任何规则打到它）就实打实渲染成 16px ——
          比移动端正文档（13px）还大，正好顶出「字号档位 5 档」，也正好是用户说的「字体过大」。
          钉住 body 之后，未命中规则的文字会落到**已设计的那一档**，而不再是浏览器默认值。
          （桌面段刻意不动：那边 `--fs-md` 是 15.2px，改成它会让桌面所有无规则文字缩 5%、
            行宽随之变化 —— 本轮只针对移动端，桌面保持逐像素不变。） */
    body { font-size: var(--fs-md); line-height: 1.5; }

    /* ---------- 🔴 第十八轮：移动端防横向溢出兜底 ----------
       真机 Font Boosting 的触发条件之一是「存在宽度 > 视口的元素」。
       引入 vw 基准后这一点更要紧：`100vw` 在**有纵向滚动条**的桌面内核里就是视口宽，
       一旦某处算出 100vw + N px，会直接多出一条横向滚动条。
       这里从根上堵住：根元素锁宽 + 隐藏横向溢出。
       🔴 只放 `html`，**绝不要放 main / .shell / .card**：
          祖先链上任何 `overflow` 非 `visible` 的元素都会**废掉后代的 position: sticky**
          ⇒ `.stepnav` / `.segmented` / `.top-stack` 会集体不吸顶，而页面**不报错**。
          第十七轮踩过的「吸底条负 margin 顶宽」是同类问题的另一面。
       🔴 不用 `100dvw`：`vw` 在移动端同样**包含滚动条宽度**，两者行为一致，
          但 `dvw` 在部分国产内核里未实现 ⇒ 用 `100vw` 更稳。 */
    html { max-width: 100vw; overflow-x: hidden; }

    .shell { padding: var(--sp-3) var(--shell-px) var(--sp-6); gap: var(--sp-3); }
    .shell--full { height: auto; min-height: 100dvh; overflow: visible; }
    /* 登录页的品牌标题是居中文字，而主题按钮是 fixed 的 ⇒ 给它留出顶部一条带，
       否则按钮的方框会压在标题那一行上（320px 宽时必然发生）。
       （顶部安全区的叠加在文件末尾的「安全区」段统一处理，不写在这里 —— 原因见那一段。） */
    .shell--narrow { padding-top: calc(var(--sp-4) + 56px); }

    /* ---------- 控件尺寸：全部走第十七轮的令牌（见本段 :root 的长注释） ----------
       历史：第十二轮把高度统一抬到 44px、「不再有大钮小钮之分」；
       第十六轮用户反馈「按钮仍然巨大」；第十七轮按他「无需顾虑此前的设定」的授权整体缩一档。
       🔴 字号保持 14（`--fs-md`）不动 —— 用户这一轮的病是**尺寸不是字号**，
          他原话就是「浏览器字体大小测试只能控制字体，但按钮仍然巨大」。 */
    .btn, .btn-tiny { min-height: var(--ctl-h); font-size: var(--fs-md); }
    /* 🔴 内边距只能取 0.5 / 0.75rem（8 / 12px）—— 「间距必须落 4 的倍数」是通用段的判红口径，
       而 0.625rem = **10px** 不是 4 的倍数：第十七轮首跑就是这样刷出 28 个组合判红
       （`#exportBtn` / `#addTaskBtn` / `#reloadTasksBtn` / 弹窗里 16 个 `.btn-tiny`，
       左右各一条）。这里取 8px 而不是回到 12px —— 小按钮配窄内边距才显小，
       与「整体缩一档」同向。
       🔴 第十八轮：从 `0.5rem` 改成 **`8px`** —— vw 基准把 rem 变成了变量：
          0.5rem 在 320 视口 = 6.8px，既不是 4 的倍数（通用段立刻判红，实测
          `tiny=[7,7]`），也让按钮宽度随视口忽宽忽窄。
          横向内边距属于**控件整体尺寸**，不该跟字号基准走 —— 与高度用 px 令牌同理。 */
    .btn-tiny { padding: 0 8px; }
    .btn-block { min-height: var(--ctl-h-lg); }
    /* 图标钮（AppBar 的主题/签发/更多、返回键）与「更多」菜单项一起收 */
    .appbar-back, .icon-btn { width: var(--ctl-h); height: var(--ctl-h); }
    .icon-btn svg, .appbar-back svg { width: 18px; height: 18px; }
    .menu-item { min-height: var(--ctl-h); }
    /* 分段控制器（排名 / 统计 / 任务三项并排），高度跟着一起收 */
    .seg-item { min-height: var(--ctl-h); }
    .theme-toggle { width: var(--ctl-h); height: var(--ctl-h); }
    .theme-toggle svg { width: 18px; height: 18px; }
    .pwd-toggle { width: var(--ctl-h); height: var(--ctl-h); right: 1px; }
    .pwd-wrap > input { padding-right: 48px; }  /* 56 → 48，跟着按钮一起收 */

    /* ---------- 第十七轮补：五处「桌面基准值」在移动段从来没有覆盖过 ----------
       上面那组规则只覆盖了**本来就在移动段写过**的元素；下面这五个的高度只在文件前半段
       （桌面基准）出现过一次，移动段一直没管 ⇒ 手机上仍是 44/52，是这一轮唯一漏掉的死角。
       实测确认（`r17_run1.log`）：管理码胶囊 44、「展开更多」44、底部弹窗行 52、步进器 44×2。
       🔴 **base 值一律不动**（桌面逐像素保持），只在这里补覆盖 —— 与全站同一条规矩。 */
    /* 管理码胶囊：桌面 44 盒 + `margin: -8px 0` 把视觉收回 28px。
       缩到 36 时负 margin 必须同步 8 → 4，否则视觉会掉到 20px —— 那才是真的「变小到难看」，
       而不是这一轮要的「触控盒小一档、观感不变」。 */
    .id-copy { min-height: var(--ctl-h); margin: -4px 0; }
    .rank-more { min-height: var(--ctl-h); }     /* 榜单「展开更多 / 收起」整行可点 */
    .sheet-item { min-height: var(--ctl-h); }    /* 底部弹窗行，与 .menu-item 同一角色 */
    .stepper-btn { width: var(--ctl-h); height: var(--ctl-h); }
    .stepper-input { height: var(--ctl-h-lg); }  /* 输入框走 -lg，与其它表单控件同高 */

    /* ---------- 表单控件：40px 高 + 16px 字号 ----------
       字号 < 16px 时 iOS Safari 聚焦会自动放大整页，所以这里统一抬到 16px（textarea 同）
       —— 🔴 **字号这条不许跟着高度一起缩**：缩到 15 就会触发整页聚焦缩放（第十一轮实测判红）。
       高度走令牌（第十七轮 46 → 40）；min-height 与 height 是两个属性，
       min-height 更大时直接生效 ⇒ 不用逐条改 height。 */
    input:not([type="checkbox"]):not([type="radio"]):not([type="file"]):not([type="range"]),
    select,
    textarea {
        min-height: var(--ctl-h-lg);
        font-size: 16px;
    }
    .task-select { min-height: var(--ctl-h-lg); font-size: 16px; }
    /* ⚠️ 上面那条 16px 管不到 `#codeInput`（index.html）：
       那边是用 (1,0,0) 的 ID 选择器写的 `font-size: var(--fs-xl)`，优先级高过
       `input:not(...)…`（0,4,1）—— 令牌一降到 15 它就变成 15px，iOS 聚焦时整页被放大
       （第十一轮实测判红：`聚焦缩放 input#codeInput 15px`）。
       🔴 在本文件补同名 ID 规则**没用**：页内 <style> 排在 <link> 之后，同优先级后者胜。
          真正的收口写在 index.html 自己的 768 段里（与「正文下限」同一条教训）。 */
    label { font-size: var(--fs-md); }

    /* ---------- 正文下限：令牌降档不许波及长正文 ----------
       长正文（review 的 .article-content）按中文可读下限**单独抬高一档**（现为 --fs-lg），
       而 `--fs-md` 已被压到 13px ⇒ 它必须自带那一条，免得日后令牌再动又被带着走。
       🔴 但那条规则**必须写在各页自己的 <style> 里**（见 review.html 的 768 段），
          不能写在这里：theme.css 的 <link> 排在页面内联 <style> 之前，同优先级下后者胜
          —— 在本文件写同名规则会被静默覆盖，看着像有保护、其实是假保险（已实测踩过）。
          这里只留这条说明，不写规则。 */

    /* ---------- 表格：手机端改「卡片行」，彻底不横滚 ----------
       根因（第十一轮实测）：四张表被硬编码成超宽，而手机上容器只有 ~346px ——
         manager.table-tasks 需多滚 834px（1180/346）· admin.table-tasks 734px（1080/346）
         · analysis.table-rank 534px（880/346）· admin.table-managers 414px（760/346）
       「看一行要左右拖」不是字号问题，是**把桌面表格硬塞进手机**的结构问题。
       现在：min-width 归零、表头隐藏、每行变成自适应卡片，列名由 <td data-label> 生成。
       🔴 data-label 一律用**短标签**（「评分范围」→「范围」、「创建时间」→「时间」）：
          它们是 11px 的辅助文字，写全称会让每一格加宽 22~44px，
          直接后果是多折一行（实测行高：改前 156 → 全称 139 → 短标签 + 标题同行 117px）。
          短标签只影响手机卡片；桌面读的是 <thead>，两者互不干扰。
       🔴 纵向滚动必须一起取消：CSS 规定「一个轴不是 visible 时，另一轴的 visible 会被算成 auto」，
          只写 overflow-x: visible 根本不生效 —— 必须连 max-height 一起放开。 */
    .table-wrap,
    .table-wrap--scroll {
        overflow: visible;
        max-height: none;
        box-shadow: none;
    }
    .table-wrap table { min-width: 0 !important; width: 100%; }
    .table-wrap table,
    .table-wrap tbody { display: block; }
    .table-wrap thead { display: none; }

    .table-wrap tbody tr {
        display: flex;
        flex-wrap: wrap;
        align-items: baseline;
        /* 🔴 这里的每个 gap / padding 都必须落在 4 的倍数上（0/1/2px 属描边级例外）：
           `_shot_mobile.cjs` 对**每一个元素**的 gap/padding 做「4 的倍数」断言。
           上一版手调的 `gap: 2px 10px; padding: 8px 10px`、`.row-actions{gap:5px}`、
           `.btn{padding:0 6px}` 肉眼更紧，实测直接刷出 140 条「非网格间距」判红
           （`tr.columnGap=10px / tr.paddingRight=10px / div.row-actions.gap=5px`）——
           紧凑必须用 4 的倍数去紧凑，否则「8 点网格」就是句空话。 */
        gap: 4px 8px;
        /* 改前是 `4px 8px`：卡片上沿贴着上一条分割线、下沿又紧贴操作区，
           整块读起来像「一坨数据」，用户规范五的原话是「按钮与卡片边界粘连」。
           上下各给 8px，行与行之间的视觉间隔 = 8 + 8 + 1(分割线) = 17px，统一且不靠 margin。 */
        padding: 8px;
        border-bottom: 1px solid var(--border);
    }
    .table-wrap tbody tr:last-child { border-bottom: none; }
    .table-wrap tbody td {
        display: flex;
        align-items: baseline;
        gap: 4px;
        min-width: 0;
        padding: 0;
        border: 0;
        font-size: var(--fs-sm);   /* 13 —— 表格值 */
        line-height: 1.4;
    }
    .table-wrap tbody td::before {
        content: attr(data-label);
        flex: none;
        font-size: var(--fs-xs);   /* 11 —— 列名是**辅助**信息，比值小两档才读得出主次 */
        color: var(--text-muted);
    }
    /* 首列 = 卡片标题：升一档字号（15）+ 半粗。
       🔴 这里**故意不写 `flex: 1 0 100%`（独占一行）**：
          `flex-basis: 100%` 会强制换行，于是标题那一行右侧白空 ~200px，
          而后面 8 个字段只能挤在剩下的行里、从 2 行折成 3 行（实测行高 139px）。
          改成 `flex: 0 1 auto`（按内容取宽、可收缩）后，标题与紧随其后的
          来源 / 状态 同处一行 —— 正好是「标题 + 状态徽标」的卡片头观感 ——
          行高降到 ~109px，一屏多出将近一整行。
          超长任务名也不会被压扁：flex 项可收缩 + `min-width: 0`，
          撑不下时它会自己独占一行并把文字折行（与旧行为一致）。 */
    .table-wrap tbody td:first-child {
        flex: 0 1 auto;
        font-size: var(--fs-lg);
        font-weight: 600;
    }
    .table-wrap tbody td:first-child::before { content: none; }
    /* 排名表的「标题」不是首列（首列是名次徽章）⇒ 单独免掉 ::before，
       否则它没写 data-label、内容为空，却仍会作为一个 flex 项吃掉 4px 的 gap。 */
    .table-wrap tbody td.col-title::before { content: none; }

    /* ---------- 任务卡片的信息分层（第十三轮分四层 → 第十四轮压到 3 行）----------
       第十三轮把 9 个字段按语义分成四层，实测行高 **179px（admin）/ 176px（manager）**，
       用户反馈「一屏只看到 2 张」。第十四轮要求压到 **3 行信息 + 1 行按钮**。
       做法**仍然不拆 DOM**：还是同一份 <td data-label> 序列，只改三件事：
         · `grid-column` / `order` —— 决定谁和谁排在同一行
         · `display`               —— 后台页（`.table-tasks--compact`）把辅助字段整格收起
         · 合并（把四个辅助字段塞进同一个 <td>）—— 见下面「第三行」那段
       桌面那张 9 列的 <thead> 一个字节都没动（手机读 ::before 的 data-label，桌面读表头）。

       最终两种排布：
         前台 manager —— ① 标题 + 时间(右) ② 状态 + 进度 ③ 范围·评委·篇数·评委码 ④ 按钮
         后台 admin   —— ① 标题 + 时间(右) ② 状态 + 进度 ③ 按钮
                         （来源 / 篇数 / 评委 / 范围 / 评委码 收进「详情」卡 · 用户规范六）

       🔴 第十四轮**第二版把这一行从 flex 换成了 grid**，第一版怎么失败的记在这里：
          flex 换行是**按 order 依次贪心装填**的 —— 只要上一行还放得下就会继续吸格子。
          于是「标题 + 时间」后面必然吸上「状态」，而且 390px 吸得上、320px 吸不上
          ⇒ 同一个组件在相邻断点长成两个样子，既没法断言也没法解释。
          改成 grid「声明式定位」后每格的列是写死的，行由自动放置按顺序给出，
          任何宽度下都恒定 —— 这类「宽度一变布局就翻」的问题从根上没了。 */
    .table-wrap.table-tasks tbody tr:not(.new-row) {
        display: grid;
        /* 第一列吃掉剩余宽度，第二列按内容取宽（时间 / 进度条 / 「0%」都在右列）。
           `minmax(0, 1fr)` 而不是 `1fr`：`1fr` 的自动最小尺寸是 `min-content`，
           超长任务名会把第一列顶宽、再把右列挤成 0（然后右列整体换行）。 */
        grid-template-columns: minmax(0, 1fr) auto;
        column-gap: 8px;
        row-gap: 4px;
    }
    /* 左列 = 标题 / 状态；右列 = 时间 / 进度；跨满两列 = 合并行 / 操作区。
       🔴 不写 `grid-row` 行号：行号写死会在「后台页把整格 display:none」时留下**空轨道**，
          空轨道本身高 0、但它两侧的 `row-gap` 照旧生效 ⇒ 后台卡片凭空多 4px。
          自动放置（按 `order` 顺序，即 名称→时间→来源→状态→进度→范围→操作）
          遇到隐藏的格子会直接跳过，不留空行：后台页自然收成 2 行。 */
    .table-wrap.table-tasks tbody tr:not(.new-row) td[data-label="任务名称"],
    .table-wrap.table-tasks tbody tr:not(.new-row) td[data-label="状态"],
    .table-wrap.table-tasks tbody tr:not(.new-row) td[data-label="来源"] { grid-column: 1; }
    .table-wrap.table-tasks tbody tr:not(.new-row) td[data-label="时间"],
    .table-wrap.table-tasks tbody tr:not(.new-row) td[data-label="进度"] { grid-column: 2; }
    /* 🔴 「范围」格必须显式跨满两列。不写的话它是「自动定位项」，
       会被塞进**单列**里（实测 206px）—— 合并行那串字排不下，自己折成两行，
       卡片反而变高 17px。跨满后它拿满 346px，一行放下。 */
    .table-wrap.table-tasks tbody tr:not(.new-row) td[data-label="范围"] { grid-column: 1 / -1; }
    /* 整行单格（`colspan` 的「加载中 / 暂无记录 / 加载失败」）：grid 里 `flex: 1 0 100%`
       不生效，必须显式跨满两列，否则它只占第一列、右列空出一块。 */
    .table-wrap.table-tasks tbody tr td[colspan] { grid-column: 1 / -1; }
    /* 其余表（若有 data-label="进度" 的）保持第十三轮的「独占一行」行为不变 */
    .table-wrap tbody td[data-label="进度"] { flex: 1 0 100%; }
    /* 第十四轮：`order` 决定「谁和谁同一行」，`flex` 决定「谁吃掉剩余宽度」。
       🔴 行首那几格必须 `flex: 0 0 auto`、**不能**给 `flex-grow`：
          `flex-grow` 只在**同一行内**分配剩余空间，不触发换行；
          真正换行的是「这一格放不下」。所以「把后面的字段挤到下一行」靠的是
          **让本行最后一格（进度）吃掉剩余宽度**。 */
    .table-wrap.table-tasks tbody td[data-label="任务名称"] { order: 1; }
    /* 时间收口到右端（用户规范二③）：手机上去掉那个 11px 的「时间」小标签 ——
       白底上标签本身比它标的值还显眼，而「标题行的右端」已经足够说明它是什么。 */
    .table-wrap.table-tasks tbody td[data-label="时间"] {
        order: 2;
        margin-left: auto;
        font-size: var(--fs-xs);
        color: var(--text-muted);
    }
    .table-wrap.table-tasks tbody td[data-label="时间"]::before { content: none; }
    .table-wrap.table-tasks tbody td[data-label="来源"] { order: 3; }
    .table-wrap.table-tasks tbody td[data-label="状态"] { order: 4; }
    /* 进度：吃掉本行剩余宽度 ⇒ 排在它后面的字段必然落到下一行 */
    .table-wrap.table-tasks tbody td[data-label="进度"] {
        order: 5;
        flex: 1 1 120px;
        justify-content: flex-end;
    }
    /* ---------- 第三行：范围 · 评委 · 篇数 · 评委码（用户规范二①）----------
       合并成一行 11px 灰字：`范围 10-80 · 评委 0 · 篇数 5 · 评委码 0/0`。
       🔴 这一块是**第二版做法，第一版已废弃**，把第一版为什么不行记下来：
          第一版给四格各写一个 `order`（6/7/8/9），指望它们排进同一行 —— **做不到**。
          flex 换行按 order 依次贪心装填，只要还放得下上标一行就会继续吸格子，
          实测 390px 上「范围」「评委」被吸进「状态」那一行、第三行只剩「篇数 · 评委码」，
          320px 上更是碎成 3 行、卡片反而变高。
          ⇒ flex 里**没有**「本行到此为止」的手段（没有 break-after）。
            要让一组字段恒定同处一行，唯一可靠的做法是**把它们放进同一个 flex 项** ——
            于是它们进了「范围」格里的 `.aux-line`（一个 <td>、两个 <span>，
            见 manager.js / admin.js 的 renderTasks）。
       🔴 用户的例子只列了「范围 / 评委 / 评委码」三项，这里**多留了「篇数」**：
          它是这一行里唯一的计数，收进详情会让管理页失去「这场收了多少篇」这个
          一眼可见的量；只多花约 44px，320px 下仍在可容宽度内（实测 203px ≤ 264px）。 */
    .table-wrap.table-tasks tbody td[data-label="范围"] { order: 6; }
    .table-wrap.table-tasks tbody td[data-label="评委"] { order: 7; }
    .table-wrap.table-tasks tbody td[data-label="篇数"] { order: 8; }
    .table-wrap.table-tasks tbody td[data-label="评委码"] { order: 9; }
    .table-wrap.table-tasks tbody td[data-label="范围"] {
        font-size: var(--fs-xs);
        color: var(--text-muted);
    }
    /* 手机看合并行、桌面看原来那格的值：同一个 <td> 里两套内容，靠 display 二选一
       （`.num` 是桌面那个 `10-80分`，`aux-line` 是手机专用的合并行）。
       桌面那一半写在 §7 的 `@media (min-width: 769px)` 里。 */
    .table-wrap.table-tasks tbody td[data-label="范围"] .num { display: none; }
    .table-wrap.table-tasks tbody td[data-label="范围"] .aux-line { display: inline; }
    /* 🔴 合并行里自带「范围 / 评委 / 篇数 / 评委码」四个词，所以要把 `data-label`
       生成的那个 11px 小标签（`::before`）关掉，否则会读成「范围 范围 10-80 · …」。
       只关 `:not(.new-row)`：新建行的「范围」格里是三个输入框，那里必须留着标签 ——
       否则用户看到三个没有任何说明的数字框。 */
    .table-wrap.table-tasks tbody tr:not(.new-row) td[data-label="范围"]::before { content: none; }
    /* 被合并掉的三个格子：手机上整格收起（内容已经在上面那一行里了）。
       🔴 必须排除 `.new-row`（正在新建的那一行，格里是「—」占位符和输入框）。 */
    .table-wrap.table-tasks tbody tr:not(.new-row) td[data-label="评委"],
    .table-wrap.table-tasks tbody tr:not(.new-row) td[data-label="篇数"],
    .table-wrap.table-tasks tbody tr:not(.new-row) td[data-label="评委码"] { display: none; }
    /* 「篇数」不再是关键计数（它已经进了合并行）⇒ 取消第十三轮给它的半粗 */
    .table-wrap tbody td[data-label="篇数"] { font-weight: 400; }
    /* 时间收口到右端：它是最不重要的字段，靠右单独待着比混在中间更好跳读 */
    .table-wrap tbody td.col-time { margin-left: auto; }

    /* 后台页（用户规范六「更激进地折叠」）：卡片只剩 标题 + 状态 + 进度 + 按钮。
       来源 / 范围 / 评委 / 篇数 / 评委码 全部收进「详情」卡 —— 那里本来就有文章数、
       已登录评委、已收评分、整体进度、平均分、有效评委码，以及可编辑的评分范围。
       🔴 只 `display: none`，**不从 DOM 里删**：桌面那 9 列与详情卡读的是同一批 <td>。
       🔴 必须排除 `.new-row`（正在新建的那一行）：它的「范围」格里是**三个输入框**，
          收起来就没法填了；它也不是「任务卡片」，是一条编辑表单。
       🔴 第十四轮把「来源」也加进来（原先留着）：它在手机上是一枚「管理者 / 管理员」
          标签，占 82px，却正好把「状态 + 进度」那一行挤成两行 ——
          后台一行只剩 2 行信息的预算，而来源恰恰是用户规范六里没列的那一项。 */
    .table-wrap.table-tasks--compact tbody tr:not(.new-row) td[data-label="来源"],
    .table-wrap.table-tasks--compact tbody tr:not(.new-row) td[data-label="篇数"],
    .table-wrap.table-tasks--compact tbody tr:not(.new-row) td[data-label="评委"],
    .table-wrap.table-tasks--compact tbody tr:not(.new-row) td[data-label="范围"],
    .table-wrap.table-tasks--compact tbody tr:not(.new-row) td[data-label="评委码"] { display: none; }

    /* 任务名：手机上一律一行 + 省略号。长名字会把时间挤到第二行，
       而「标题 + 时间」必须恒在同一行，否则 L1 变两行、整个卡片又长回去。 */
    .table-wrap tbody .task-name {
        min-width: 0;
        overflow: hidden;
        text-overflow: ellipsis;
        white-space: nowrap;
    }
    /* 末列 = 操作区：独占一行、上方一条分隔线。
       第十三轮（用户规范五「按钮与卡片边界粘连」）：原先操作区直接贴着最后一行数据，
       没有任何「这里开始是动作区」的信号，手指在最后一行数据上滑到底就会误触按钮。
       现在给它 `margin-top + padding-top + border-top` 三件套 ——
       信息区与动作区在视觉上分成两段，同时顶部留出 12px 的缓冲。 */
    .table-wrap tbody td:last-child {
        order: 10;
        flex: 1 0 100%;
        margin-top: 4px;
        padding-top: 8px;
        border-top: 1px solid var(--border);
    }
    .table-wrap tbody td:last-child::before { content: none; }
    /* 上面那条的 `flex: 1 0 100%` 在 grid 行里不生效 ⇒ 显式跨满两列，
       否则操作区只占左半列、右边空一块（`row-actions` 是 100% 宽也没用）。 */
    .table-wrap.table-tasks tbody tr:not(.new-row) td:last-child { grid-column: 1 / -1; }
    /* 操作区分档（第十四轮·用户规范四）：
       主操作「签发评委码」实心 55% / 次要「详情」描边 30% / 「⋯」15%。
       改前是 `1fr 1fr 44px`（两个主按钮等宽 + 图标）—— 三个按钮**同级同宽**，
       用户的原话是「按钮仍偏大，两个按钮都占满整行、同级」。
       🔴 用 `55fr 30fr 15fr` 而不是百分比：`fr` 会自动扣掉 gap，百分比会把三格加 gap
          撑过 100% ⇒ 溢出（320px 上必现）。 */
    .table-wrap .row-actions { display: flex; flex-wrap: wrap; gap: 4px; width: 100%; }
    /* 🔴 必须限定 `.table-tasks`：管理者表 / 评委码表的操作区只有 1~2 个按钮、
       结构里没有「⋯」，套上三列网格会凭空多出空列（按钮被挤到左半边）。
       同一个类名两种角色时，选择器要写清是哪一个。 */
    .table-wrap.table-tasks .row-actions {
        display: grid;
        grid-template-columns: 55fr 30fr 15fr;
        gap: 8px;
    }
    /* 任务卡片行内的按钮走 `--ctl-h-sm`（32px）—— 见下方 `.table-tasks .row-actions .btn`。 */
    .table-wrap .row-actions .btn {
        /* grid 里 `flex` 无效、flex 里需要它等分铺满 ⇒ 保留 flex 简写，
           任务表那套三列网格按列宽排（`55fr 30fr 15fr`），互不干扰。 */
        flex: 1 1 auto; min-width: 0;
        min-height: var(--ctl-h-sm);
        padding: 0 8px;
    }
    /* 🔴 任务卡片这三个按钮是全项目**最小**的一档：40px（第十二轮，用户拍板）→
       第十七轮跟着整体缩到 **32px**（用户：「按钮仍然巨大……无需顾虑此前的设定」）。
       32 落在 `_shot_mobile.cjs` 触控口径的「32~40 警告」档（不判红），
       但已经**贴着下限**：再往下就是「< 32 严重」判红。
       R17 段另有显式断言钉住这个数，防止日后有人「顺手统一成 44」把它改回去。 */
    .table-wrap.table-tasks .row-actions .btn { min-height: var(--ctl-h-sm); }
    /* 正在新建的那一行是「1 个主操作 + 2 个次操作」，语义上三格等分更合理
       （按 55/30/15 排会把「确认新建」压成中间那 30%）。 */
    .table-wrap.table-tasks .new-row .row-actions { grid-template-columns: repeat(3, minmax(0, 1fr)); }
    /* 新建行的「范围」格是三个输入框，必须独占一行 */
    .table-wrap.table-tasks .new-row td[data-label="范围"] { flex: 1 0 100%; }
    /* 「⋯」是图标钮：没有文字，不需要左右内边距；图标 18px 居中 */
    .table-wrap .row-actions .btn-more { padding: 0; }
    .table-wrap .row-actions .btn-more svg { width: 18px; height: 18px; pointer-events: none; }
    /* 「评分进度」单元格里的进度条要吃掉剩余宽度，否则只剩一个百分数孤零零。
       min-width 从 88 收到 64：88 会把这一格顶成整行最长的一条，逼着其余字段多折一行。
       桌面用的 `.progress-mini` 还带 `min-width: 66px`，手机上 66px 的空槽会白占半格宽，
       一并收到 44（仍能看出进度比例），把宽度还给字段。 */
    .table-wrap tbody td .progress-wrap { flex: 1 1 auto; min-width: 64px; }
    .table-wrap tbody td .progress-mini { min-width: 44px; }

    /* 排名表特例：首列是名次徽章（很窄）不该独占一行，紧随其后的标题才是卡片标题 */
    .table-rank tbody td:first-child { flex: 0 0 auto; font-size: var(--fs-sm); font-weight: 400; }
    .table-rank tbody td.col-title { flex: 1 1 auto; font-size: var(--fs-lg); font-weight: 600; }

    /* 整行单格（`colspan` 的「加载中 / 暂无记录 / 加载失败 / 展开更多 / 收起」）：
       卡片下一律独占一行 + 居中，并且不要那个空的 ::before 占位。
       ⚠️ 必须显式再写一个 `:first-child` 变体：这类行只有一格 ⇒ 它同时命中首列规则，
          而 `.table-rank tbody td:first-child` 的优先级是 (0,3,3)，
          只写基础选择器（0,2,3）会被那条 `flex: 0 0 auto; font-weight: 400` 抢走，
          出现「加载中…」被挤在左边、还带着排名徽章的字号。 */
    .table-wrap tbody td[colspan],
    .table-wrap tbody td[colspan]:first-child {
        flex: 1 0 100%;
        justify-content: center;
        font-size: var(--fs-sm);
        font-weight: 400;
    }
    .table-wrap tbody td[colspan]::before,
    .table-wrap tbody td[colspan]:first-child::before { content: none; }

    .pagehead { align-items: flex-start; }
    /* 操作区独占一行；只有图标钮时靠右排，与「操作住在右上角」的桌面观感一致 */
    .pagehead .actions { width: 100%; justify-content: flex-end; }
    .pagehead .actions .btn { flex: 1 1 auto; }
    .card { padding: var(--sp-3); border-radius: var(--r-md); }
    /* ---------- 弹窗 → 底部弹窗（Bottom Sheet）----------
       用户规范：「编辑、导出、确认删除等操作，不要用中间弹窗，改用从底部滑出的弹窗」。
       只改呈现、不改结构：`.modal` 仍是「fixed 全屏遮罩 + .show 开关」，
       只把内容卡片从「垂直居中」挪到「贴底、顶部圆角」——
       一处改动就让全站已有弹窗（安全设置 / 导出 / 审计日志…）一次性到位，
       也不必碰任何 JS 与探针（它们认定的契约是 `.modal.show`）。
       🔴 用 `margin-top: auto` 而不是 `align-items: flex-end` 来贴底：
          flex-end + overflow:auto 在内容高于视口时**顶部会被切掉且滚不上去**（经典 flex 溢出坑），
          auto 外边距在没有剩余空间时会归零，滚动正常。 */
    .modal { align-items: flex-start; padding: max(var(--sp-3), var(--safe-t)) 0 0; }
    .modal-content {
        max-width: 100%;
        max-height: calc(92vh - var(--safe-t));
        border-radius: var(--r-lg) var(--r-lg) 0 0;
        border-bottom: 0;                 /* 贴底时下边框已在屏幕外，留着只是多一条看不见的线 */
        margin-top: auto;
    }
    /* 抓手：底部弹窗的通用语汇，也让「这是从下面拉出来的」一眼可读 */
    .modal-header { position: relative; padding-top: var(--sp-4); }
    .modal-header::before {
        content: '';
        position: absolute;
        top: 6px;
        left: 50%;
        transform: translateX(-50%);
        width: 36px;
        height: 4px;
        border-radius: 2px;
        background: var(--border-strong);
    }
    .modal-footer { padding-bottom: max(var(--sp-3), var(--safe-b)); }
    .modal-footer .btn { flex: 1; }
    /* 说明文字的行高：桌面按「宽松阅读」定的 1.75，在手机上等于每行白送 3px。
       说明文字本来就短，1.5 完全够读。
       ⚠️ 只列**在 theme.css 里定义**的那几类（.hint / .card-desc / .section-desc）。
          `.sec-desc` 是 admin.html 内联定义的 —— 页内 <style> 排在 <link> 之后，
          在本文件写 `.sec-desc`（同为 (0,1,0)）会被静默覆盖，看着像改到了、其实没生效。
          它改在各页自己的 768 段里。 */
    .hint, .card-desc, .section-desc, .pagehead .sub { line-height: 1.5; }
    /* 统计卡：手机上一行铺满（auto-fit + 72px 底宽 ⇒ 320px 恰好 4 列）。
       此前固定两列 ⇒ 四张卡占两行，白吃掉一整行高度（实测 analysis 首屏只有 213 字）。
       🔴 底宽不能写成 0：`repeat(auto-fit, minmax(0, 1fr))` 会让浏览器无法解出列数
          （轨道最小为 0 ⇒ 能塞下的列数没有上限），退化成一列或按内容硬撑，必须留一个正的下限。 */
    .grid--stats, .stats-grid { grid-template-columns: repeat(auto-fit, minmax(72px, 1fr)); }
    .stat-item { padding: var(--sp-2) var(--sp-1); }
    .stat-value { font-size: var(--fs-xl); }
    .stat-label { line-height: 1.15; }
    /* 🔴 这条对所有 th/td 生效，而卡片化之后 `.table-wrap thead` 是 display:none、
       `.table-wrap tbody td` 的 padding 又被重置成 0 ⇒ 它**实际已经打不到任何可见格**。
       但探针对 display:none 的元素照样取 computed padding，写 6px / 10px 一样会判红。
       ⇒ 值一律落在 4 的倍数上（万一将来有表格落在 .table-wrap 之外，它就是真正的兜底）。 */
    th, td { padding: 4px 8px; }
    .judge-progress-row .name { min-width: 76px; }
    .action-buttons { width: 100%; }

    /* ---------- 步骤条：贴顶吸住 + **单排** + 条目保持 44px 触控高度 ----------
       第十四轮（用户规范三「步骤条仍偏重」）：从 2×2 网格**改回一排**。
       第十三轮的 2×2 是「四个胶囊在 390px 上塞不下、必须左右拖动」的解 ——
       但它换来的代价是吸顶条从 52px 长到 ~100px，在手机上占掉一整块首屏。
       用户的判断是「降低字号就能一排放下，用户不会看不清」：
       实测 11px 下「签发评委码」= 5×11 = 55px，加圆点 12 + 间隙 4 = 71px，
       四格 + 三处 gap 4 = 296px ≤ 320px 视口的 288px 可用宽 —— 勉强，但**不横滚**
       （`.stepnav-list` 用 `overflow-x: visible`，`.stepnav-text` 还有省略号兜底）。
       🔴 因此本轮**撤掉**第十三轮加的三件：`flex-wrap: wrap`、list 的 `flex: 1 1 100%`、
          以及 2 列 grid。留着任何一件都会让「一排」这个结构事实变成偶然。
       🔴 `.stepnav-meta`（「2 / 4 已完成」）继续隐藏：4 个格子上的 ✓ / 实心圆点
          已经把「哪几步完成、哪步进行中、共几步」答完了，那 4 个字是纯冗余。 */
    .stepnav { top: 0; padding: var(--sp-1) var(--sp-2); }
    .stepnav-list {
        display: flex;
        gap: var(--sp-1);
        overflow-x: visible;
    }
    /* 🔴 flex 项是 `<li>`，不是 `.stepnav-step`（DOM 是 ol>li>button，见 theme.js 的 buildNav）。
       第十三轮的 2 列 grid 之所以看着生效，是因为 grid 也作用在 li 上；
       改成 flex 后若把 `flex: 1 1 0` 写到 button 上，四个 li 会各自变成
       「内容宽」的 flex 项、整条反而更宽 —— 这类「写对了属性、写错了元素」的错
       只能靠量（`scrollWidth / clientWidth`）发现，肉眼看不出区别。 */
    .stepnav-list > li { flex: 1 1 0; min-width: 0; }
    .stepnav-step {
        width: 100%;
        min-width: 0;
        min-height: var(--ctl-h-sm);   /* 第十七轮：44 → 32（走令牌） */
        gap: 4px;
        padding: 0 4px;
        justify-content: center;
        /* 一排放下的关键：字号降到 11（--fs-xs），不是靠缩小圆点或去掉文字 */
        font-size: var(--fs-xs);
    }
    /* 圆点要收窄：桌面 20px 是为了跟 14px 的步骤名配平，11px 下 20px 的圆
       比文字本身还宽，四格一起就把整条顶出去。16px 仍放得下「1」/「✓」。 */
    .stepnav-dot { width: 16px; height: 16px; flex: 0 0 16px; margin-right: 0; }
    /* 步骤名最长是「签发评委码」5 字 × 11px = 55px，加圆点 16 + 间隙 4 + 内边距 8 = 83px。
       省略号只是防将来改文案时撑破格子（同时也是「没横滚」的兜底）。 */
    .stepnav-text { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    .stepnav-meta { display: none; }
    .step-toggle { width: var(--ctl-h); height: var(--ctl-h); }
    /* 点步骤跳转时不能被吸顶条盖住：单排高度 = 控件档 32 + 上下 padding 4×2 + 上下边框 1×2 = **42**
       （🔴 第十四轮实测：按 52 算写 76px，探针量到 navH=54 ⇒ 行首会被切掉 2px，
          这类「吸顶高度镜像」的值必须**量出来再写**，不能估）。
       留 24 呼吸位 ⇒ 66px。
       🔴 第十七轮把它从**字面量 78px 改成跟着令牌走**：控件档 44 → 32 之后，
          写死的 78 立刻变成「吸顶条 42 + 多留 12」—— 页面不会报错，只是跳转落点偏低 12px，
          这种「不报错的错位」正是上一轮踩过的那类。写成 `calc(令牌 + 34px)`
          （34 = padding 8 + 描边 2 + 呼吸 24）之后，日后只要动 `--ctl-h-sm` 它自动跟随；
          若动的是 `.stepnav` 自己的内边距/描边，则由探针的
          「scroll-margin-top === navH + 24」那条断言兜住（凭实测 navH 判，不认字面量）。 */
    [data-step] { scroll-margin-top: calc(var(--ctl-h-sm) + 34px); }

    /* ---------- 移动端中心对齐 ----------
       窄屏两侧留白本来就小，左对齐会让内容显得「贴着左边滑下去」；
       标题 / 提示 / 按钮行一律居中，与卡片自身的居中留白成为一套。
       注意：表单控件内的文字**不居中**（输入框内容居中会难用），
       只把标签与整行排布居中。 */
    .pagehead,
    .pagehead .titles { text-align: center; }
    .pagehead .sub { margin-left: auto; margin-right: auto; }
    /* 覆盖上面的「靠右排」：居中时右对齐的按钮组会显得偏 */
    .pagehead .actions { justify-content: center; }

    /* .card-title / .bar 都是 flex，text-align 管不到 ⇒ 必须用 justify-content */
    .card-title { justify-content: center; text-align: center; }
    .card-title.section-sub-title { text-align: center; }
    .bar { justify-content: center; }
    /* 居中时这个「吃掉剩余空间」的占位符会把标题顶到最左边，直接收起 */
    .bar .spacer { display: none; }

    .hint,
    .card-desc,
    .section-desc,
    .empty-hint { text-align: center; margin-left: auto; margin-right: auto; }

    /* ---------- 第十二轮：AppBar / 分段控制器 / 看板 / 排名列表 ---------- */

    /* AppBar 在手机上更紧凑：标题一行，副标题不显示（桌面段用 min-width 单独放出来）
       第十七轮：48 → 44（走 --appbar-h 令牌）。 */
    .appbar { min-height: var(--appbar-h); padding: 0; }
    /* 🔴 手机上标题必须**左对齐**：上面那段「移动端中心对齐」把 .pagehead 全部居中了，
       而 AppBar 是「左返回 / 中标题 / 右图标」的三段式，居中会让标题与返回键贴在一起。 */
    .appbar-title { text-align: left; }
    .appbar-title h1 { text-align: left; }
    /* 🔴 第十六轮：这里原先有一条 `.appbar-sub--keep`（手机上把副标题放出来一行）。
       用户当轮明确要求后台页「改为后台（管理员名称），下方的描述直接砍掉」⇒
       admin.html 已把「当前管理员：xxx · 管理评审任务…」整行删除、管理员名并入 h1，
       该变体**再无消费者**，于是连同规则一起移除。
       留着不只是死代码：它会诱导下一个人继续往 AppBar 里塞第二行 ——
       而那正是这一轮要去掉的东西（真机 360px 上它被 nowrap + 省略号截成
       「当前管理员：admin · 管理…」，信息不全还白占一行高度）。 */

    .segmented { margin-bottom: var(--sp-2); }
    .seg-panel { scroll-margin-top: 64px; }

    /* 🔴 第十八轮：`analysis.html` 的顶部留白把「内容刚好一屏」的页面顶过了滚动线。
       实测（390×844、种子数据 1 条任务）：docScrollH=892 / innerH=844 ⇒ **只多 48px**，
       而这 48px 全是留白（`--sp-5`=16 顶部 + `--sp-3`=12 与卡片之间的 gap + 底部 `--sp-6`=20
       被上一屏吃掉的部分），用户滚下去**看不到任何新内容**，只有一条纯粹的视觉噪音滚动条。
       收口：顶部内边距折一档（`--sp-5`=16 → `--sp-4`=16 在 481~768 段同值，
       在 ≤480 段是 16 → 12），顶部省 0~4px；真正的大头在下面那条 `gap`。
       ⚠️ 这条**只降留白、不动任何控件高度**，不影响第十七轮「控件高度令牌唯一真相」的契约。 */
    .shell { padding-top: var(--sp-4); }

    /* 排名列表：手机换两行卡片式网格（表头隐藏，字段自带标签语义由顺序承担）。
       桌面那份「表头 + 行」的列定义保持不动 —— 两个断点共用同一份 DOM。 */
    .rank-head { display: none; }
    /* 列网格挂在 `.rank-body` 上（第十二轮拆的两层：外壳管定位/裁切，body 管列与位移） */
    .rank-body {
        grid-template-columns: 28px minmax(0, 1fr) auto 20px;
        grid-template-areas:
            "no title title score"
            "no meta  meta  arrow";
        gap: 4px 8px;
        /* 🔴 这里**不能**写 min-height:0：改成 0 等于把桌面的 64px 下限也一起取消，
           而手机恰恰是最需要「一行占满一屏宽度」的场景。
           第十七轮按用户「整体缩小」的要求 64 → **52**（排名行是列表项，不是按钮，
           不用跟 --ctl-h 那套走）。
           🔴 第十八轮再降到 44 —— 注意这一档**同时受 vw 基准影响**：
              52 × (13.65/16) = 44.4px @320、52 × 1 = 52px @375。
              降到 44 之后 @320 只有 37.5px。用户已明确「无需顾虑此前的设定」，
              而排名行是长列表项（不是孤立按钮），行数多、容错需求低于主操作按钮。 */
        min-height: 44px;
        padding: var(--sp-2) var(--sp-3);
    }
    /* 手机上左滑面板收窄到 64px：320px 上一屏只有 288px 可用，
       72px 的面板会把「排名 + 标题」压得太窄 */
    .rank-item { --swipe-w: 64px; }
    /* 标题允许两行（手机上截成一行的话，两篇同名文章会长得一模一样，
       点错了才知道——列表项的首要职责是「认出是哪一篇」） */
    .rank-title {
        display: -webkit-box;
        -webkit-line-clamp: 2;
        -webkit-box-orient: vertical;
        white-space: normal;
    }
    /* 「作者 · 评委 5 · σ」是辅助信息：手机降到 12px（用户规范里点名「评委 5」属辅助档）。
       桌面那一列仍是 14px —— 它是表格的一列，不是一行小字。 */
    .rank-meta { font-size: var(--fs-xs); }
    .rank-detail { padding: 0 var(--sp-3) var(--sp-3); }

    /* 看板在 320px 上仍是 4 列（72px 底宽 × 4 + 8×3 gap = 312 < 320-24）。
       数字 18px/700 是这个宽度下能放下的最大档，再大 「77.44」 就会折行。 */
    .kpi { padding: var(--sp-2) var(--sp-1); }

    /* ---------- 工具行：任务下拉 + 状态 ----------
       🔴 第十四轮**删除**了 `.tool-row--dock`（原先贴在榜单卡片下沿的吸底「生成链接」条）。
          删除理由（用户规范一「致命问题」）：它是 `position: sticky; bottom: 0`，
          在榜单卡片内部恒定压住最后几行 —— 实测 390px 上盖住第 8~10 名。
          现在整块搬进「导出 / 公示」的 Bottom Sheet，榜单区域不再有任何操作条。
          注意：它**没有**在 `_shot_mobile.cjs` 的「固定层遮挡」口径里 —— 那条口径只扫
          `position: fixed`，sticky 从来就没被量到 ⇒ 改前那一套度量全绿，是「量不出来」，
          不是「没问题」（这一轮为此专门补了 sticky 遮挡断言，见 R14 段）。
          `--shell-px` 令牌**保留**：它仍然是 `.shell` 左右内边距的唯一真相。 */
    .tool-row { justify-content: center; align-items: center; }
    /* 🔴 第十八轮：「表单控件高度 == 令牌」的**真正**收口。
       现象：`#topNInput`（`type="number"`、`.stepper-input`）声明 `height: var(--ctl-h-lg)`
             = 36px，实测 **38px**，且 320/360/375/390 四个视口**全都是 38**（令牌已是 px、
            不随 vw 缩放，本该四视口一致 —— 它确实一致，只是都**错**了）。
       ═══ 逐层排除（`_r18_diag8.cjs` 实测）═══
         · 不是 flex 拉伸：父链 `.stepper` / `.sheet-gen-row` 的 `align-items` 都是 `center`。
         · 不是行高：`getComputedStyle` 里 `line-height` 已是 `16px`（`line-height: 1` 生效），
           `padding` = 0、`border` = 1px×2、`box-sizing: border-box`。
         · 决定性对照：动态造一个**裸** `type="number"`（只有 `font-size:16px`、
           **任何套用站点样式的类都没加**）⇒ 实测 **38px**，加不加 `line-height: 1` 都是 38px。
       ⇒ 结论：**38px 是 Chromium 给单行文本控件（`type=number/text`…）的 UA 内在内容高**
         （16px 字 + 上下内边距/基线留白），它是**内在尺寸**，作者写 `height: 36px`
         也压不下去 —— 因为全局那条 `input:not(...) { min-height: var(--ctl-h-lg) }`
         给的是 **`min-height: 36px`**，而 `36px < 内在 38px` ⇒ max() 取 38。
       🔴 所以「只补 `line-height: 1`」是**无效**的（试过，仍是 38）。正解有两条，缺一不可：
         ① 在**本段**（≤768，也就是真正写 px 令牌的那一段）给表单控件一条**显式 `height`**
            —— 显式 `height` 参与 max() 计算为 36px；
         ② `min-height` 也保持 `var(--ctl-h-lg)`（36px）不变。
         两者同值 ⇒ 内在尺寸不再能"顶上来"，令牌成为唯一真相。
       ⚠️ 不要把这条写进组件段（`.stepper-input { height: var(--ctl-h-lg) }` 在 2101 行
          已经是这样了，但它被**全局 min-height 与 UA 内在尺寸的 max()** 越过）。
          落点必须是这里 —— 与产生 38px 的那条 `min-height` **同一段、同一组选择器**。
       ⚠️ 也**不能**改成 `height: 36px` 字面量：令牌是唯一真相，写死数字会立刻
          违反第十七轮的契约（R17 段逐条断言令牌值 + 实测高度）。 */
    input:not([type="checkbox"]):not([type="radio"]):not([type="file"]):not([type="range"]),
    select,
    textarea {
        height: var(--ctl-h-lg);
        min-height: var(--ctl-h-lg);
    }
    .stepper-input { width: 3rem; }

    /* 底部弹窗：手机上铺满宽度，圆角只留上面两角 / 安全区在下沿 */
    .sheet { border-radius: var(--r-md) var(--r-md) 0 0; }

    /* ==================================================================
     * 第十五轮（用户七条）
     * ================================================================== */

    /* ---------- 规范一：签发表单改两行 ----------
       改前：`.form-row` 是 `flex-wrap: wrap`，三个孩子（select 220px 起 / 数量框 88px /
       按钮）在 390px 内放不下 ⇒ 换行成**三行**：任务选、数量框、按钮。
       用户要的是「所属任务独占一行，下一行 `[签发数量][签发评委码]` 并排，1 : 2」。
       🔴 用 `flex: 1 1 0` / `flex: 2 1 0`（basis 为 0）而不是百分比：
          flex-basis 为 0 时宽度只按比例分**扣除 gap 之后**的剩余，
          写百分比会把三格加 gap 撑过 100% ⇒ 横向溢出（320px 上必现）。 */
    #stepCodes .form-row { row-gap: var(--sp-3); }
    #stepCodes .form-row > .grow { flex: 1 0 100%; }
    #stepCodes .form-row > div:not(.grow) { flex: 1 1 0; min-width: 0; }
    /* `.w-sm` 给数量框写死了 5.5rem，那个固定宽会让它挤在左边、右边留一大块空 */
    #stepCodes .form-row > div:not(.grow) > input { width: 100%; }
    #stepCodes .form-row > #generateCodeBtn { flex: 2 1 0; min-width: 0; }

    /* ---------- 规范二：任务标题防挤压 + 时间降为 11px 灰 ----------
       标题现在是 grid 第一列（`minmax(0, 1fr)`），**不加 nowrap 时长标题会自己折行**，
       整卡高度跟着参差。加 nowrap + ellipsis 是**守卫**：现有最长任务名 176px，
       而 361px 起第一列可用 215px ⇒ 实际不触发截断（R15 有一条「标题未被截断」盯着这一点，
       防的是「守卫顺手把本来放得下的标题也吃了」）。 */
    .table-wrap.table-tasks .task-name {
        display: block;          /* 行内元素上 text-overflow 不生效 */
        min-width: 0;
        white-space: nowrap;
        overflow: hidden;
        text-overflow: ellipsis;
    }
    /* 时间戳：用户规范二要 11px 灰字。字号走**唯一的有据例外档** `--fs-2xs`（见根令牌块），
       颜色**不跟规范二的 `#999`** —— 全库零颜色字面量是既有硬约束，改用 `--text-muted`。 */
    .table-wrap.table-tasks tbody td[data-label="时间"],
    .table-wrap.table-tasks tbody td.col-time {
        font-size: var(--fs-2xs);
        color: var(--text-muted);
    }

    /* ---------- 规范四：榜单标题与「导出 / 公示」同行 ----------
       改前的成因：`.action-buttons { width: 100% }`（本段更早处）+ `.bar { flex-wrap: wrap }`
       ⇒ 按钮组被挤到第二行。因为那条 100% 是给 admin/manager 的工具栏用的（那里按钮多、
       本来就该独占一行），这里**必须按卡片收窄**，不能改通用规则。 */
    #rankingCard > .bar { flex-wrap: nowrap; justify-content: space-between; }
    #rankingCard > .bar > .card-title { flex: 1 1 auto; min-width: 0; justify-content: flex-start; text-align: left; }
    #rankingCard > .bar > .action-buttons { width: auto; flex: 0 0 auto; margin-left: auto; }

    /* ---------- 规范七·2：「⋯」横向补到 44px ----------
       第十四轮拍板「三个按钮高度统一 40px」⇒ **高度不动**（R13/R14 各有断言钉着 40，
       见上方 `.table-wrap.table-tasks .row-actions .btn { min-height: 40px }` 的注释）。
       这一条只补**宽度**：320px 上 15% 那一格实测 37px，比 44 的触控宽少 7px。 */
    .table-wrap .row-actions .btn-more { min-width: 44px; }
}

/* 窄屏手机（320~480）：外壳留白再收紧一档，把宽度还给正文 */
@media (max-width: 480px) {
    :root {
        --sp-4: 12px;
        --sp-5: 16px;
        --sp-6: 20px;
        --shell-px: var(--sp-2);   /* 窄屏外壳左右只剩 8px，吸底条的负 margin 必须跟着缩 */
        /* 字号**不再**单独降档：≤768 段已经把 4 档压到 11/13/15/17，
           窄屏再往下塌会掉出「中文可读下限」（标题 15~16、正文 13）。
           原先这里写 `--fs-2xl: 1rem`（h1 再降 1px），第二轮取消 ——
           页标题是整页唯一的锚点，比正文高一档才有层级；宽度不够靠收紧留白解决，不靠缩字。 */
    }

    /* 🔴 第十八轮：`.shell` 的 `gap: var(--sp-3)`（本段 12px）在移动端是**多余**的 ——
       `.shell` 是 `flex column`，而移动端的各卡片/分区**自己**已经有 `margin-bottom`
       （见 `.segmented { margin-bottom: var(--sp-2) }` 等），两者叠加让「卡片间距」变成
       12 + 8 = 20px，比桌面还松。
       把 `gap` 归零，卡片间距完全交给各自的 margin 声明 —— 单一真相，且让每个组件的
       **内容边界**（而不是容器的排版参数）决定留白。
       🔴 **必须写在 ≤480 段**：≤768 段的 `gap: var(--sp-3)` 会被 ≤480 段同选择器的
          `.shell { padding: … gap: var(--sp-3) }` 再覆盖一次（后者在文件更后面）。
         第十八轮首版把这条写在 ≤768 段（2628 行）⇒ 在 390 视口上**完全无效**，
         实测页面高 892 一动不动。教训与「页内 <style> 排在 <link> 之后」同族：
         **同选择器的最后一条声明才是赢家**，写规则前先 grep 一遍全部断点。 */
    .shell { gap: 0; }

    .card { padding: var(--sp-3); }
    /* h1 与 481~768 段保持同一档（--fs-2xl），不再窄屏单独降级 */
    .pagehead h1 { font-size: var(--fs-2xl); }
    .pagehead .sub { font-size: var(--fs-xs); }

    /* 弹窗底部按钮改竖排：并排时每个只剩 ~80px，中文标签会被压成两行 */
    .modal-footer { flex-direction: column; align-items: stretch; }
    .modal-footer .btn { flex: none; width: 100%; }
    .modal-header, .modal-body { padding: var(--sp-3) var(--sp-4); }

    .grid--stats, .stats-grid { gap: var(--sp-2); }
    .stat-item { padding: var(--sp-3) var(--sp-2); }
    .stat-value { font-size: var(--fs-xl); }
    .code-item { flex: 1 1 100%; }
    .action-buttons { gap: var(--sp-1); }
    th, td { padding: 8px; }

    /* 320px 上「2 / 4 已完成」这种进度摘要会把步骤挤掉 ——
       该条的**唯一归属已移到 ≤768 段**（单排也自带那层信息，两个断点都该隐藏）。
       这里不再重复写一遍：同一件事写两处，改一处必然漏另一处。
       🔴 第十四轮：原先是 `padding: 0 8px`（2×2 网格里有富余）。
          改回单排后 320px 只剩 274px 分给四格，左右各 8px 的内边距就是 64px 的净损失
          ⇒ 收到 0，把宽度全部让给「签发评委码」这五个字。 */
    .stepnav-step { padding: 0; }
    /* 🔴 320px：圆点让位给文字。
       算一下就知道没有别的答案：内宽 304 − 条内边距 16 − 边框 2 = 286，四格各 68.5px；
       而「签发评委码」五个字在最小档 12px 下**本身就要 60px**，再放一个 16px 的圆点
       加 4px 间隙 = 80px > 68.5px ⇒ 必然省略号。
       用户规范三要的是「一排展示完全、用户不会看不清」—— 那么该让位的是圆点：
       它承载的「第几步 / 已完成」在 320px 上由 `.is-active` 的实心底与 `.is-done`
       的深色文字承担（两者都保留），而标签文字一个字都不能少。
       ⚠️ 只在 ≤480 生效：390px 上量到 356/356、零省略，圆点留着。 */
    .stepnav-dot { display: none; }
    [data-step].is-collapsed { padding: var(--sp-2) var(--sp-3); }
}

/* 尊重系统「减弱动态效果」 */
@media (prefers-reduced-motion: reduce) {
    *, *::before, *::after {
        animation-duration: 0.01ms !important;
        animation-iteration-count: 1 !important;
        transition-duration: 0.01ms !important;
        scroll-behavior: auto !important;
    }
}

/* ---------- 滚动条 ---------- */
::-webkit-scrollbar { width: 9px; height: 9px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--scroll-thumb); border-radius: var(--r-pill); }

/* ============================================================
   安全区（刘海 / 圆角 / 底部横条）—— 手机竖屏的顶部与底部
   ------------------------------------------------------------
   横屏刘海的**左右** inset 由 body 承担（见「重置与骨架」段）；
   这里只补竖向的两处：
     · 顶部 —— 吸顶步骤条、登录页那条 fixed 主题按钮带，不能被状态栏 / 刘海压住；
     · 底部 —— 页面最后一行要给 home indicator 横条让位。

   🔴 这段必须放在**文件最后**，不能塞进 ≤768px 那段：
       @media (max-width:480px) 在它之后又重新写了 `.shell` / `.shell--narrow` 的简写，
       同优先级下后写的赢 ⇒ 放在前面会被静默覆盖，只有 481~768 这一段生效、窄屏全失效
       （实测就是「注入 40px 假 inset 后，390px 上 shell 底边纹丝不动，768px 上却跟着变」）。
       这与 `theme.css` 的 <link> 排在页面内联 <style> 之前是同一类坑：**顺序决定胜负**。

   用 max() 而不是直接相加：没有刘海时 env() 为 0，取到的就是本来的留白 ⇒ 桌面逐像素不变。
   ============================================================ */
@media (max-width: 768px) {
    .shell { padding-bottom: max(var(--sp-6), var(--safe-b)); }
    .stepnav { padding-top: max(var(--sp-1), var(--safe-t)); }
    /* 第十二轮的吸顶条：AppBar 不吸顶（滚走腾出空间），只有分段控制器吸住。
       它的实色底在组件段，这里只补「不被状态栏 / 刘海压住」。 */
    .segmented { padding-top: max(4px, var(--safe-t)); }
    /* 底部弹窗自己扛安全区（见组件段 padding 的 max(--sp-3, --safe-b)），
       这里只需保证它不被 home indicator 盖住时能滚动到底 */
    .sheet { max-height: calc(80vh - var(--safe-t)); }
    /* --sp-4 在 ≤480 段已降到 12px，所以这一条同时覆盖 481~768(16+56) 与 ≤480(12+56)，
       不必在两个断点里各写一遍。 */
    .shell--narrow { padding-top: calc(var(--sp-4) + 56px + var(--safe-t)); }
}

/* ============================================================
   320px 级窄屏：AppBar 的标题必须降一档
   ------------------------------------------------------------
   实测（`_shot_mobile.cjs`，se320 视口）：「数据分析系统」在 AppBar 里 108px > 可用 104px，
   被省略号吃掉一个字 ⇒ 判红「裁字×1」。成因是这一行要塞下
   「返回 44 + 标题 + 三个图标 44×3 + 4 个间隙」，320px 上留给标题的只剩 104px。
    18px × 6 字 = 108px，改用 16px（--fs-lg，**已在令牌档内**）即 96px，完整显示。
   🔴 只降标题一档，不动 `--fs-xs~2xl` 的阶梯，档位总数不变（仍 ≤4 档）。 */
@media (max-width: 360px) {
    .appbar-title h1 { font-size: var(--fs-lg); }    /* 任务卡片：≤360px 上「标题」不再与「时间」同行。
       算一遍就知道**没有第三条路**（`_shot_mobile.cjs` se320 判红「裁字×6」）：
         卡内宽 264；最长的任务名「暑期散文大赛（已结束）」在 16px 下量到 176px，
         时间「2026-10-05 15:30」94px，加 8px 间距 = 278 > 264 ⇒ 标题必然被省略号吃掉。
         360px 上刚好顶满（可用 176 / 实宽 176，**零余量**），再长一个字同样会截断。
       两条备选都试算过、都更差：
         · 标题降到 14px —— 154 + 94 + 8 = 256 能塞下，但卡片主标题就掉到正文档，
           而标题是整张卡最重要的元素，不值；
         · 把「时间」并进下面那行合并字 —— 合并字本身已经 248px，换掉「篇数」再放时间
           要 331px > 264，放不下。
       ⇒ 按用户规范二③的第三个选项（时间不在卡片里占位）处理：
         **≤360 起卡片不再显示时间**，标题独占整行（反而更容得下长任务名）。
         时间仍在桌面表（≥769）与 361~768 的卡片上，没有丢数据，只是不在 320 的卡面上。
       ⚠️ 只改列定位、不写 `grid-row` 行号：行号写死会在隐藏的格子上留下空轨道（见 ≤768 段注释）。 */
    .table-wrap.table-tasks tbody tr:not(.new-row) td[data-label="任务名称"] { grid-column: 1 / -1; }
    .table-wrap.table-tasks tbody tr:not(.new-row) td[data-label="时间"] { display: none; }
}
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
