/*
 * 壳的样式。**这里不许出现任何跟版式有关的东西** ——
 * 版式全部来自 web/assets/pages/<slug>.css（由稿子机械生成）。
 * 这个文件只解决「稿子的画布怎么落到真机视口上」这一件事。
 *
 * 为什么不复用 web/assets/fit.js：那是**看稿专用**的适配层，
 * 它把整屏 393×852 等比缩到窗口里 —— 稿子是给人并排比对的，
 * 缩完能一眼看全。真机不能这么干：稿子画布常常比手机屏高得多
 * （纠错管理 393×1388，刷题页 393×852 但内容会随题目变长），
 * 等比缩下去字会小到看不清。真机要的是：
 *   · 横向：铺满视口（稿子的 393 宽 → 100%）
 *   · 纵向：内容自然流，页面自己滚
 * 所以这里只改「画布盒子」，不动里面任何一条布局规则。
 */

html, body {
  margin: 0;
  padding: 0;
  background: var(--cl-bg-page, #ffffff);
  -webkit-text-size-adjust: 100%;
}

/* ══ 2026-10-18 用户点名「APP 前端用户端的底色改成白色」═════════════════════
 * 底色真身是令牌 `--cl-bg-page`，原来 #FAFAFF（带一丁点紫的近白）。
 * ⚠ 不去改 `web/assets/tokens.css`：那份第一行就写着「由 tools/build/gen.mjs
 *   生成，勿手改」，而且 app 和 adminx 共用它 —— 动它会把管理后台一起带白。
 * ⚠ 选择器必须比 `:root` 强：view.js 是把 tokens.css 插在 app.css **前面**
 *   的（ensureCss 的 before 参数），同特异性靠后胜出，所以 `:root` 也行；
 *   但 `[data-theme="暗色"]` 那一块特异性同为 (0,1,0)，用 `:not()` 甩开它，
 *   免得暗色模式被这条一起拉成白底。
 * 页面里凡是写 `var(--cl-bg-page, …)` 的地方（页根 / 吸顶筛选条兜底）都会
 * 跟着变白，不用一个个找。 */
:root:not([data-theme="暗色"]) { --cl-bg-page: #ffffff; }

/* ★ 2026-10-17：挡掉 Chrome 自己那条「下拉刷新」。
   我们自己在 app/pull.js 里做了下拉跟手，浏览器那条会抢在前面 ——
   用户的动作变成「刷新整个页面」，加载完又回到第一屏、状态全丢。
   `contain` 只关掉「overscroll 传给祖先」这一件事，容器内部的滚动一点不受影响。 */
html, body {
  overscroll-behavior-y: contain;
}

/*
 * 触摸高亮 —— 点一下按钮冒出一块**蓝色**，看着像被选中了。
 * 那不是我们画的：全站样式（app.css + web/assets/**）里一条 :active / :focus /
 * tap-highlight 都没有，量出来这一屏每个节点都带着 UA 的默认值
 * rgba(51, 181, 229, 0.4)（Blink 在 Android 上的默认触摸高亮色 #33B5E5 四成透明度），
 * 首页 97 个、题库详情 62 个、我的 79 个 —— 凡是能点的都盖。
 * 它盖的是**整个元素矩形**，跟稿子画的版式对不上（卡片、圆角胶囊上尤其明显），
 * 连着点几下还会留一块像「选中」的蓝底，所以在应用这一层关掉。
 * 这条属性是继承的，写在 html 上里面每一个节点都跟着走；稿子那边一个字没动。
 * 量法见 tools/build/probe-tap.py。
 */
html {
  -webkit-tap-highlight-color: transparent;
}

/*
 * 按下态 —— 上面那条把**系统那层**高亮关掉了，这一条是把**自己这层**补上。
 * 稿子的动效表里「按下 120ms scale(.97)」（notes/10 §五），数值照抄，没另定。
 *
 * ★★ 2026-10-09 用户三连「**不移动，不移动，不移动**」（要的是按下不许动几何）：
 *    按下反馈从 `scale: .97` 改成**纯透明度**（`opacity: .78`），几何彻底退出按下态。
 *    这推翻了稿子动效表里的 scale(.97) —— 用户偏好 > 稿子。全仓同类（后台
 *    `.ax-btn/.ax-keybtn:active` 的 translateY(1px)、`.ae-mdt` 的 translateY(1px)）同批摘掉。
 *
 * 记号怎么来的（`data-press=""` = 可按，`data-press="down"` = 正按着）见 view.js
 * 那两段注释：接线时自动打记号，按下/抬手由 pointerdown/pointerup 显式挂。
 *
 * ★★ 2026-09-29（第五报）用户：「**我说的是，点按动画非弹窗加载，按钮的，这些容易卡顿**」。
 *    真触摸逐帧量过（`tools/build/probe-press.py`），一次点按的真实时间线是：
 *        手指落下 → 65ms 抬手
 *        `data-press="down"` 在抬手那一帧就摘掉了（机关本身没问题，精确到帧）
 *        **但 Chrome 紧接着把 `:active` 点亮，攥住约 150ms 才松**
 *        （那是它在 touchend 之后补的一串合成鼠标事件）
 *        再叠上这里的 `transition: scale 120ms` 慢慢滑回 1
 *      ⇒ 手指 108ms 就走了，按钮 **325ms** 才复原 ——
 *        **手指离开之后还有六分之一秒保持按下的样子**，读起来就是「卡」。
 *    第二个病：快按（<120ms）时缩放根本走不到 .97（实测停在 0.9739）就被抬手打断，
 *    去是半路、回也是半路。
 *
 * ★ 所以按下态从「一小段动画」改回**瞬时状态**：
 *   ① 这里不再有 `transition` —— 按下即 .97、抬手即复原，跟手到帧；
 *   ② 按下只认 `[data-press="down"]` **一条**机制，不再写 `:active`。
 *      两个东西同时声称代表「正按着」，正是这次出问题的根源。
 *      （鼠标也走这条：`pointerdown` 不分指针类型，鼠标按下同样会打记号。）
 *   ③ 触摸上 `:active` 整体不许改几何 —— 见下面 `*:active` 那条，那是给全仓兜的底。
 *
 * ⚠ scale/transform 都不用了（2026-10-09「不移动」）：按下只动透明度，
 *   跟节点自己的位移（抽屉 translateY、换题 translateX）互不相扰。
 * ⚠ `touch-action: manipulation` 顺手去掉「等双击」那一段。
 * ⚠ `prefers-reduced-motion: reduce` 下连透明度渐变也不做（瞬时）——
 *   跟 notes/14 的 15.7 里给滑动定的那条规矩同源（这类用户要的是「别动」）。
 */
[data-press] {
  touch-action: manipulation;
}
[data-press="down"] { opacity: .78; }
@media (prefers-reduced-motion: reduce) {
  [data-press="down"] { opacity: .72; }
}

/*
 * ★ 触摸设备上 `:active` **不许改几何**。
 *
 * 为什么值得一条全仓兜底：`any-pointer: coarse` 的机器上，Chrome 的 `:active`
 * 时机不在我们手里 —— 手指按住时它不给，**抬手之后才点亮、还攥住约 150ms**
 * （实测见上，`tools/build/probe-press.py`）。谁拿它驱动 `scale`，
 * 谁的按钮就会在手指离开之后继续「按着」六分之一秒。
 * ⇒ **几何只许由 JS 的 `[data-press="down"]` 驱动**（pointerdown/pointerup 精确到帧）。
 *   `:active` 在触摸上只准改颜色 —— 背景色/文字色不受这条影响，照常生效。
 *
 * ⚠ 为什么敢写 `*`：全仓只有 `:active` 这几处声明过 `scale`
 *   （`.gaozi-act` / `.gaozi-act--danger` / `.ac-btn--primary|ghost|text|danger` /
 *   `.ae-mdt` / 两处后台危险键），**没有任何元素平时靠 `scale` 摆造型** ——
 *   `grep -n "scale" app/app.css` 可验。所以这条只会掐掉那些幽灵按下态，
 *   不会把谁原本的样子改掉。
 * ⚠ 用 `any-pointer` 而不是 `pointer`：`pointer` 只看主指针，
 *   触屏笔记本（主指针是鼠标）上用手指点照样中这个招。
 *
 * ★★ 2026-09-29（第六报）用户：「**所有可点按的地方按下去就算不松手，动画也会回弹**」。
 *    上面那条兜底写得太宽了 —— 它是 `*:active`，于是**只要 `:active` 亮着，谁也改不了几何**，
 *    连我们自己挂的 `[data-press="down"] { scale: .97 }` 一起被抹平。
 *    真机上这两件事是**同时**成立的：手指按下 → 我们打 `data-press="down"`
 *    → Chrome 紧接着把 `:active` 点亮（真机按住期间会亮；这一点模拟触摸量不出来，
 *    见下面的量法）⇒ `.97` 刚生效就被顶回 1.0000，**手还按着，按钮自己弹回去了**。
 *    实测（`Input.dispatchMouseEvent` 按住 700ms，把「记号 down + :active 亮」一起造出来）：
 *        t=65.8ms  记号='down'  :active=True   **缩放 1.0000** ← 按下态整个没了
 *    改法就是把它收窄一格：**记号说「正按着」的时候，`:active` 不许插手** ——
 *    这本来就是这段注释开头那句「几何只许由 JS 的 `[data-press="down"]` 驱动」的字面意思，
 *    原来那条 `*:active` 把「驱动」做成了「一票否决」。
 *    ⇒ `scale` 那点反馈回来了（触屏笔记本上用鼠标点后台按钮也不再丢了，
 *      原来注释里写的那个「有意取舍」现在不用取舍了）。
 *    ⚠ 抬手之后的幽灵 `:active` 照样被压住 —— 那时记号已经摘掉，
 *      这条规则对它是生效的，所以「手指走了还按着 150ms」那个老毛病不会回来。
 * ★ 2026-09-29（第七报）把「几何只有一个说话的人」这件事收干净了。
 *   收窄之后暴露出另一处老账：㉑ 里那几枚后台按钮（`.gaozi-act` / `.ac-btn--*`）
 *   当初自己写着 `:active { scale: .96 / .98 }` + `transition: scale 100ms`，
 *   被上面那条 `*:active` 一直压着没显形 —— 收窄之后它们**复活了**，
 *   于是同一个 `scale` 又变成两处声明在抢（谁后写谁赢），
 *   而且按下去是**一段 100ms 的动画**（用户第五报点名的那个「卡」）。
 *   ⇒ 那 6 处 `:active` 上的 `scale` 与两条 `transition` 里的 `scale 100ms` 全部摘掉，
 *     只留底色（深一档那套取值法一个字没动）。从此**几何只有 `[data-press="down"]` 一个作者**，
 *     全部瞬时、跟手到帧。
 *   实测（按住 900ms，`--sel=.gaozi-act`）：改前鼠标按住停 **0.9600**（它自己那条赢了），
 *   改后两条路都停 **0.9700**。
 * ⚠ 上面那段「全仓只有 :active 这几处声明过 scale」在这一报之后**已经不成立**了 ——
 *   现在 app.css 里 `:active` 只改颜色。这条兜底留着是给**别处**兜的：
 *   稿子自带 CSS、`web/assets/**`、以后有人新写的规则，谁再拿 `:active` 驱动几何都拦得住。
 *
 * 量法：按住不放逐帧读缩放（记号 + `:active` 一起打），见 `tools/build/probe-press.py`
 *   的 `--hold` 一档；不这么做的话，模拟触摸上这条路永远是绿的（Chrome 按住期间不给 `:active`）。
 */
@media (any-pointer: coarse) {
  *:active:not([data-press="down"]) { scale: none !important; }
}

#app { display: flex; justify-content: center; }

/*
 * 换屏时的垫场层 —— 新屏还没准备好（控制器拿真数据去填之前会把它藏起来）时，
 * 把**上一屏**原样浮在这儿，等新屏亮相再撤。机制和三个设计决定写在 app.js
 * 的 mountScreen 上面，这里只说样式。
 *
 * ⚠ 这里**不许有任何跟版式有关的东西** —— 它里面装的是上一屏那棵原样的 DOM，
 *   外面这层只负责「铺满视口、居中、别挡事件」。多写一条就等于是我又另造了一套版式，
 *   而且浮层里的东西跟原来的位置对不上，用户会看到上一屏「跳」了一下 ——
 *   那比空白还难受。宽度的对齐靠的是同一套：.screen 自己写着
 *   `width:100%; max-width:var(--design-w,393px)`，父层只要是 flex + 居中就一致。
 *
 * 挂 body 而不是 #app：#app 是 flex 容器，多一个孩子会跟新屏并排挤在一起。
 * pointer-events:none：上一屏是**冻住的**（它的控制器已经卸了），
 *   让它接点击等于让用户点到一堆接不上的东西，那不如让他点不着。
 */
.screen-cover {
  position: fixed;
  inset: 0;
  z-index: 8000;          /* 高于 #app；低于 toast(9999) 和 devbar(9000) */
  display: flex;
  justify-content: center;
  align-items: center;
  pointer-events: none;
}

/* 垫场层里**占行的只有上一屏**。别的孩子（加载胶囊）一律 absolute ——
   2026-10-11 加胶囊时它没带 position，成了 flex 行里并排的第二项，
   把冻住的上一屏从 393 挤窄到 ~308.8（实测 t38 量具：垫场里 [0,0,308.8,852]
   vs #app 里 [0,0,393,852]）—— 屏内居中的内容整体左移 ~42px，
   交叉淡出时就是用户报的「上一页残影 + 偏移闪帧」。
   flex:none 是双保险：以后谁再往这里塞一个没 absolute 的孩子，
   上一屏也不会再被挤窄（只是并排多一截，仍看得出来是布局错了）。 */
.screen-cover > .screen { flex: none; }

/* 换屏垫场上的「加载中」胶囊（2026-10-11，见 app.js showCover 那段）——
   300ms 后才淡入：快换屏全程无感，真慢了才给反馈。
   ⚠ **absolute + margin:auto 居中**，不进 flex 行（见上面 flex:none 那段：
     并排会把上一屏挤窄）。不用 transform 居中 —— 避免给以后的动画留坑。
   ⚠ 2026-10-10 用户点名「tabbar 的加载态」—— 当时定 80ms（怕「按下去没反应」）。
   ★ 2026-10-17 改回 300ms，理由是**量出来的**：热切换（首页→题库，稿子和控制器
     都在内存里）从点下去到垫场层撤完实测 **127ms**，而 80ms 的阈值意味着
     **每一次热切换都必然闪一下这枚胶囊**（80ms 时它出现、127ms 时它随垫场层一起
     消失）。用户看到的不是「加载反馈」，是「点一下就在转圈 → 又没了」——
     这正是他这次报的「像重新加载一遍」。热切换快到不需要反馈，就别给假反馈；
     300ms 只有真的慢（弱网、冷进一屏）才会出现，那时它才有意义。 */
.screen-cover .sc-load {
  position: absolute;
  inset: 0;
  margin: auto;
  width: max-content;
  height: max-content;
  padding: 10px 22px;
  border-radius: 999px;
  background: rgba(15, 23, 42, 0.78);
  color: #fff;
  font-size: 13px;
  letter-spacing: 0.03em;
  box-shadow: 0 10px 28px rgba(15, 23, 42, 0.28);
  opacity: 0;
  animation: sc-load-in 120ms ease 300ms forwards, sc-load-pulse 1.2s ease-in-out 420ms infinite;
}
@keyframes sc-load-in { to { opacity: 1; } }
@keyframes sc-load-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.72; } }

/*
 * 撤垫场层时**淡出**（2026-09-27 加；2026-10-10 收到 70ms；2026-10-17 定在 160ms）。
 *
 * 这一层里装的是上一屏原样的 DOM，撤它的那一帧新屏已经在底下了 ——
 * 原来直接 `remove()` 就是一次硬切，用户读作「卡一下然后跳过去」。
 *
 * ★ 2026-10-17 为什么从 70 改到 160（这一版是量过再定的，不是凭手感）：
 *   逐帧时间线（tmp/diag-swap.py，CPU 降速 4x）量到热切换的真实构成是
 *      +  0ms  点下去
 *      + 60ms  新屏已经挂好、visibility 也亮了（数据早就回来了）
 *      +127ms  垫场层撤完 —— 用户就是等到这里才真正看见新屏
 *   也就是说：**前面 60ms 是主线程在重建整屏（样式重算 + 布局），后面是垫场层**。
 *   70ms 的淡出夹在这中间，读起来是「先静止 60ms，再快闪一下」——
 *   快闪比慢淡**更像卡**：眼睛看到的是「跳」，不是「换」。
 *   160ms 是把这一下摊开成一记能看清的溶解：点下去 → 旧屏开始化 → 新屏显影，
 *   中间没有「一动不动」的那一段。它比 70 慢，但**感知上更快**，因为
 *   第一帧就有变化了。
 *
 * ⚠ 只动 `opacity`：动 `transform`/`left` 会让它每一帧重排，而这一层里
 *   装着整屏 DOM，重排的代价正好落在「用户最不想等」的那一瞬间。
 * ⚠ 它不能有 `transition` 之外的进入态：这一层是**被加类名然后被摘掉**的，
 *   类名一挂就开始淡，没有第二次机会（超时/接管那两条路走的是硬撤，见 app.js）。
 * ⚠ 这个时长必须跟 app.js 的 `COVER_FADE_MS` **同一个数**：那边靠它定时 remove。
 *   两边不一致的表现是「淡完了元素还挂着」或「没淡完就被拿走」（后者又变硬切）。
 */
.screen-cover--out {
  opacity: 0;
  /* ★ 曲线 2026-10-17 也换了。原来跟着「按下反馈」用 `cubic-bezier(.2,0,0,1)` ——
     那是**入场**曲线（起步极快、尾巴极长），拿来淡出等于「啪一下没了，然后
     剩下 100ms 什么都看不出来」。逐帧读出来：160ms 的过渡在 **68ms 时 opacity
     已经到 0.03**，肉眼跟 70ms 的版本没有区别 —— 时长白加了。
     淡出用对称的 ease-in-out：透明度均匀往下走，160ms 每一段都看得见。 */
  transition: opacity 90ms cubic-bezier(.4, 0, .6, 1);
}
@media (prefers-reduced-motion: reduce) {
  /* 这一类用户要的是「别动」。硬撤就是原来的行为，不给过渡。 */
  .screen-cover--out { transition: none; opacity: 1; }
}

/*
 * 管理后台的侧边导航抽屉。机制和「稿子画在哪儿」写在 app/admin-nav.js 顶上，
 * 这里只说两件事：**外层这圈壳**，和**那枚汉堡的触控目标**。
 *
 * ⚠ 抽屉里的**内容**（220px 宽的白底侧边栏、九项、底部头像+退出）一条样式都不在这
 *   儿 —— 那整块是从稿子里 clone 过来的（含它自己的 CSS）。这里多写一条，
 *   就等于在代码里又抄了一份版式，稿子改了它不跟，而且不报错。
 *
 * z-index 分层：垫场层 8000（换屏用，铺满且不吃事件）< 抽屉 8100 < devbar 9000
 *   < toast 9999。抽屉必须在垫场层之上 —— 否则用户从后台某页点菜单跳转、
 *   新页在取数据时垫场层浮起来，会把刚打开的抽屉盖住。
 */
.admin-nav {
  position: fixed;
  inset: 0;
  z-index: 8100;
  pointer-events: none;   /* 交给下面两层各自收：遮罩收点击，面板收点击 */
}
.admin-nav-mask {
  position: absolute;
  inset: 0;
  pointer-events: auto;
  /* 遮罩照稿子的惯例：底色用正文色 + 45% 透明（稿子 9-38 的「遮罩」就这个写法）。
     ⚠ 不用 --op-mask：那个令牌生成时被塞了 px 单位（0.44999998807907104px），
       opacity 拿到它等于 0，遮罩会变成完全透明。 */
  background: var(--cl-text-primary, #1c1b22);
  opacity: 0;
  transition: opacity 220ms ease;   /* 跟着抽屉一起淡入，不然抽屉还在滑、后面已经暗了 */
}
.admin-nav-panel {
  position: absolute;
  top: 0;
  bottom: 0;
  left: 0;
  display: flex;          /* 让稿子那块侧边栏的 align-self:stretch 撑满高度 */
  pointer-events: auto;
  overflow: hidden;
  /* 滑入。只动 transform，不动 left/width —— 只动 transform 才走合成层，
     在手机上不掉帧；动 left 每一帧都要重排。 */
  transform: translateX(-100%);
  transition: transform 220ms cubic-bezier(0.22, 0.61, 0.36, 1);
}
.admin-nav.is-open .admin-nav-panel { transform: translateX(0); }
.admin-nav.is-open .admin-nav-mask { opacity: 0.45; }

@media (prefers-reduced-motion: reduce) {
  .admin-nav-panel, .admin-nav-mask { transition: none; }
}

/*
 * 汉堡那枚图标的触控目标。
 *
 * 稿子把 `Icon/Menu` 画成 24×24，手指点不准（HIG 要 44）。这里用一层透明的
 * ::after 往四周长出去，**不改图标自己的尺寸** —— 改尺寸会把它在顶栏里的
 * 位置和行高一起动掉，那就不叫还原稿子了。伪元素不参与布局，长了也不挤别人。
 */
.admin-nav-trigger { position: relative; }
.admin-nav-trigger::after {
  content: "";
  position: absolute;
  top: 50%;
  left: 50%;
  width: var(--sz-tap-min, 44px);
  height: var(--sz-tap-min, 44px);
  transform: translate(-50%, -50%);
}

/*
 * 稿子画布。gen.mjs 给根节点写了固定 width/height（393×1388 这种），
 * 那是**设计尺寸**，不是渲染尺寸。真机上要这么翻：
 *
 *   宽 393 → 铺满视口
 *   高 1388 → 改成视口高（100dvh）
 *
 * 高度这条是关键。设计稿那张画布是一张「照片」，它把当时那 3 张卡正好装进
 * 1388 里；真数据有 5 条、20 条，高度必须重新分配。所以：
 *   根节点 = 视口高，保持 overflow:hidden（跟稿子一样，不许整页滚）
 *   内容区 = 内部滚（见 [data-scroll]）—— 这也是稿子自己的意思：
 *            它给内容区写了 flex:1，语义就是「顶栏之外的剩余空间」，只是
 *            在固定画布下那点剩余空间不需要滚而已。
 *
 * 这样顶栏/状态栏会自然钉在顶上，只滚内容 —— 比整页滚更贴手机。
 * 宽度是唯一一处「值变了」的换算，其余布局数字全部原样。
 */
.screen {
  width: 100%;
  max-width: var(--design-w, 393px);
  overflow: hidden;
  transform: none !important;   /* 覆盖 fit.js 的缩放；真机不缩放 */
  /* 浮起来的 TabBar（见 ⑭-b）钉在这一层的底边。必须是这儿不是页根：
     桌面上 .screen 只是居中那一列，挂在页根下面胶囊会按整扇窗撑开。 */
  position: relative;
}

/* 稿子根节点：把设计高度换成视口高。js 里 view.js 的 adaptCanvas() 也会设一遍，
   这里是兜底，免得 js 没跑起来时画布高度还是 1388。
 *
 * ⚠ 浮层（Toast / 答题卡抽屉 / 纠错弹窗 / 遮罩）也是 .screen 的直接子元素 ——
 *   控制器把它们 appendChild 到 root 上。但它们是**浮层不是板块**：
 *   拿「撑满视口」去量它们，Toast 会被拉成 280×852 的整屏方块（实测 y=-96、
 *   底部 756，把整个页面盖住），抽屉和弹窗同理。所以这里把浮层排除掉 ——
 *   排除之后它们各自的 height 回到 auto / 自己的规则，卡片本身多大就多大。
 *   加浮层时记得同步这里的名单（类名统一以 -gaozi 结尾，遮罩是稿子的图层名）。
 *   2026-10-11：`.guest-float`（访客登录浮卡，guest.js）也进了名单 ——
 *   它是 `.screen` 的直接子节点、靠 bottom:25% 定位，被这条拉成 100dvh
 *   就会从「中下方一张卡」变成「一块盖满屏的透明板」。 */
.screen:not([data-ds="xq"]) > *:not(.toast-gaozi):not(.drawer-gaozi):not(.dialog-gaozi):not([data-name="遮罩"]):not([data-role="overlay"]):not(.guest-float) {
  height: 100dvh !important;
  min-height: 0 !important;
}

/* ══ ★ 2026-10-05 第五十二轮：**桌面档稿子**（`data-ds="xq"`，1440×900）══
 *
 * 上面那一条「根节点 = 视口高」是**手机档**的适配：稿子画布是 393×852，
 * 真机上把 852 换成视口高、让内容区内部滚。新稿那 27 屏不是这个形态 ——
 * 它们是**桌面档**：稿子根节点自己声明 `1440×900`，而里面 131 个子节点
 * 全是 `position:absolute` + 显式坐标（`layout:none`）。
 *
 * ── 为什么不能沿用手机那套 ──────────────────────────────────────
 *   ① 撑成 `100dvh` 对绝对定位的子节点**没有任何作用** —— 子节点不跟着长，
 *      只是根节点自己变高，底下多出一片空白；
 *   ② 反过来，如果让子节点 `height:100%`，240×900 的侧栏会跟着窗口变成
 *      240×1080，而稿子里那张表还钉在 y=136 —— 侧栏比内容长，视觉上「多一截」。
 *
 * ── 这里选的是什么（第五十二轮定的，第五十四轮改过一次）──────────
 * **一张 1440×900 的纸**，四周不拉伸、内部一个坐标不改 ——「还原」的全部
 * 意思就是不发明。
 * 但它**整体等比缩放**到刚好装进窗口（`zoom`，见下面那一条）：1440 是稿子的
 * 坐标系，不是「必须按 1:1 像素呈现」的尺寸。
 *
 * ⚠ 为什么不做「侧栏拉伸 + 顶栏跟宽」那种自适应：那是**设计决定**，不是还原 ——
 *   稿子只画了 1440 这一档，其余档位长什么样得设计师说了算。而且 27 屏每屏的
 *   表格列宽都是写死的，拉宽之后列会挤在左边、右边空一大片。
 *   ⇒ **整体等比缩放**是唯一既「铺满」又「一个坐标都不改」的做法：
 *     版式的**比例**全保真（列宽、行高、间距的相对关系一个不差），
 *     变的只有「一个设计像素等于几个屏幕像素」。
 * ⚠ `data-ds="xq"` 是 gen.mjs 发在**新稿屏根节点**上的令牌作用域标记
 *   （见 gen.mjs 的 dsAttr），旧稿一个都没有 —— 所以下面每一条都只在
 *   新稿那 27 屏上生效，手机档 97 屏一个像素都碰不到。 */
.screen[data-ds="xq"] {
  max-width: none;
  /* ══ ★★ 2026-10-08 第五十八轮：纸**跟着窗口长大**（弹性，不再是一张定尺的纸）═══
   * 用户：「管理后台页面太别扭了，都是写死的数据，一点都不弹性，没有任何自适应」。
   *
   * 原来是 `width: var(--design-w)` —— 1440 写死。窗口比 1440 宽的时候，
   * `applyDeskScale()` 的 `contain` 缩放只把纸**放大**（zoom 无上限：2560 的窗口
   * zoom=1.6，字号 16 显示成 25.6、侧栏 240 变 514，版式一个像素都不重排），
   * 或者缩放被高度卡住时右边空一圈（第五十七轮只把那一圈铺成了背景 ——
   * **背景满了，内容没满**，看着还是「一块定尺寸的内容浮在满屏背景上」）。
   *
   * 现在两件事一起做：
   *   ① `admin-desk.js` 给 zoom 加**上限 1**（只缩小、不放大 ⇒ 字号恒为设计值）；
   *   ② 这里让纸的**局部尺寸** = 视口 ÷ zoom（除系数是因为这条规则在 `zoom`
   *      子树里生效：写 `100vw` 会**再被缩一次** —— 跟遮罩那条
   *      `calc(100vh / var(--xq-zoom))` 是同一个道理，同一个变量）。
   * ⇒ 纸永远恰好铺满视口，`--xq-pad-x/y` 归 0，第五十七轮的 ring 规则自动退化。
   *
   * ⚠ `max(…)` 的下限是稿子的设计尺寸：窗口比稿子**小**的时候（1024 宽的电脑）
   *   仍然先缩放（zoom<1）再按视口铺开，纸的局部宽 = 100vw/zoom ≥ design-w
   *   —— 所以正常情况下左半边那个 max 项从不生效，留着它只是兜底
   *   （js 没跑起来、`--xq-zoom` 还是 1 时，行为跟旧版逐字相同）。
   * ⚠ 高度同理：1920×1080 的窗口纸高 1080，稿子内容画到 900，下面空 180
   *   —— 那圈是纸自己的底（`--xq-paper-bg`），侧栏的
   *   `height:100%` 与 ring 规则会一起铺过去，不会露出 body 的浅底。 */
  width: max(var(--design-w, 1440px), calc(100vw / var(--xq-zoom, 1)));
  height: max(var(--design-h, 900px), calc(100vh / var(--xq-zoom, 1)));
  /* 纸自己的底 = 稿子内容区那一层（`--cl-bg-secondary`，稿子根自己写着）。
     ⚠ 第五十二轮这里写的是 `--cl-bg-page`（#FAFAFF），**跟内容区差一档**
       （内容区是 #F1F3F5）—— 当时纸外面没人看得到，所以看不出来；
       第五十七轮要把窗外的留白铺成纸的底，这一档之差就露馅了 ⇒ 改成量出来的。
     ⚠ 这一层其实被稿子根完全盖住（两者同尺寸），留着只是兜底。 */
  background: var(--xq-paper-bg, var(--cl-bg-page));
  flex: 0 0 auto;
  /* ══ ★★ 2026-10-06 第五十四轮：把纸**铺满窗口** ══════════════════════════
   * 用户报「为什么就这一块显示」（截图：1920 的窗口里纸只占中间 1440）。
   * 缩放系数由 `admin-desk.js` 的 `applyDeskScale()` 算好写在根节点的
   * **内联 `zoom`** 上；这里给 1 只是兜底（js 没跑起来时按原稿 1:1 摆）。
   *
   * ⚠ 为什么用 `zoom` 不用 `transform: scale`：
   *   ① `.screen` 上有一条 `transform: none !important`（覆盖 fit.js 的缩放，
   *      真机不缩放，见上面 `.screen` 那一段）—— 它会**静默吃掉**这里写的
   *      transform，改了没效果也不报错；
   *   ② `transform` **不改布局尺寸**：父层 `#app` 仍然以为是 1440×900，
   *      缩小之后右侧/下方留一圈空白、放大之后又被裁掉，居中还得再补一层
   *      `translate(-50%,-50%)` 和负边距；
   *   ③ `zoom` **改布局尺寸** —— 父层 flex 居中拿到的就是缩放后的真实尺寸，
   *      不用任何补丁。
   *   `zoom` 早先是 Chrome/Safari 私有，2024 年进了规范、Firefox 126 起也支持；
   *   这个项目只跑 Chrome 与安卓 WebView，两处都稳。
   * ⚠ 缩放**不改** `getComputedStyle().left/top/width`（那些是布局值），
   *   但**会改** `getBoundingClientRect()`。量版式的探针里凡是拿 rect 去比
   *   稿子坐标的，都得先除以这个缩放系数 —— 见
   *   `tools/build/probe-admin-table-desk.py` 的 `SCALE` 那一段。 */
  zoom: var(--ds-scale, 1);
}

/* ── 稿子根也跟着纸长 ────────────────────────────────────────────────────
 * ★ 2026-10-09：上面那条把**纸**（`.screen`）改成跟窗口长之后，`probe-desk-fill`
 *   在 1080 / 1000 高的档位上红了「侧栏下缘贴住窗口底」—— 根因在这儿：
 *   **稿子根自己在页 CSS 里写死 `height:900px`**（`web/assets/pages/3-*.css`
 *   27 个文件全是 `.n-3-xxx{…height:900px…}`），纸长到 1080、根还停在 900，
 *   于是侧栏那条 `height: calc(100% + 2*pad + 2px)` 的 `100%` 仍以 900 为底
 *   ⇒ 侧栏下缘 901，离窗口底差 179，**中间露出 body 的浅底**（不报错，只是难看）。
 *
 * 判据是「根 = 纸」这一层事实（`deskRoot(screen) === screen.firstElementChild`，
 * 见 admin-desk.js:40），所以修在**根这一层**，不逐屏去改 27 份页 CSS ——
 * 页 CSS 是 gen.mjs 生成物，改它下一轮生成就被冲掉。
 *
 * ⚠ 选择器要**限定 `data-ds="xq"`**：手机档 97 屏的 `.screen` 没这个令牌
 *   （gen.mjs 的 dsAttr 只在 xq 表上发），它们的根高度由 `height:100dvh` 那套
 *   管着，一个字都不能碰。
 * ⚠ 只认 `>` 直接子节点 + `data-ardot`：`.screen` 的直接孩子里，稿子根是唯一
 *   带 `data-ardot` 的（浮层是 `data-role="overlay"`，不带）⇒ 不会误伤浮层。
 * ⚠ 特异性 (0,3,0) 压得住页 CSS 的 `.n-3-xxx` (0,1,0)，不用 `!important`
 *   （这个仓的口径：能用特异性解决就不上 important，important 留给正对照注入）。 */
.screen[data-ds="xq"] > [data-ardot] {
  height: 100%;
}

/* ── 这一层只管「把纸居中」────────────────────────────────────────────────
 * 第五十二轮这里写的是 `overflow:auto` + `justify-content: safe center`
 * （纸比窗口宽时横向滚、别把左半边推到够不着的地方）。**第五十四轮改掉了**：
 * 纸现在会缩放到装得下，于是**永远不会溢出** —— 留着 `overflow:auto` 只会
 * 在缩放系数取整的边界上偶尔闪一条 1px 滚动条出来。改成 `overflow:hidden`
 * + 真正的居中（两个轴）。
 *
 * ⚠ `align-items:center` 是新加的：原来只有 `justify-content`，纵向是
 *   `stretch`（纸有固定高 900 所以看不出问题）。缩放之后纸的高度是
 *   `900 × 系数`，比窗口矮的时候要靠它才能上下居中。 */
body[data-canvas="desk"] #app {
  overflow: hidden;
  justify-content: center;
  align-items: center;
  /* 让「居中」有高度可用 —— `#app` 原来没有高度，是被 `.screen` 撑开的
     （html/body 都没写 height）。缩放之后纸比视口矮的时候，没有这一条就没法
     上下居中，纸会贴在顶上。 */
  min-height: 100dvh;
}

/* ══ ★★ 2026-10-06 第五十七轮：去掉纸**外面那圈留白** ═══════════════════════
 * 用户：「我这个桌面需要自适应填充填满，**不能把它写死**」。
 *
 * ── 现象 ────────────────────────────────────────────────────────────────
 * 上面那套 `zoom` 取的是 `contain`（纸装得下，但不一定填满）⇒ 常见 16:9 窗口下
 * **横向**空出来一截（1920 空 96、2560 空 128，量值见 `admin-desk.js` 的
 * `fillDeskRing`）。露出来的是 body 的底 `--cl-bg-page`，比纸自己的底
 * `--cl-bg-secondary` 浅一档 ⇒ 视觉上「一张纸浮在窗里」，最扎眼的是
 * **那条白侧栏的左缘离窗口左边还差一截**。
 *
 * ── 两条规矩（对应 `fillDeskRing` 量出来的两个事实）────────────────────
 * ① **窗外那圈 = 纸自己的底**。底色**不在这里写死**，是 `--xq-paper-bg`
 *    （`fillDeskRing` 从稿子根 `getComputedStyle().backgroundColor` 量的）——
 *    设计师改稿子底色，这里跟着走，不用回来改这份 CSS。
 * ② **白侧栏 / 白顶栏铺到窗口边**。稿子把这两块画在纸里（`侧栏 0,0,240×900`、
 *    `顶栏 240,0,1200×56`，27 屏逐屏量过、完全一致），纸缩了它们也跟着缩。
 *    这里只把它们的**背景盒往外铺**：侧栏往左/上/下、顶栏往右/上。
 *
 * ── ⚠⚠ 这里改的全是「背景盒」，没有动任何一个节点的位置 ────────────────────
 * 导航项、Logo、面包屑、页面标题、卡片、表格 —— 全在原坐标，**一个像素没挪**。
 * 变的只有「白底铺到哪儿为止」。所以「版式还原」那条纪律没有被破。
 *
 * ── 为什么不写 1200 / 900 这些数 ──────────────────────────────────────────
 * `100%` = 纸宽（稿子根那一层就是纸），`--sz-sidebar-w` / `--sz-topbar-h`
 * 是稿子的令牌（240 / 56）。顶栏宽 = 纸宽 − 侧栏宽 + 右侧留白。四个式子全部
 * 由稿子自己的量值推出，没有一个是新造的数。
 * ⚠ 上/下留白（`--xq-pad-y`）**只有窗口比 16:10 更高**时才不为 0
 *   （比如 1280×900 那种竖一点的窗口）；常见 16:9 窗口下它就是 0，
 *   下面这两条自然退化成原样，不会多出一个像素。
 * ⚠ 末尾那个 `+ 1px` 是**防亚像素缝**的：`pad` 是量出来再除系数的浮点数，
 *   浏览器取整之后侧栏左缘可能落在 −0.3px，窗边会漏出一条比头发还细的底色线
 *   （这种「差 1px、不报错」是本仓最贵的一类）。往窗**外**多铺 1px 就与它无关了
 *   ——`#app` 是 `overflow:hidden`，多出去的那点被裁掉，也不会撑出横向滚动条。
 *
 * ── ⚠ 松开裁剪（原来两层都是 `overflow:hidden`）────────────────────────────
 * 手机档那两条 `overflow:hidden` 是靠它把 393 的画布钉死的；桌面档**必须松开**，
 * 否则伸到纸外面的部分会被**静静切掉**——表现是「改了没效果，也不报错」。
 * 松开的代价说清楚：这 27 屏的稿子里**没有一个节点是越界的**（`probe-desk-fill.py`
 * 有一条判据守着：除这两块外壳之外，稿子根的孩子一个都不许越出纸的范围）。
 * 只松开桌面档那两层：手机档（`.screen` 不带 `data-ds`）一条都没动。
 * 浮层（`[data-role="overlay"]` / Toast）**不松** —— 它们挂在 `.screen` 下、
 * 跟纸是兄弟，不该跟着放开。
 */
body[data-canvas="desk"],
body[data-canvas="desk"] #app {
  background: var(--xq-paper-bg, var(--cl-bg-page));
}

.screen[data-ds="xq"],
.screen[data-ds="xq"] > *:not([data-role="overlay"]):not(.toast-gaozi):not(.drawer-gaozi):not(.dialog-gaozi) {
  overflow: visible;
}

.screen[data-ds="xq"] [data-name="侧栏"] {
  left: calc(-1 * var(--xq-pad-x, 0px) - 1px);
  top: calc(-1 * var(--xq-pad-y, 0px) - 1px);
  width: calc(var(--sz-sidebar-w, 240px) + var(--xq-pad-x, 0px) + 1px);
  height: calc(100% + 2 * var(--xq-pad-y, 0px) + 2px);
}

.screen[data-ds="xq"] [data-name="顶栏"] {
  top: calc(-1 * var(--xq-pad-y, 0px) - 1px);
  width: calc(100% - var(--sz-sidebar-w, 240px) + var(--xq-pad-x, 0px) + 1px);
  height: calc(var(--sz-topbar-h, 56px) + var(--xq-pad-y, 0px) + 1px);
}

/* ── 桌面档的「排行名」：名字长了**截断**，不折行也不溢出 ────────────────────
 * 稿子给这一格的宽度是**量出来的**（`湖南大学` → 48、`高频成语 800` → 74），
 * 不是一列定宽；而 `nowrap` 是 gen.mjs 按「一行高」贴上去的 ——
 * 于是真数据里长一点的名字（「教师资格 · 综合素质」）既不折行也不缩，
 * 直接横向溢出压到底槽和条上。
 *
 * 所以：宽度由控制器按稿子的可用余量写回（底槽左边缘 − 名字左边缘 − 8），
 * 这里只负责**打省略号**。跟手机档的 `.dash-rank-name` 是同一个取舍
 * （那一条写着为什么不能折行：折行会让下面那根条跟着往下挪，五行的条对不齐，
 * 一眼看不出谁比谁多多少）。全名挂在 `title` 上，鼠标停上去能看全。
 *
 * ⚠ 选择器按**图层名前缀**认，不按某一屏的 `data-ardot`：题库详情 / 章节管理 /
 *   纠错处理那几屏都有排行名或类似的单行名字格，按前缀写一次全都能用。
 *   写死 `3:3462` 的话，下一屏接进来时同样的毛病要再修一遍。 */
.screen[data-ds="xq"] [data-name^="排行名"] {
  overflow: hidden;
  text-overflow: ellipsis;
}

/* ══ ★ 2026-10-06 第五十三轮：桌面档的**表格** ══════════════════════════════
 *
 * 新稿那 27 屏里九屏是同一张表（题库管理 / 试题检索 / 用户管理 / 组织管理 /
 * 组织详情 / 激活码 / 资料管理 / 公告管理 / IP 黑名单），骨架在
 * `app/routes/admin-table-desk.js`。这里只放**三条跟 CSS 有关的规矩**。
 *
 * ── ① 格子：长了就省略号 ──────────────────────────────────────────────────
 * 稿子给每个格子的 `width` 是**量出来的**（「2026 秋季课程调整通知」量成 225px），
 * 不是列宽 —— 真数据长一点就横向压到下一列上。引擎已经按**列边界**重算了宽度
 * （见 admin-table-desk.js 的 `colW`）并写回内联样式，这里补上「超了就省略号」。
 * `white-space:nowrap` 是 gen.mjs 按「这一格只有一行高」贴上去的，已经有了 ——
 * 少它的话 `text-overflow` 不生效（多行截断那一条坑，见仓库 MEMORY）。
 *
 * ⚠ 全名挂 `title` 上，鼠标停上去能看全 —— 省略号本身不给人看全名的路。
 *   引擎在写格子时没挂；这一条留在这里是因为**目前**九屏的格子都短到不会截，
 *   真截起来再说（宁可先不挂，也不要挂一堆「跟显示一模一样的 title」）。
 *
 * ── ② 操作列：一串链接，不是一个按钮 ──────────────────────────────────────
 * 稿子那一格写的是 `编辑 · 下线 · 删除` —— **一句话，不是三枚按钮**
 * （底色透明、没有边框、颜色是品牌蓝）。所以引擎摆进去的是一串 `<span>`，
 * 只有悬停加下划线这一条反馈。用 `<button>` 会带上浏览器那套外观，
 * 那就不是还原了。
 * ⚠ 分隔符 `·` 单独一个 span、`pointer-events:none` —— 不然点到点号也会触发。
 * ⚠ 操作列**不打省略号**（引擎内联 `overflow:visible`）：那是能点的东西，
 *   被截掉一枚就等于少一个功能，比排版难看严重得多。
 *
 * ── ③ 页码那一格：稿子没画翻页按钮 ────────────────────────────────────────
 * 新稿只画了 `页码` 那一格、没有上一页/下一页。但翻页是**现存功能**
 * （手机档那六个列表页都有），按纪律不许阉割 —— 所以两枚箭头做进了页码格里
 * （`‹ 1 / 3 ›`），不新增节点、不新增位置。到头的方向 `opacity:0.3`
 * 且点不动：**不藏**，藏了页码格会左右跳。
 */
.screen[data-ds="xq"] .xq-cell {
  overflow: hidden;
  text-overflow: ellipsis;
}

.screen[data-ds="xq"] .xq-link {
  cursor: pointer;
}
.screen[data-ds="xq"] .xq-link:hover {
  text-decoration: underline;
}
/* 危险动作（删除 / 移出 / 作废）在**悬停时**才变红：平时仍是稿子那条品牌蓝，
   因为稿子上那一格就是一串同色的词，没有一个字被画成红的。
   ⚠ 令牌名是 `--cl-semantic-warning`（#E84026），不是 `--cl-alert` ——
     新表（[data-ds="xq"]）的语义三色叫 warning / alert / confirm，其中
     **warning 才是红**（alert 是橙）。3-9037 那张规范页里
     「危险按钮·删除 / 作废 / 移出」用的就是它，照它来。 */
.screen[data-ds="xq"] .xq-link--danger:hover {
  color: var(--cl-semantic-warning);
}

.screen[data-ds="xq"] .xq-page-hit {
  cursor: pointer;
  /* 命中区只有 12px 的字，四周补一点内边距 —— 不然要点准很费劲。
     用 padding 不用 width：这一格是 flex 行里的 inline，加了 padding 也不会挤走别的。 */
  padding: 0 4px;
}

/* 推出来的页脚（资料管理 3-6654 / IP 黑名单 3-2429 那两屏的稿子没画页脚，
   见 admin-table-desk.js 的 `ensureFooter`）。字号/颜色取稿子自己那两格的同款令牌。 */
.screen[data-ds="xq"] .xq-derived-foot {
  position: absolute;
  height: 15px;
  font-size: var(--fs-caption-l);
  color: var(--cl-text-secondary);
  font-family: var(--ff);
  line-height: normal;
  white-space: nowrap;
  display: flex;
  align-items: center;
}

/*
 * 滚动容器。**这个标记不是手写的，是 view.js 从稿子的计算样式里推出来的** ——
 * 稿子里写了 flex:1 又 overflow:hidden 的那个节点，语义就是「承载剩余空间的那块」，
 * 真机上给它滚。一页只标一个（取离根最近的），不然会嵌套双滚动。
 */
[data-scroll="1"] {
  overflow-y: auto !important;
  overflow-x: hidden !important;
  overscroll-behavior: contain;
  -webkit-overflow-scrolling: touch;
}

/* ── 列表的筛选 + 搜索吸顶（2026-10-10 用户点名「筛选器不要滚动、搜索框不要
   滚动，这是个大忌，所有板块都是」）──────────────────────────────────────
   包装容器是控制器塞的 .sticky-filters（错题本 / 收藏夹同款，见各自
   stickyFilters()）；滚动层就是上面这个 data-scroll（= content）。
   背景取页面底色 —— 半透明会让滑过的卡片透出来，吸顶反而更花。 */
.sticky-filters {
  position: sticky;
  /* ⚠ top 由 stickyFilters() 按 content 的 paddingTop 写成负值（-padTop）：
     吸顶层是卡在**内容盒顶**的，而滚动区顶边还在它上方 padTop 那么多 ——
     写 top:0 会留出一条没人盖的缝，卡片滚上去就从缝里露出来
     （2026-10-11 用户报的「顶部为啥镂空」；padTop 各帧不一样，所以 JS 算）。 */
  top: 0;
  z-index: 6;
  /* ⚠ 必须拉满内容宽（2026-10-11 用户报的「顶部问题」的另一半）：
     父层是 `flex column + align-items:center`，这个容器不给 stretch/width
     就只包住最宽的那个子项 —— 实测 260px < 内容宽 353px，滚动时卡片从右侧
     漏过去。宽度给 100% 是给非 flex 场景兜底（同一个坑两种形状）。 */
  align-self: stretch;
  width: 100%;
  box-sizing: border-box;
  display: flex;
  flex-direction: column;
  /* 分段 / 搜索 / 汇总 之间的呼吸（同批用户点名「没有间距！！！」）。
     用 gap 不用 margin：稿子自带的 margin 各帧不一致，叠上去就乱。 */
  gap: 10px;
  background: var(--cl-bg-page, #fafaff);
  padding: 8px 0 14px;
}
/* 稿子给子项的 margin 跟 gap 会叠加，直接清掉（只清直接子项，组件内部不受影响） */
.sticky-filters > * { margin: 0; }

/* ── 列表分页条（错题本 / 收藏夹，2026-10-11 用户点名「要分页，不要加载更多」）──
   稿子没画分页帧，控件自己搭；配色/圆角全走全局令牌，跟卡片同一套语言。 */
.pager {
  /* 同 .sticky-filters 一个坑：父层是 flex + align-items:flex-start，
     不拉满的话这里只包住按钮自己的宽度、还贴在左边（实测 244px 贴 96）。 */
  align-self: stretch;
  width: 100%;
  box-sizing: border-box;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 14px;
  padding: 14px 0 2px;
}
.pager-btn {
  font-family: inherit;
  font-size: 14px;
  line-height: 1;
  color: var(--cl-text-primary, #1c1c28);
  background: var(--cl-bg-card, #fff);
  border: 1px solid var(--cl-border-base, #e7e7f0);
  border-radius: 10px;
  padding: 10px 18px;
  cursor: pointer;
}
.pager-btn[aria-disabled="true"] {
  color: var(--cl-text-disabled, #b5b5c2);
  background: var(--cl-bg-secondary, #f3f3f8);
  cursor: default;
  pointer-events: none;
}
.pager-info {
  font-size: 13px;
  color: var(--cl-text-secondary, #6b6b7b);
  font-variant-numeric: tabular-nums;
  min-width: 56px;
  text-align: center;
}

/* 窄屏（真机）铺满，不留白边 */
@media (max-width: 480px) {
  .screen { max-width: 100%; }
}

/* ══ ★ 2026-09-28 第三十五轮：换屏入场（治「UI 切换卡卡的 / 每个动画都是硬切」）══
   出处 = 参考设计 `闪卡学习.html:45-47`：
     `.view{display:none;…}` / `.view.active{display:flex;animation:viewIn .45s var(--ease) both;}`
     `@keyframes viewIn{from{opacity:0;transform:translateY(16px);}to{opacity:1;transform:none;}}`
   我们这边没有「多个 .view 常驻 DOM、切 display」这套（壳是**一次挂一屏**：
   `mountScreen` 里 `host.replaceChildren(screen)`），所以照搬的是**那段动画本身**，
   不是它的切换机制 —— 每次挂屏重放一次 450ms 的**上移**。

   ★★★ 2026-09-29：**透明度那一半已经拿掉了**（`opacity` 从 keyframes 里删掉）。
   用户第二回报「切着切着总感觉慢半拍、不跟手，而且点任何按钮都卡」，量出来是这样
   （probe-ablate2.py，真触摸 + 逐帧记不透明度，#app 里挂的是新那一屏才算数，
   CPU 降速 4 倍；三个变体只差这一条 CSS）：

       量到的            带 opacity（原样）   去掉 opacity   整段关掉
       看得出内容            207 ms           100 ms        91 ms
       完全到位              308 ms           241 ms       233 ms

   ⇒ 用户看到内容要等 **207ms**，其中 **107ms 纯粹是「从半透明变成实心」**；
     而位移几乎不花钱（去掉 opacity 的 100 与整段关掉的 91 只差 9ms）。
   为什么这么贵：新屏挂进来时 `is-enter` 让它从 `opacity:0` 淡上来，**同一时间**
   `screen-cover` 正装着上一屏在淡出 —— 两层都是半透明的，屏幕在中段是「灰蒙蒙」的，
   读起来就是「慢半拍」。而且这段动画**每次挂屏都放**（答题换题、页内换屏都走
   `ctx.remount`），所以不止标签栏，是「点哪儿都卡一下」。
   ⇒ 这一轮**只去掉了 opacity，位移留着**（当时的判断是「位移不花钱」）。
   ⚠ 别再顺手把 opacity 加回来。要「更软」就调时长/位移，别调透明度 ——
     透明度是**延迟可见**，位移只是**在移动**，前者被读成卡，后者被读成动。
   ⚠★ **但位移后来也出事了**：它不花钱 ≠ 它不出事。下一轮用户报「切 Tab 时整个窗口
     在抖」，量的就是这段 16px 位移（它挂在 `#app` 上，连底边那颗 TabBar 一起走）。
     所以现在这一段是**整段关掉**的，见右下方 2026-09-29 第四报那段的实测波形。
   ⚠★ **动画挂在 `#app` 上，不能挂 `.screen`** —— 上面 `.screen` 那条
     `transform: none !important` 是有用的保护（覆盖 fit.js 在画布档下的缩放），
     而按 CSS 级联顺序 **`!important` 的声明压过动画**（动画那一层在它下面）
     ⇒ 挂在 `.screen` 上只有 `opacity` 会生效、`translateY` 会被静默吃掉。
   ⚠ 缓动照参考设计 `--ease` = `cubic-bezier(.22,1,.36,1)`（不是闪卡页那条 `--ease-out`）——
     两处**不是同一个数**，别顺手统一（前者更"慢起快收"，适合整屏位移；后者适合元素级）。
   ⚠ 位移 16px 与 450ms 都是参考设计原值 —— 现在整段关掉了，这两个数只在
     「万一要把动画放回来」时才有意义，放回来之前先看第四报那段实测。
   ⚠ 一处**有意的风险**（当初写的，现在因为动画已关而**不再成立**，留着当底稿）：
     当初写的是「`#app` 上带 `transform` 时它的 `position:fixed` 后代改成相对 `#app` 定位」。
     现在 `#app` 不再带 transform，这条风险自动消失 —— 将来要把动画放回来，它跟着回来。 */
/* ★★★ 2026-09-29 第四报：**位移这一半也拿掉了**，整段入场动画关掉。
   用户第三报「左右切 Tab 时页面会晃、高度不一致、抖一下、整个窗口也在抖」。

   量出来是这样（probe-shake2.py：真触摸 + 逐帧记 `#app` 与浮起来那颗 TabBar 的上沿 y，
   CPU 降速 4 倍，逐帧只在值变了时打一行）：

       t(ms)   #app.y   TabBar.y   垫场层
         +7      0.00     757.00     无       ← 上一屏还好好待在原位
        +24     16.00     773.00     有       ← 新屏一挂，整屏连胶囊**同一帧**掉下 16px
        +49     14.69     771.69     有
       ...
       +383      0.04     757.04     无       ← 450ms 才爬回来

   ⇒ 关键在那个 **+24ms 的瞬间**：动画的 `from` 就是 `translateY(16px)` 且
     `fill-mode: both`，所以类一贴上去元素**已经在 16px 处**，没有「从 0 走到 16」这个过程——
     它是**一帧之内硬跳下去、再慢慢爬回来**。而这段动画挂在 `#app` 上，`#app` 是整块可见区域，
     连浮起来钉在底边的 TabBar 一起走 ⇒ 观感就是**整个窗口往下顿一下**（757→773 那 16px）。
     测量同时排除了别的可能：`.screen` 高恒为 852（各页没有「高度不一样」）、
     文档层 scrollHeight 恒等于视口高（窗口根本没在滚）、滚动容器 scrollTop 恒为 0。

   ⇒ 所以这一段整个关掉。换屏的过渡还有**垫场层那 120ms 交叉淡出**兜着，
     两块都在 y=0 原地做溶解，不会变成硬切、也不会再有任何位移。
   ⚠ 别再改成「位移调小一点」（8px / 4px 都不行）：TabBar 是钉在整块区域底边的，
     它跟着动多少用户就看见多少。要「更软」只能调**淡出时长**，不能调位移。

   下面是原样，留着是为了这行注释能说清「关掉的是什么」：
     @keyframes screenIn { from { transform: translateY(16px); } to { transform: none; } }
     #app.is-enter { animation: screenIn 450ms cubic-bezier(.22, 1, .36, 1) both; } */
/* ★★★ 契约：`#app` 是**整块可见区域**，它的几何在换屏期间是**契约，不是样式**。
 *
 *   · 换屏的过渡**只能由「出屏」承担** —— 也就是垫场层 `.screen-cover` 那 160ms 淡出
 *     （`COVER_FADE_MS`，唯一的旋钮）。
 *   · **进屏从第一帧起就必须在最终位置**，不许带位移、不许带透明度登场。
 *
 * 所以这一行不是「把入场动画关掉」，而是**让 `#app` 根本不可动画**：
 * 谁再把 `transform` / `opacity` 挂回 `#app` 上，浏览器直接不认。
 * 上一轮的病就是挂在它身上的（16px 位移连浮起来的 TabBar 一起走 = 「整个窗口在抖」），
 * 靠「记得别写」是防不住的 —— 报告前一轮刚把位移从 16px 调到「更软」，
 * 下一轮就变成了抖。写成 `!important` 之后，这类改动**不会再有效果**，
 * 也就不会再有效果看不见的失效（那才是最坏的一种：改了代码、什么都没发生、
 * 也没人报错，于是谁都不知道这行其实早就死了）。
 *
 * ⚠ 和上面 `.screen` 那条 `transform: none !important` 是一对：这两个容器**定义窗口几何**，
 *   都不许动。要给某个**具体页面**加动效，加在屏里面的元素上（`#app` 的后代），
 *   那条路是通的 —— 只有这两个容器被锁住。
 * ⚠ 为什么用 `!important` 而不是删掉 `is-enter` 相关的代码就算完：
 *   删代码只解决**这一次**；`!important` 解决的是**下一次**（别人不知道这段历史）。
 * ⚠ 联动：`app.js` 里 `mountScreen` 已经**不再挂 `is-enter` 类**了（那个类现在是死的）。
 *   两边一起看，别只改一边。 */
#app, #app.is-enter { animation: none !important; }

/* ══ ★ 2026-10-17 动效层：进场那一拍，交给**屏里面的东西** ══════════════
 *
 * 用户：「可能做一下跟手的动画，细微的细节能让页面活起来，比如缓动、缓出、
 * 一镜到底等类型动画。」
 *
 * 上面那条 `#app / .screen { animation: none !important }` 锁的是**窗口几何**，
 * 不能动（动它整个窗口会跟着抖，TabBar 一起走）。但「屏里面」是另一回事：
 * 每张卡片各动各的，谁也不推谁 —— 视线读到的是「内容在长出来」，
 * 而不是「窗口在跳」。这就是这一节存在的理由。
 *
 * 三条规矩（写在这儿，改的人不用去翻别的文件）：
 *   ① **只动 opacity 和 translateY，距离 ≤ 10px**。再大就是「飞进来」，
 *      跟这个稿子的克制感不搭；而且位移一大，动画期间点它会点偏。
 *   ② **只在 `body[data-enter]` 窗口里播**。这个标记由壳在挂屏那一刻打上、
 *      约 0.9s 后摘掉（app.js 的 mountScreen）。窗口之外插进来的行
 *      （滚动到底加载更多）**不演**——否则越滚越晕。
 *   ③ **错峰只给前 6 枚**（36ms 一档），第 7 枚起统一 180ms。
 *      一屏能看见的就那么五六行，后面的错峰是白算。
 *
 * ⚠ 缓动曲线统一从下面两个变量取，别在各处手写 cubic-bezier：
 *   进场用 `--xq-ease-out`（起步快、收尾稳 —— 这是 iOS 那套「内容浮上来」的手感），
 *   跟手拖动用 `--xq-ease-soft`（两头都软，松手回弹不突兀）。
 * ⚠ `both` 是必须的：延迟期间元素要保持 `from` 那一帧（透明），
 *   否则它先实着出现、再突然消失去播动画，比不动画还难看。
 */
:root {
  --xq-ease-out: cubic-bezier(.2, 0, 0, 1);
  --xq-ease-soft: cubic-bezier(.4, 0, .2, 1);
}

@keyframes xq-rise {
  from { opacity: 0; transform: translateY(10px); }
  to   { opacity: 1; transform: none; }
}

/* ══ 墓碑：列表项滚动显现（.xq-in / .is-in）已于 2026-10-18 整套拆除 ════════
 *
 * 用户原话：「不要隐藏列表，章节列表，组织列表等，要都加载。我只是要滑动缓动，
 * 没必要藏着不加载。」
 *
 * 拆掉的是这三层（它们都由**滚动位置**决定内容显不显示，等于把列表藏起来）：
 *   ① `app/reveal.js` —— IntersectionObserver 只在滚进视口时才加 `.is-in`；
 *   ② 下面这条 `.xq-in` 基础规则 —— `opacity:0; translateY(18px)`，没 `.is-in` 就一直隐身；
 *   ③ 文末 `@supports (animation-timeline: view())` 的 `xq-scrub` —— 按滚动进度刷透明度/缩放。
 *
 * ⚠ 留着的是 `app/scroll.js` 的**滚轮缓动**（lerp 逼近目标 scrollTop）——
 *   那是「滑动缓动」，跟显不显示无关，用户点名要保留。
 * ⚠ `streamAppend` 分批插 DOM（8ms 预算）也保留：数据全量到手、只是分帧铺，
 *   是真性能优化，不是隐藏。
 * ⚠ 下次别再把 `.xq-in` 加回来：「列表项滚到才亮」看着是动效，实际是
 *   首屏空白 + 慢设备上内容凭空延迟出现，用户会当成「没加载」。
 */

/* ══ 下拉跟手的指示器（逻辑在 app/pull.js）══════════════════════════
 *
 * 一枚**浮在顶上**的胶囊，不进文档流 —— 插进滚动块里会破坏稿子的版式。
 * 位置由 `--pull` 驱动（JS 每帧写一次，数值 = 当前内容的位移）。
 * 0 → 96px 的行程里它从「藏在屏幕外」滑到「露出 40px」，
 * 正好盖住内容被拉下来露出的那条缝。
 *
 * 三段状态，跟 app/pull.js 的三个动作一一对应：
 *   .is-on    出现
 *   .is-armed 过线（箭头那圈换成主色，文案变成「松开刷新」）
 *   .is-busy  松手了，转圈
 */
.xq-pull {
  position: fixed;
  left: 50%;
  top: 0;
  z-index: 8500;          /* 高于垫场层(8000)，低于 devbar(9000) 和 toast(9999) */
  display: flex;
  align-items: center;
  gap: 7px;
  padding: 7px 14px;
  border-radius: 999px;
  background: rgba(20, 22, 30, .84);
  color: #fff;
  font: 12px/1 var(--ff, sans-serif);
  letter-spacing: .02em;
  box-shadow: 0 8px 24px rgba(15, 23, 42, .22);
  pointer-events: none;
  opacity: 0;
  transform: translate(-50%, calc(var(--pull, 0px) * 0.9 - 60px));
  transition: opacity 140ms var(--xq-ease-out);
}
.xq-pull.is-on { opacity: 1; }

.xq-pull-arrow {
  width: 12px;
  height: 12px;
  border-radius: 50%;
  border: 1.5px solid rgba(255, 255, 255, .38);
  border-top-color: rgba(255, 255, 255, .95);
  flex: none;
}
.xq-pull.is-armed .xq-pull-arrow { border-top-color: #7c5cff; }
.xq-pull.is-busy .xq-pull-arrow { animation: xq-spin 720ms linear infinite; }

@keyframes xq-spin { to { transform: rotate(360deg); } }

/* ══ 加载动效 ①：骨架微光（skeleton shimmer）══════════════════════
 *
 * 数据没到的时候，屏幕上那几块应该是**在动的** —— 不动的灰块会被当成
 * 「卡住了」。所以给「加载中」那一帧里的占位块挂一条扫过去的微光。
 *
 * 打标记的动作在壳里（app.js 的 shimmerSkeleton）：只给**没有子节点、没有文字、
 * 又有实际尺寸**的块 —— 页头、说明文字、图标都不动，动的是那些本来就是
 * 「等着被填上」的格子。
 * ⚠ 这里只加 `position` / `overflow` / 伪元素三样，不动稿子画的任何一条尺寸和配色；
 *   微光是一层半透明白，扫过去就走。
 */
.xq-sk {
  position: relative;
  overflow: hidden;
}
.xq-sk::after {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(100deg,
              transparent 20%, rgba(255, 255, 255, .58) 50%, transparent 80%);
  transform: translateX(-110%);
  animation: xq-sweep 1250ms var(--xq-ease-soft) infinite;
  pointer-events: none;
}
/* 同一屏里一排占位块，让微光**错开一点**依次扫过去 ——
   一起扫是一整块在闪，错开才是「一格一格在填」。 */
.xq-sk:nth-of-type(3n + 2)::after { animation-delay: 140ms; }
.xq-sk:nth-of-type(3n + 3)::after { animation-delay: 280ms; }

@keyframes xq-sweep {
  to { transform: translateX(110%); }
}

/* ══ 加载动效 ②：顶部那条细进度条 ═══════════════════════════════
 *
 * 点下去到新屏出来之间，总有一段「什么都没变」的时间。这条 2px 的条就是
 * 给那一段时间一个交代 —— 它在动，用户就知道东西在路上。
 *
 * 两条讲究：
 *   · **只跑到 92% 就等着**：进度条最忌讳「一路跑满然后发现还没完」，
 *     所以它自己停在一个接近完成的位置，等真的 done 再补满并淡出。
 *   · **不挡事**：`pointer-events:none`、`z-index` 压在垫场层之上但低于 toast。
 */
#xq-bar {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  height: 2px;
  z-index: 8800;
  background: linear-gradient(90deg, #6d4aff, #9b7bff);
  transform-origin: 0 50%;
  transform: scaleX(0);
  opacity: 0;
  pointer-events: none;
}
#xq-bar.is-on {
  opacity: 1;
  animation: xq-bar-run 900ms var(--xq-ease-soft) forwards;
}
/* 跑完：补满 + 淡出。用 transition 而不是再起一条 animation ——
   从 scaleX(.92) 直接接过去，不回弹。 */
#xq-bar.is-done {
  animation: none;
  transform: scaleX(1);
  opacity: 0;
  transition: opacity 240ms linear;
}

@keyframes xq-bar-run {
  0%   { transform: scaleX(0); }
  55%  { transform: scaleX(.68); }
  100% { transform: scaleX(.92); }
}

@media (prefers-reduced-motion: reduce) {
  .xq-sk::after { animation: none; opacity: 0; }
  #xq-bar.is-on { animation: none; transform: scaleX(.5); }
}


@media (prefers-reduced-motion: reduce) {
  .xq-pull { transition: none; }
  .xq-pull.is-busy .xq-pull-arrow { animation: none; }
}

/* 关了动效的人：原来这里单独给这一档写 `#app.is-enter { animation: none }`。
   现在上一行对**所有人**都是 none 了，这条已经无事可做 —— 删掉而不是留着，
   是因为留一条「看着像在给这类用户做特殊处理、其实什么都没做」的规则，
   下次谁读到这里会以为「动画是给别人的、这类用户另有安排」。 */


/* 提示条：**壳自带的**，用于壳级别的报错（「控制器出错」这类，页面都还没起来）。
   页面自己的提示不走这里 —— 刷题页用稿子画的 Toast（12:74），见下面 .toast-gaozi。 */
.toast {
  position: fixed;
  left: 50%;
  /* ★ 2026-10-14 键盘避让：跟 `.toast-gaozi` 同一条口径。键盘把屏幕下半截占掉
     的时候，这条提示正好落在键盘底下 —— 用户什么也看不到，那等于没提示。
     `--xq-kb` 见 `.drawer-gaozi` 上面那段（一条变量、所有浮层各自跟上）。 */
  bottom: calc(96px + var(--xq-kb, 0px));
  transform: translateX(-50%);
  background: rgba(28, 27, 34, .88);
  color: #fff;
  font: 13px/1.5 var(--ff, sans-serif);
  padding: 10px 16px;
  border-radius: 10px;
  z-index: 9999;
  max-width: 80vw;
}
.toast[hidden] { display: none; }

/* ★★ 2026-10-07：Toast 的**四种语气**（稿子 `3-8908`「23 · 反馈与状态」画的那四枚：
     成功 / 警告 / 错误 / 信息，每枚各有底色和一个图标）。
   以前四种消息共用同一块深灰底 ⇒ 「已保存」和「保存失败」**长得一模一样**，
   只有里面的字不一样。扫一眼分不出好消息还是坏消息，就等于没有。

   ⚠ 三条取舍：
     ① **底色用令牌的一档、图标用品类色**，不自己配 RGB —— 令牌在深浅两个主题里
        都有对应的一档，写死颜色的话 ㊵ 那套主题切换会有一条接不上；
     ② **图标（左侧那一条竖杠）只做辅助**：色盲用户靠的仍然是那句话本身。
        所以 `kind` 决定的是「先给个方向」，判据不挂在颜色上；
     ③ 这一段只管「这一条是什么语气」，文案仍由调用方给 ——
        四种话术一句都不写在这里（见 `app.js` 的 `toast(msg, ms, kind)`）。 */
.toast { display: flex; align-items: center; gap: 8px; }
.toast::before {
  content: "";
  flex: 0 0 4px;
  align-self: stretch;
  border-radius: 2px;
  background: var(--cl-text-disabled, #8a8a93);
}
.toast[data-kind="success"]::before { background: var(--cl-success-500, #1a9e5c); }
.toast[data-kind="warn"]::before    { background: var(--cl-warning-500, #c77700); }
.toast[data-kind="error"]::before   { background: var(--cl-danger-600, #c0272d); }
.toast[data-kind="info"]::before    { background: var(--cl-primary-500, #3b6cf6); }
/* 错误那条给个更厚的一点存在感 —— 它是唯一一类「不说清楚会让人以为数据坏了」的。 */
.toast[data-kind="error"] { font-weight: 600; }

/* ── 代码阶段规则（稿子表达不了、只能在代码里落的）─────────────
 *
 * ardot 没有「固定定位」也没有 max-height / 行数截断，
 * 所以这类东西在稿子上只能画成「它长什么样」，位置和截断得在这里补。
 * 每一条都必须写清出处，否则下一个人会把它们当成我随手写的样式。
 */

/* ① 稿子画的 Toast（12:74 的 12:157）在画布上是 absolute + left:56.5/top:400 ——
 *    那是「摆在照片里的位置」，不是真机上的位置。真机要的是浮在底部。
 *    只改位置，卡片本身的底色/圆角/阴影/字号全部来自稿子的 CSS。 */
.toast-gaozi {
  position: fixed !important;
  left: 50% !important;
  top: auto !important;
  bottom: calc(96px + var(--xq-kb, 0px)) !important;
  transform: translateX(-50%);
  z-index: 9999;
}
.toast-gaozi[hidden] { display: none; }

/* ②-b 解析正文要**保留换行**（2026-10-14 用户：「解析每个模块要换行，不然都集中到一块」）。
 *   解析在库里是多段结构文本（【出处】／【教材原文】／【考点】／【解析】／
 *   【选项辨析】逐项／【结论】／【记忆要点】），段与段、项与项之间全靠 \n 分隔。
 *   而 quiz.js 是用 `setText → textContent` 灌进去的，
 *   HTML 默认会把连续空白和换行折成一个空格 ⇒ 所有模块会挤成一坨。
 *   `pre-line` 只保留换行、合并多余空格（PDF 转 docx 带来的空格也顺带压掉）。 */
[data-name="解析"] {
  white-space: pre-line;
}

/* ③ 解析卡的折叠。ardot 存不了 -webkit-line-clamp，所以稿子上
 *    「解析卡/长文」（12:233 的 12:248）画的「4 行 + …+ 展开全部」，
 *    那个「4 行」只能在代码里落。
 *    只在 data-fold="1" 时生效 —— 短解析永远走普通的解析卡，不折。 */
[data-fold="1"] [data-name="解析"] {
  display: -webkit-box;
  -webkit-line-clamp: 4;
  -webkit-box-orient: vertical;
  overflow: hidden;
}
[data-fold="0"] [data-name="解析"] {
  display: block;
  -webkit-line-clamp: none;
  overflow: visible;
}

/* ③ 填空的输入框。稿子 12:190 画的是 Component/Input/Default 里一个叫「占位」的
 *    div（写着「请输入答案」）。真机上必须能真输入，所以 quiz.js 把这个槽换成了
 *    真的 <input>，**class 沿用稿子的那一份** —— 字号/颜色/行高照旧继承。
 *    这里只补三条 div 不需要、input 才需要的东西（它默认不是 flex 子项、
 *    自带边框、自带 outline）。 */
input[data-name="作答输入"] {
  flex: 1 1 0;
  min-width: 0;
  border: 0;
  background: transparent;
  outline: none;
  padding: 0;
  font: inherit;
  color: inherit;
}

/* ④ 答题卡抽屉（稿子 12:75 的 12:171）。ardot 没有「固定定位」也没有 max-height，
 *    所以那一帧画的是「抽屉长什么样、摆在哪儿」，真机上必须是浮层 + 可滚。
 *    位置和限高只能在这里落；卡片本身的底色/圆角/内边距全部来自稿子。 */
.drawer-gaozi {
  position: fixed !important;
  left: 0 !important;
  right: 0 !important;
  /* ⚠ 抬起来的那个量是**软键盘**占掉的高度（`--xq-kb`，由 app.js 的
     `syncKeyboard()` 写在 <html> 上）。用户报「弹窗没有避让键盘」——
     抽屉和底部面板都是 `fixed; bottom:0`，键盘一弹正压住它们，
     而面板里那个输入框正是用户要打字的地方。没有键盘时它是 `0px`，等于没改。
     （桌面档不弹软键盘 ⇒ 量出来恒为 0 ⇒ 那条规则在电脑上不起作用。） */
  bottom: var(--xq-kb, 0px) !important;
  top: auto !important;
  /* 上限用**可视视口高**（`--xq-vh`，app.js 写的）而不是 `100vh`：
     `vh` 是"最高的那个视口"、**不跟键盘变** —— 键盘一弹它就把上限算高，
     板子顶上那一截（标题行 / 关闭钮）被顶出屏幕。理由同 .dialog-gaozi 那条。 */
  max-height: min(85vh, calc(var(--xq-vh, 100vh) - 20px));
  /* ⚠ 这两个 !important 是必须的，不是保险。稿子给抽屉节点（12:75 的 12:171）
     写了 `overflow:hidden` —— 那是描述画布用的，跟本规则**同特异度**，
     而稿子的页面 CSS 是 loadView 后面注入的，源码顺序在后 → 它赢。
     少这两个 !important，抽屉/弹窗里的内容一超长就被**切掉且滚不到**
     （实测纠错弹窗 overflowY 计算值 = hidden）。别照抄 [data-scroll] 那种写法漏掉。 */
  overflow-y: auto !important;
  overflow-x: hidden !important;
  z-index: 9000;
}
/* 遮罩也一样：稿子里它是 absolute 铺满画布，真机上要盖住整个视口。
 *
 * ★★ 2026-10-14 修「弹窗弹起来背景全黑」（用户报的原话）。
 *   根因不在颜色，在动画：稿子 9-33 那枚遮罩写的是
 *   `background: var(--cl-text-primary); opacity: .45`（45% 的黑），
 *   而我们给它挂了 `maskIn` 淡入（`to{opacity:1}`，`fill-mode: both`）——
 *   **动画的收尾态优先级高于普通声明**，于是运行时 opacity 被顶成 1，
 *   遮罩从「45% 的黑」变成「纯黑」，整页背景黑掉。
 *   所以颜色必须收进 `background` 的 alpha 里，不能再靠 opacity 表达浓淡。
 *   ⚠ `opacity: 1` 是**必要的冗余**：reduced-motion 下没有动画（`animation:none`），
 *     那一刻就只剩两条普通声明打架 —— 不写这一条，稿子那个 .45 会生效，
 *     遮罩又会淡到看不见。它跟动画收尾态同值，所以两边都不打架。
 *
 * ★ 同一次按用户要求把背景改成「略微模糊」：`backdrop-filter` 糊的是**后面那一页**，
 *   不是遮罩自己的颜色 —— 面板/卡片/文字一个像素都不模糊。
 *   ⚠ 必须带 `-webkit-` 前缀：安卓 WebView / iOS Safari 上不带不生效。
 *   ⚠ blur 值取小（7px）：这一层在低端机上是整屏合成，值一大就掉帧；
 *     `saturate` 一起给是为了让糊掉的页面不发灰。
 * 深色主题那一档另给：深色下 `--cl-text-primary` 是**浅色**（#F4F4F5），
 *   照稿子的取色逻辑遮罩该是「提亮」而不是压暗，直接用同一个深色会把页面压得更黑。 */
[data-name="遮罩"] {
  position: fixed !important;
  inset: 0 !important;
  z-index: 8999;
  background: rgba(28, 27, 34, 0.28) !important;
  opacity: 1;
  backdrop-filter: blur(7px) saturate(115%);
  -webkit-backdrop-filter: blur(7px) saturate(115%);
}
[data-theme="暗色"] [data-name="遮罩"] {
  background: rgba(15, 15, 18, 0.42) !important;
}

/* ⑤ 纠错弹窗（稿子 15-111 的 15:113）。跟抽屉同一个道理：ardot 没有「固定定位」，
 *    稿子画的是「弹窗长什么样、贴在底部」，真机上得钉在视口底下并允许滚动
 *    （长描述 + 六个芯片在小屏上可能超出）。
 *    卡片本身的底色/圆角/内边距/阴影全部来自稿子，这里只管位置。 */
.dialog-gaozi {
  position: fixed !important;
  left: 0 !important;
  right: 0 !important;
  /* 同上：软键盘占掉的高度（`--xq-kb`）。激活组织那张面板就在这一档上。 */
  bottom: var(--xq-kb, 0px) !important;
  top: auto !important;
  /* ⚠ 高度也要跟着键盘收，不能只看视口：85vh 在键盘弹起来之后**比可视区还高**，
     面板顶上那一截（标题行 / 关闭钮）会被顶到屏幕外面去，够不着也看不到。
     减 20 是给「面板上沿离屏幕顶」留一口气，别贴着状态栏。
     ★ 2026-10-14 第二次修：原来减的是 `100vh`，而 `vh` **不跟键盘变**
       （它是最高的那个视口）⇒ 键盘一弹它就把上限算高了，等于白减。
       换成 `--xq-vh`（= 可视视口高）。没有键盘时两者基本相等，等于没改；
       有键盘时减完正好是「面板真正能用到的全部高度」。 */
  max-height: min(85vh, calc(var(--xq-vh, 100vh) - 20px));
  /* 宽度就按视口来，不许被内部内容撑宽 —— 面板是个块，撑宽等于横向溢出。
     桌面档那两条（`.admin-shell` / `.screen[data-ds="xq"]`）另有 max-width + 居中。 */
  width: 100%;
  /* ★ 2026-10-14 用户：「它这个弹窗的尺寸也把握得不好」。
     根因是**面板是 `fixed` 的**：`fixed` 相对**视口**定位，而这一套界面的纸
     （`.screen`）自己写着 `max-width: var(--design-w, 393px)` —— 手机上两者一样宽
     看不出来，可一旦跑在**比 393 宽的视口**上（平板 / 微信 PC 端的小程序窗口 /
     桌面浏览器直接打开），纸还是 393、面板却铺满整个窗口，
     表现就是「弹窗比它盖住的那个页面宽出一大截」，抓手和关闭钮隔着一整个屏幕。
     改法不是另定一个宽度，而是**跟纸用同一个上限**：`--design-w` 就是纸的宽度
     （393 / 桌面档另有 560 那两条覆盖），`min(..., 100%)` 保证窄屏不溢出。 */
  max-width: min(var(--design-w, 393px), 100%);
  left: 50% !important;
  right: auto !important;
  transform: translateX(-50%);
  /* 同抽屉：稿子的 15:113 写了 overflow:hidden，不加 !important 就压掉这条
     （见上面 .drawer-gaozi 的说明）。长描述 + 六个芯片在小屏上必须能滚。 */
  overflow-y: auto !important;
  overflow-x: hidden !important;
  z-index: 9001;   /* 比抽屉(9000)高一层：纠错弹窗是从题面上开的，抽屉不该盖它 */
}

/* ⑤ 点按区补足（HIG：最小 44×44）。
 *    稿子是按「看着好看」画的，有些可点元素的绘制高度不到 44px。
 *    下面用透明伪元素往上下撑开命中区，**不动任何布局**（伪元素不占空间，
 *    也不改变绘制位置）—— 所以视觉与稿子逐像素一致，手感却够。
 *    每一条都写清是哪个节点、稿子画的多高、撑到多高。 */

/* 「展开全部」行（12:233 的 12:231）：稿子画 329×23 —— 撑到 45 高 */
[data-name="展开行"] { position: relative; }
[data-name="展开行"]::after {
  content: "";
  position: absolute;
  left: 0; right: 0; top: -11px; bottom: -11px;
}

/* 答题卡格子（12:75）：稿子画 32×32，格间距 10 ——
   上下各撑 5px 正好吃满格间距、撑到 42，且**相邻格子的命中区不重叠**
   （重叠会让点在边界上时不知道跳哪一题）。 */
[data-name^="答题卡格/"] { position: relative; }
[data-name^="答题卡格/"]::after {
  content: "";
  position: absolute;
  left: 0; right: 0; top: -5px; bottom: -5px;
}

/* ⑥ 确认弹窗（稿子 9:33「清空错题本？」、11:320「删除错题」里那个「弹窗/确认」）。
 *    同上：ardot 没有固定定位，稿子把它画在画布 41/330 处 —— 那是「摆在照片里的
 *    位置」，不是真机上的位置。真机要的是浮在视口正中（稿子 330 本来也就是居中：
 *    弹窗高约 172，852 画布居中正好是 340）。
 *    卡片本身的底色/圆角/阴影/内边距/按钮全部来自稿子，这里只落位置。
 *    遮罩的位置不用在这里写 —— 上面已经有一条 [data-name="遮罩"] 管着。 */
[data-name="弹窗/确认"] {
  position: fixed !important;
  left: 50% !important;
  /* ★ 2026-10-14 键盘避让：居中框原来钉死在 50%，键盘弹起来后它有一半在键盘底下。
     上移**半个键盘高**（不是整个）—— 居中框本来就只占屏幕中间一小块，
     抬半个键盘就够把它从键盘上挪开，抬整个会让它贴到状态栏、看着像"跳走了"。
     `--xq-kb` 见 `.drawer-gaozi` 上面那段。 */
  top: calc(50% - var(--xq-kb, 0px) / 2) !important;
  transform: translate(-50%, -50%);
  /* 可视区变矮之后，长内容的确认框也要能滚，不能顶出屏幕。
     上限用 `--xq-vh`（可视视口高）而不是 `100vh`：`vh` 不跟键盘变，理由见 .dialog-gaozi。 */
  max-height: calc(var(--xq-vh, 100vh) - 48px);
  overflow-y: auto;
  z-index: 9000;
}

/* ══ ⑥-a 宿主「不说话」时的兜底抬升（2026-10-14，用户第二次报键盘遮挡）══════
 *
 * 上面那几条（`.drawer-gaozi` / `.dialog-gaozi` / 确认框）全都靠 `--xq-kb`
 * —— 那个变量只在宿主**肯告诉我们**键盘有多高时才不是 0。
 * 安卓 WebView 的 `adjustNothing` / `adjustPan`（以及一部分小程序容器）弹键盘时
 * `innerHeight` 与 `visualViewport` 都不动，`--xq-kb` 永远是 0，
 * 于是板子照旧钉在屏幕底下，输入框被键盘压得死死的。
 *
 * app.js 判定出这种情况会给 <html> 打 `data-xq-kb="lift"`，这里负责"抬"：
 * 底部面板的**底边钉到视口高的 54%**（从下往上），自身最高 40vh
 * ⇒ 整块落在屏幕高 6%~46% 那一段；常规手机键盘的上沿在 55%~65%，
 * 带数字键盘的也多在 50% 上下 ⇒ 至少留 4% 余量。
 * 宁可抬过头一点也不许抬不够：抬过头只是"板子位置偏高"，
 * 抬不够就是输入框又被压住 —— 那正是用户报的那个 bug。
 *
 * ⚠ 只在「宿主不说话」时生效。宿主肯说的那些（安卓浏览器、iOS Safari、
 *   `adjustResize` 的壳）走上面 `--xq-kb` 那一路，板子仍从屏幕底部滑上来 ——
 *   这条不许去动它们，否则等于把好好的底部面板无缘无故顶到半空。
 * ⚠ 抬起来是**整块**抬：内部排版、宽度、抓手与按钮的相互位置一个像素不动，
 *   只是这块板子整体上移。
 * ⚠ Toast 一起抬：键盘压着屏幕下半截的时候，面板自己的校验提示
 *   （「请输入激活码」）正好落在键盘底下，用户什么也看不到 —— 那等于没提示。
 * ★ 2026-10-14 补：这一节原来只覆盖手机档那三种浮层（底部面板 / 抽屉 / 确认框），
 *   漏了**题库详情那三张底部抽屉**（`[data-name="底部容器"]`，第 ⑨ 条）和
 *   **壳自带的提示条**（`.toast`）。用户说「还有一些其他的弹窗类似的」——
 *   按同一条口径补齐：凡是钉在视口底下、又能装下输入框的，都得会躲。
 */
html[data-xq-kb="lift"] .dialog-gaozi,
html[data-xq-kb="lift"] .drawer-gaozi {
  bottom: 54vh !important;
  max-height: 40vh !important;
}
html[data-xq-kb="lift"] [data-name="弹窗/确认"] {
  top: 26% !important;
  max-height: 40vh;
}
/* 题库详情那三张抽屉：容器只占上半屏，面板（flex-end）自然落在 46vh
   ⇒ 常规键盘（上沿 55%~65%）够不着它。`bottom` 让位给 `height`，
   否则 top / bottom / height 三者同时有值时 `bottom` 会被忽略（见第 ⑨ 条）。 */
html[data-xq-kb="lift"] [data-name="底部容器"] {
  bottom: auto !important;
  height: 46vh !important;
}
html[data-xq-kb="lift"] .toast-gaozi,
html[data-xq-kb="lift"] .toast {
  bottom: calc(96px + 54vh) !important;
}

/* ⑥-b 桌面档的确认框（稿子 3-9037「24 · 破坏性操作确认」，2026-10-06 第五十六轮）
 *
 * 上面那一条是**手机档**的口径（311 宽、标题居中、一行说明、两枚等宽按钮）；
 * 桌面档那张规范页定的是另一套（488×270、标题左对齐 22 号粗体、右上有关闭钮、
 * 标题下一条通宽分隔线、说明两行、再加一行红字「影响：…」、按钮是「纯文字取消 +
 * 语义红危险按钮」）—— 那一套在 `routes/admin-dialog.js` 的 `confirmXqBox` 里落。
 *
 * 这里只管**位置**（稿子画的是 absolute，真机上要浮在视口正中，理由跟上面那一条一模一样）：
 *   · 那一组节点装在一个 `.xq-dialog-layer` 里 —— 图层就是稿子那张 1440×900 的纸，
 *     节点坐标**一字不改**照稿子的来；控制器量出卡片的盒子之后，把**整个图层平移**
 *     一下，卡片就落在纸的正中（`left/top` 由控制器写内联）。
 *   · 所以这里只声明图层自己的尺寸与可点性，**一个位置都不写死**。
 *   · ⚠ 图层**不缩放**、也不 `transform` —— 它在 `.screen` 里面，桌面档那张纸
 *     早就被 `admin-desk.js` 的 `applyDeskScale()` 用 `zoom` 缩放过，跟着走才对；
 *     在这儿再写一次 `transform` 会跟 `.screen` 上那条 `transform:none !important`
 *     的既有口径打架（见第五十二轮那段）。
 *   · ⚠ `pointer-events`: 图层自己是**一张透明的纸**，不收点击 —— 否则「点弹窗外面
 *     关掉」就永远收不到那一下（点到的会是这张纸）。真正收点击的是卡片和那几格按钮。
 */
.xq-dialog-layer {
  position: absolute;
  left: 0;
  top: 0;
  width: 1440px;
  height: 900px;
  pointer-events: none;
  z-index: 9000; /* 跟手机档那一枚同一个层号：遮罩是 8999，比它高一层 */
}
.xq-dialog-layer > * {
  pointer-events: auto;
}
/* 「底 + 字」是稿子拆出来的两个**平级**节点（点在字上不冒泡到底）⇒
   可点的这几格光标都要换，不然鼠标移上去还以为是块背景。 */
.xq-dialog-layer [data-name^="关闭钮"],
.xq-dialog-layer [data-name^="关闭字"],
.xq-dialog-layer [data-name^="危险按钮"],
.xq-dialog-layer [data-name^="按钮·取消"],
.xq-dialog-layer [data-name^="按钮字·取消"] {
  cursor: pointer;
}

/* ══════════════════════════════════════════════════════════════════
 * ⑥-b 浮层的**进入过渡**（2026-09-28 第三十五轮）
 *
 * 为什么加（用户原话）：「**每个动画都是硬切**」—— 查实：切屏刚补了 `screenIn`，
 * 而这几层浮层在此之前**一条 transition / animation 都没有**，是 `cloneNode` 之后
 * 直接挂上去、当场满不透明地出现，确实是一次硬切。
 * ⚠ 上面这句里的 `screenIn` 是**当时**的事（2026-09-28）。那一段后来整段拿掉了
 *   （2026-09-29 第四报，见上文 `#app` 那段）：切屏的过渡现在由**垫场层**承担。
 *   这一节讲的浮层过渡跟它没关系，不受影响 —— 只是别被上面那半句带偏。
 * 量过在册的浮层一共五处：抽屉（`.drawer-gaozi`）/ 底部面板（`.dialog-gaozi`）/
 * 确认框（`[data-name="弹窗/确认"]`）/ 遮罩（`[data-name="遮罩"]`）/ Toast（`.toast-gaozi`）。
 * 后台抽屉（`.admin-nav`）本来就有，不在这一批里。
 *
 * ⚠ 为什么可以直接写 `animation` 而不用「先加类名再挂」那套：
 *   · 这五处**每次都是新建的节点**（`cloneNode(true)` 后 appendChild，
 *     见 `routes/quiz.js`、`routes/cards.js`、`admin-dialog.js`），
 *     CSS 动画在元素首次进入文档时自动开始，不需要谁去补一个类。
 *   · Toast 是**复用**的（`mountToast` 挂一次、之后靠 `hidden` 开关），
 *     而 `hidden` → `display:none` → 显示**会重启** CSS 动画（CSS Animations 规范），
 *     所以每一次 `flash()` 都能重放。
 *
 * ⚠ 只动 `translate` / `scale` / `opacity` 这三个**独立变换属性**，**绝不写 `transform`**：
 *   这几层身上都已经有 `transform`（`.toast-gaozi` 是 `translateX(-50%)`、
 *   确认框是 `translate(-50%,-50%)`、后台的面板是 `translateX(-50%)`）——
 *   `@keyframes` 里再写 `transform` 会把它整条**顶掉**，面板会当场横向跳回左对齐。
 *   独立属性（`translate`/`scale`）跟 `transform` 是**相乘的两段**，互不覆盖；
 *   而且本项目本来就在用它（`app.css` 的 `transition: scale 120ms`、`scale: 0.98`）。
 *   浏览器门槛：Chrome 104+ / WebView 104+（项目既有的按下态用的就是它）。
 *
 * ⚠ 时长与曲线跟**后台抽屉**那一条取同一个值（220ms / `cubic-bezier(.22,.61,.36,1)`）——
 *   它不是新造的一档，是这套界面里「一整个面板推进来」的既有口径（见 `.admin-nav-panel`）。
 *
 * ★ 2026-09-29（第四报的插曲，记一笔免得下次重走）：用户那句「**其他界面的这种会抖动
 *   动画也不要**」我当成了这一节，把四条帧全换成只淡入的 `fadeIn`；他随即澄清
 *   「**我说的是，点按动画非弹窗加载，按钮的**」——他指的是按下态，不是浮层。
 *   **本节已原样退回**（帧名与时长的值都没动）。
 *   顺手留一条量测底账，免得下次又把它当 bug 来「修」（`tools/build/probe-overlay-motion.py`）：
 *       底部抽屉 28.0px ／ 底部面板 28.0px ／ 确认弹窗 缩 0.94（上沿 6.0px）／ 提示条 10.0px ／ 遮罩不动
 *   **这四处是「一层抽屉从屏底推上来 / 弹窗弹出来」的正常交代**，和「换屏时整个窗口
 *   下沉 16px」不是一回事 —— 那个已经锁死在 `#app` 上。分得清的标准就一条：
 *   **动的那个元素自己没占满屏幕，就不叫「整个窗口在晃」。**
 * ══════════════════════════════════════════════════════════════════ */
@keyframes sheetIn {
  from { translate: 0 28px; opacity: 0; }
  to   { translate: 0 0;    opacity: 1; }
}
@keyframes popIn {
  from { scale: .94; opacity: 0; }
  to   { scale: 1;   opacity: 1; }
}
/* 桌面档的确认框：跟着上面那条 `popIn` 的口径（同一档时长与曲线），
   但它自己身上没有 `transform` 定位的包袱 —— 位置是控制器写在图层上的 `left/top`。
   只淡入 + 一点点下落：它是「弹出来的」，不该看着像整页在动（同上 §⑤ 的判断）。 */
@keyframes dialogIn {
  from { translate: 0 8px; opacity: 0; }
  to   { translate: 0 0;   opacity: 1; }
}
@keyframes maskIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes toastIn {
  from { translate: 0 10px; opacity: 0; }
  to   { translate: 0 0;    opacity: 1; }
}

.drawer-gaozi,
.dialog-gaozi {
  animation: sheetIn 220ms cubic-bezier(.22, .61, .36, 1) both;
}
[data-name="弹窗/确认"] {
  animation: popIn 200ms cubic-bezier(.22, .61, .36, 1) both;
}
.xq-dialog-layer {
  animation: dialogIn 200ms cubic-bezier(.22, .61, .36, 1) both;
}
/* 遮罩只淡入：它铺满整屏，位移会让人以为整页在动。 */
[data-name="遮罩"] {
  animation: maskIn 220ms cubic-bezier(.22, .61, .36, 1) both;
}
.toast-gaozi {
  animation: toastIn 200ms cubic-bezier(.22, .61, .36, 1) both;
}
/* ⚠ `animation` 用的是 `both` 收尾态（`translate:0 0` / `opacity:1`），它的优先级
   高于普通声明（只输给 `!important`）⇒ **将来谁想用内联 `opacity` 让这一层淡出，
   会被这个收尾态静默吃掉**。现在这五处都是「关掉 = `.remove()` 整块摘掉」或者是
   Toast 那种 `hidden`（`display:none` 压根不渲染），所以没有冲突；
   要加退场动效的话得走「加类名 → `transitionend` 再 remove」，不能靠写内联样式。
   ⚠ 另外 `admin-dialog.js:634` 在**后台分栏态**给的是 `dialog-inline` 而不是
   `dialog-gaozi` ⇒ 桌面上那个内联面板**不会**跟着滑上来（那是嵌在分栏里的一块，
   不是从屏底推上来的浮层），这是有意的。 */

@media (prefers-reduced-motion: reduce) {
  .drawer-gaozi, .dialog-gaozi, [data-name="弹窗/确认"],
  [data-name="遮罩"], .toast-gaozi, .xq-dialog-layer { animation: none; }
}

/* ⑦ 搜索框的输入位。跟 ③ 填空的输入框同一个道理：稿子画的是「占位」那个 div，
 *    真机上必须能真输入，所以控制器把它换成了真的 <input>，class 沿用稿子的那一份。
 *    这里只补 div 不需要、input 才需要的几条（它默认不撑开、自带边框和 outline）。 */
input[data-name="搜索输入"] {
  flex: 1 1 0;
  min-width: 0;
  /* 热区：**撑满外层搜索条的整条高**（那条是 44，HIG 红线）。
     input 自己只有 ~21 —— 旁边图标和留白点得中、输入框那一段却点不中，
     手指按在输入行上半截是空的。align-self 把它拉成通高，文字仍随 input
     垂直居中（Chrome 的 input 本来就居中），绘制位置一分不动。
     （2026-10-12 版式审查报的「搜索输入 高只有 21」就是这一条。） */
  align-self: stretch;
  border: 0;
  background: transparent;
  outline: none;
  padding: 0;
  font: inherit;
  color: inherit;
}

/* ⑧ 滚动容器的直接子节点，**按稿子画的高度画，不许被压扁**。
 *
 * 这条修的是一个已经发生过、而且闸门量不到的真 bug：错题本上线的版本里，
 * 顶上的分段控件实际只有 8px 高、搜索框 0px（整条看不见），
 * 但页面「无报错、卡片数对、点击有变化」—— 三条断言全是绿的。
 *
 * 原理：内容区（[data-scroll="1"]，稿子给它写的是 flex:1 min-height:0）
 * 是纵向 flex 容器。子节点里凡是**没有 flex-shrink:0** 的，都要去吸收溢出量：
 * 卡片那种稿子自带 flex-shrink:0 的完好无损，而分段控件 / 搜索框这种没带的，
 * 就被压成一条缝 —— 溢出量越大压得越狠，所以「数据一多，顶上就扁了」。
 *
 * 为什么设计稿上看不出来：稿子画布是固定 393×852 的**一张照片**，
 * 内容刚好装得下，flex 没有任何溢出可分配。ardot 里也没有「这是个滚动容器」这个概念，
 * 所以这件事只能在这里落。**这条不改任何稿子画的值** —— 每个人的高度还是稿子写的高度，
 * 改的只是「谁去吸收溢出」：从「把上面的控件压扁」改成「让内容区滚」。
 *
 * 三种线上路由都量过：纠错管理和刷题页的直接子节点稿子本来就带了 flex-shrink:0，
 * 加这条前后逐个小节点的高度一模一样（见 tools/build/squash-probe.py）。
 */
[data-scroll="1"] > * { flex-shrink: 0; }

/* ⑨ 底部抽屉（题库详情的三张：重刷确认 / 练习设置 / 题库介绍）。
 *
 * 稿子那三帧画的是「底部容器（占满画布、justify-content:flex-end）+ 面板」，
 * 但 ardot 里没有「固定定位」这个概念，它记的是 `position:absolute;top:0;height:852px`
 * —— 852 是设计画布的高，真机上就是「一个 852 高的盒子按文档流排下去」，
 * 抽屉会跑到屏幕外面去。所以这里把它钉在视口上，跟遮罩（规则③）同一个道理。
 *
 * pointer-events 那一对是必须的：容器占满全屏，不加就等于在遮罩上面盖了一层
 * 透明玻璃 —— 点面板**外面**的空白处会落到容器上，遮罩收不到那一下，
 * 「点空白关掉抽屉」就失灵了。所以容器本身不收事件，只有它的子节点（面板）收。
 *
 * ⚠ 全站只有这三帧用这个图层名（题库详情那三张抽屉），已核对。
 */
[data-name="底部容器"] {
  position: fixed !important;
  left: 0 !important;
  right: 0 !important;
  top: 0 !important;
  /* ★ 2026-10-14 键盘避让（见 ⑥-a）。原来写的是 `inset:0` + `height:100dvh`，
     而 `dvh` **不跟软键盘变** ⇒ 键盘一弹，这个盒子仍旧盖满整屏，里面的面板
     （justify-content:flex-end）被钉在**屏幕**底下 —— 面板里的输入框照样被压住。
     改成「上边钉 0、下边让开键盘高」，盒子高由这两者相减得出 —— 所以 `height`
     必须是 `auto`：top / bottom / height 三者同时有值时，`bottom` 会被静默忽略。
     没有键盘时 `--xq-kb` 是 `0px` ⇒ 与原来的 `inset:0` 完全等价，一个像素不动。 */
  bottom: var(--xq-kb, 0px) !important;
  height: auto !important;
  z-index: 9000;
  pointer-events: none;
}
[data-name="底部容器"] > * { pointer-events: auto; }

/* ⑩ 题库详情那张概览卡上的简介，最长两行。
 *
 * 稿子画的那行样文只有一句（「国家公务员考试行测专项题库，覆盖五大模块」），
 * 而真描述可以是几百字 —— 卡片会被撑开、把进度环和三格统计挤走。
 * 按 HIG 的规矩，正文不折叠到看不见，但卡片里要收敛：这里限两行 + 省略号，
 * **全文去哪儿看**由稿子上那枚「展开」回答（它开的是「题库介绍」那张抽屉，
 * 旧前端也是这一下）。
 *
 * 为什么不在稿子里改：这是**同一条数据的两种呈现**（卡上摘要 / 抽屉全文），
 * 属于代码阶段的事，跟解析卡那条（规则②）同一个理由。
 * 判据不是 maxLines —— ardot 那个字段的默认值就是 1，全稿都写着 1，不是设计意图。
 */
[data-clamp="2"] {
  display: -webkit-box !important;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

/* ⑪ 练习记录那一页的四样代码件（稿子里都没有，都是控制器按数据现造的）。
 *
 * 为什么会有它们：那张稿子画的是「一次练习的战果」（已完成徽标 + 已答/答对/答错），
 * 而这一页的数据源是**每题一条**的 QuestionRecord，聚合数根本没有，
 * 题干和正确答案也不在记录里（详见 routes/records.js 文件头 ①②③④⑤）。
 * 于是：汇总那行多一枚「清空」（旧前端就在那儿）、卡片里多一格题干（从错题卡借的）
 * 和一行「你的答案 / 正确答案」、翻页时多一枚「加载更多」。
 *
 * 四样都用**设计系统自己的令牌**上色，不新造颜色；徽标那两条是把稿子那枚中性徽标
 * 的两态就地改色（--cl-success-50/700、--cl-danger-50/700），
 * 因为稿子里没有可以整枚换的绿/红徽标帧。 */

/* 汇总行：稿子画的是单独一行小字，这里把它包成一行，右边挂「清空」。
 *
 * ⚠ 那对 13 / -13 不是为了好看，是给「清空」腾**44px 热区**（HIG：可点区域不小于 44）
 *   又**不让它在版式里变高**。内边距把这一行的盒子撑到 45px，等量负外边距把它在
 *   文档流里占的高度还原成 19px —— 卡片的位置跟稿子画的一模一样。
 *
 *   为什么不用规则⑤那套「::after 撑热区」：绝对定位的伪元素会把这一行的
 *   **scrollHeight 顶到 32**，而闸门把「滚动容器的子节点比自己的内容矮」判成压扁。
 *   那会是一条**假红** —— 这一行没被压扁，是热区溢到盒子外面去了。
 *   （这是真的踩到的：第一次跑闸门就是 s=error 那条红在这儿。）
 *   改成内边距之后，热区是这个盒子**自己**的尺寸：量得准，也真的点得到。
 */
[data-role="汇总行"] {
  display: flex;
  flex-direction: row;
  align-items: center;
  justify-content: space-between;
  gap: var(--sp-3);
  align-self: stretch;
  flex-shrink: 0;
  padding: 13px 0;
  margin: -13px 0;
}
/* 稿子那行小字自己的类是 align-self:stretch（它在稿子里是独生子），
   包成一行之后要跟着居中，不然会贴在行的顶上都不到底。 */
[data-role="汇总行"] > [data-name="汇总"] { align-self: center; }

/* 「清空」是个纯文字按钮 —— 稿子没有这一枚（9:38 只画了确认框，没画入口）。
   高度跟着那一行撑满（45px），整块都是可点的；文字本身仍然只有 19px 高。 */
[data-role="清空"] {
  align-self: stretch;
  display: flex;
  align-items: center;
  flex-shrink: 0;
  color: var(--cl-danger-700);
  font-size: var(--fs-caption);
  font-weight: var(--fw-medium);
  line-height: 19px;
  cursor: pointer;
}

/* 答案行：「你的答案」按对错上色，「正确答案」恒为绿。
   两行都是 12px —— 卡片的正文是题干，这两行是注解，不该跟题干抢。 */
[data-role="答案行"] {
  display: flex;
  flex-direction: column;
  gap: 2px;
  align-self: stretch;
  flex-shrink: 0;
  font-size: var(--fs-caption);
  line-height: 19px;
}
[data-role="答案行"] [data-r="mine"] { color: var(--cl-text-secondary); }
[data-role="答案行"] [data-r="mine"][data-ok="0"] { color: var(--cl-danger-700); }
[data-role="答案行"] [data-r="mine"][data-ok="1"] { color: var(--cl-success-700); }
[data-role="答案行"] [data-r="right"] { color: var(--cl-success-700); }

/* 记录卡上那枚徽标的两态。稿子挂的是 Component/Badge/Neutral（中性灰 + 样文「已完成」），
   改的是它的底色和字色两处 —— 结构与尺寸一个字不动。 */
[data-name="记录卡片"] [data-result="ok"] { background: var(--cl-success-50) !important; }
[data-name="记录卡片"] [data-result="ok"] > * { color: var(--cl-success-700); }
[data-name="记录卡片"] [data-result="no"] { background: var(--cl-danger-50) !important; }
[data-name="记录卡片"] [data-result="no"] > * { color: var(--cl-danger-700); }

/* ⑫ 刷题报告那一页卡头上的一行（稿子里没有这一层）。
 *
 * 为什么会有它：稿子 2:1731 画的是一张**统计看板**（分段控件 + 累计卡 + 四格 + 分布卡），
 * 而这一页要的是**报告列表** —— 稿子没有画「列表里的报告卡」。卡的壳、四格、按钮行、
 * 按钮、三种文字样式**全部取自稿子**（2:1656 的四格统计卡 / 按钮行 / 主按钮，
 * 11:315 的危险按钮），只有卡头这一行是代码摆的：左边题库名 + 章节·时间，
 * 右边正确率大数字 + 「正确率」。整件事的来龙去脉见 routes/reports.js 文件头。
 *
 * ⚠ 这里只写「谁在哪儿」，不写任何字号/字色/字重 —— 那三样是稿子给的，
 *   代码拿的是稿子那三个节点的 class（n-2-1748 / n-2-1662 / n-2-1661），
 *   所以字号改了这里不用动。 */
[data-role="报告卡头"] {
  display: flex;
  flex-direction: row;
  align-items: flex-start;
  justify-content: space-between;
  gap: var(--sp-3);
  align-self: stretch;
  flex-shrink: 0;
}
[data-role="报告卡名"] {
  display: flex;
  flex-direction: column;
  gap: var(--sp-1);
  flex: 1 1 0;
  min-width: 0;
}
/* 题库名和「章节 · 时间」都只占一行，长了省略。
   题库名可以很长（真库里有「2024年国家公务员录用考试行政职业能力测验」这种），
   撑开会把右边那个正确率挤出卡片 —— 全文在题库详情那一页，不在这里塞。 */
[data-role="报告卡名"] > * {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* 正确率那枚大数字的基线跟「正确率」三个字对齐 —— 大数字的行高是 41px，
   居中对齐的话小字会浮在半空。 */
[data-role="报告卡率"] {
  display: flex;
  flex-direction: row;
  align-items: baseline;
  gap: var(--sp-1);
  flex-shrink: 0;
}

/* ⑬ 登录 / 注册 / 找回密码的输入位。
 *    跟 ③（填空）、⑦（搜索框）是同一件事：稿子画的是「占位」那个 div，
 *    真机上必须能真输入，所以控制器把它换成了真的 <input>，class 沿用稿子那一份。
 *    这里只补 div 不需要、input 才需要的几条（它默认不撑开、自带边框和 outline）。
 *    ⚠ 名字没用「作答输入」那一个：那个名字在刷题页是「答题」的意思，
 *      登录页借用它会让以后看代码的人以为这一页在答题。
 *    占位字的颜色单独补一条 —— 稿子那份 class 的 color 是给「还没输入时」用的
 *    （--cl-text-secondary），而输入之后要的是值该有的颜色（--cl-text-primary，
 *    见错误帧里「值」那一格）。<input> 自己分不出这两种，得分开写。 */
input[data-name="登录输入"] {
  flex: 1 1 0;
  min-width: 0;
  border: 0;
  background: transparent;
  outline: none;
  padding: 0;
  font: inherit;
  color: var(--cl-text-primary);
}
input[data-name="登录输入"]::placeholder {
  color: var(--cl-text-secondary);
  opacity: 1;   /* Firefox 默认给占位字加透明度，会把稿子的 secondary 又调淡一档 */
}

/* ⑭ 底部胶囊导航：**浮在页面上**的液态玻璃（用户点名要的，稿子没画过这一版）。
 *
 *    认锚用图层名 `[data-name^="TabBar/"] [data-name="胶囊"]`：TabBar 的三种变体
 *    分别叫 TabBar/首页选中 · /公告选中 · /我的选中，胶囊在三种里是同一颗。
 *    稿子那颗是**纯白**（--cl-bg-card）+ 一圈中性边 + 一道紫色外投影。
 *
 *    ★★ 2026-09-25 用户报：「胶囊bar 是个假的胶囊，胶囊之外还有一个横条做底，
 *       没有镂空效果，胶囊应该是浮于页面」。量完之后，他说的是对的 ——
 *       而且病根比"玻璃调得不够"深一层：
 *
 *       TabBar 原来**在版式里占位**（.n-8-1007 是 height:95px 的普通流元素，
 *       .screen 的内容区顶到它上面就为止了，实测滚动区高 757 = 852-95）。
 *       于是胶囊**后面永远只有页根那一块纯色 #FAFAFF**。
 *       backdrop-filter 模糊一块纯色，结果还是那块纯色 —— 玻璃的「镂空」这一半
 *       在数学上就不可能发生（旧版这里写着"一点都看不见"，那句话当时是对的）。
 *       同时，那 95px 的占位带虽然自己 transparent，但它把内容**截断**在胶囊上方，
 *       看上去就是「胶囊底下垫着一条横条」—— 用户看到的横条就是它。
 *
 *       所以他说的两件事是同一件事：**胶囊不浮起来，就没有镂空**。
 *
 *    做法：TabBar 从版式里抬起来（app.css ⑭-b 那两条），absolutely 钉在 .screen 底边。
 *      内容区于是长到整屏高，卡片从胶囊**下面**滚过去 —— 模糊这才有东西可揉。
 *      空出来的那 95px 由内容区自己的 padding-bottom 补回来（[data-tabpad]），
 *      不然最后一行会被胶囊压住。
 *      ⚠ 这两件事必须成对做，只浮不补 = 内容末尾永远够不着；
 *        只补不浮 = 白多一块空白，还是老样子。
 *      ⚠ 标记是 view.js 的 adaptCanvas() 贴的，**不是写死在这个文件里**：
 *        只有真带 TabBar 的那四页（首页/公告/我的/资料，稿子 2-1224 / 8-941 / 8-939 /
 *        8-935 那一族）才该浮，刷题页、后台那些页没有 TabBar，
 *        无条件给所有 [data-scroll] 加 95px 底距会把它们的版式顶坏。
 *
 *    ⚠ 半透明**没有**用 color-mix 去调 --op-mask：那个令牌生成时被塞了 px 单位
 *      （0.44999998807907104px），拿它做 alpha 会变成 0（全透明）。
 *      这里直接写 rgba，色相就是 --cl-bg-card 那个白，只是透到 72%。
 *    ⚠ 稿子原来那条 inset 1px 的边**留着**，没有换成纯白亮边 ——
 *      浮起来之后玻璃底下是滚动的内容，边就是这颗药丸的轮廓，更要留着。
 *      玻璃是**加在稿子之上**的一层，不是把稿子改掉。
 *    ⚠ 选中项那一格（导航项/首页 之类）**不动**：用户说的是「只把胶囊做成玻璃」，
 *      实心紫的选中块照旧。 */
[data-name^="TabBar/"] [data-name="胶囊"] {
  /* ★★ 2026-10-19 用户第三次动这颗胶囊：「它是给颜色了吗？我怎么感觉它有点
     渐变的颜色」。他看到的确实是渐变，而且来源不是某一条声明，是**三条叠出来的**：
       ① backdrop-filter 的 saturate(200%) —— 模糊的同时把底下内容的颜色**加饱和**，
          首页滚到紫色 Hero 那一段时，胶囊上缘就泛紫、往下越滚越淡 = 一条竖向渐变；
       ② 底缘那道 inset 0 -1px 的品牌紫 —— 跟 ① 同方向，
          等于把「上紫下白」这个渐变又描了一遍；
       ③ 顶上那道 inset 0 1px 的纯白顶光 —— 又把上端往白拉了一次。
       三条都在「把上下两端往不同颜色拉」，所以它永远看起来像渐变，而不是一块颜色。

     结论：**导航条不该跟着它底下滚过什么而变颜色**。它是常驻控件，不是内容。
     所以这一版改成**不透明白底 + 一圈描边**（跟列表卡同一个色值），把上面三条
     全部撤掉，外投影留着（那是它「浮在内容之上」的唯一信号）。
     顺带省掉每帧的 backdrop 合成 —— 这颗胶囊在页面最底层、又常驻，
     滚动时一直在重算，本来就不便宜。

     ⚠ 这不是把「液态玻璃」退回去当成做错：玻璃的前提是**底下有东西可透**，
       而首页那块底是纯白 + 一张紫卡，透上来的只会是紫，不是层次。
       真要玻璃感，得先让页面底有纹理（见 8910 那段 2.0 的失败记录），
       那是改整个底的事，不是调这条能解决的。
     ⚠ 选中项那一格（导航项/首页 之类）不动：实心紫的选中块照旧，它是状态，
       不是材质。 */
  background: var(--cl-bg-card);
  box-shadow:
    inset 0 0 0 1px var(--cl-border-base),        /* 轮廓：撤掉玻璃之后它才是边界 */
    /* ★ 2026-10-20 用户两轮反馈：第一轮「太浓范围太大」原 0 6px 20px / .14 收到
       0 4px 12px / .10；第二轮「还是有点浓，加一个淡淡的投影」再收到 0 1px 4px / .07。
       ⚠ 这是**回落层**（玻璃不可用时才生效）；真正常用的量测看 8830 那段
         生效层（!important），两处要一起改，别只收一处。 */
    0 1px 4px 0 rgba(123, 97, 255, 0.07),
    0 1px 2px 0 rgba(28, 27, 34, 0.04);           /* 贴地一层，压住边缘的虚 */
}

/* ⑭-b 让 TabBar 真的「浮」起来 —— 见上面那段。
 *
 * 标记由 view.js 的 adaptCanvas() 贴：只有这一屏真有 TabBar 时才贴 float，
 * 所以这里两条规则只会命中那四页（外加它们的空/错/加载态帧）。
 *
 * ⚠ 定位基准是 .screen（app.css 里给它加了 position:relative），不是页根：
 *   真机上两者一样大，但桌面上 .screen 是 max-width:393px 居中的那一列 ——
 *   挂在页根下面的话，left:0;right:0 会按**整扇窗**撑开，胶囊跑到屏幕两头去。
 * ⚠ pointer-events 必须分三层写：TabBar 和「栏容器」都是**整幅宽**的，
 *   让它们可点就等于在内容上盖了一条 95px 高的隐形玻璃板 ——
 *   底下的卡片点不动，而且完全看不出为什么。只有那颗药丸自己收点击。 */
[data-float="1"] {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 20;
}
[data-float="1"],
[data-float="1"] [data-name="栏容器"] {
  pointer-events: none;
}
[data-float="1"] [data-name="胶囊"] {
  pointer-events: auto;
}
/* 内容区把不再占位的那 95px 自己留出来（TabBar 的原高，见稿子 .n-8-1007）。
   用 padding 不用 margin：padding 在滚动容器里是能被滚到的，
   最后一屏内容滚到底之后正好落在胶囊上沿以上。 */
[data-tabpad="1"] {
  padding-bottom: 95px !important;
}

/* ⑮ 刷题页顶栏那格题库名：长名字收省略号（HIG）。
 *    稿子给「左槽」（15:24;15:3）标了 clipsContent、给了 overflow:hidden ——
 *    **裁掉是稿子的意思**，这条不跟它争。争的是「怎么裁」：
 *    题库名这格是 flex-shrink:0 的自然宽，名字一长就从格子边缘**硬切半个字**，
 *    连省略号都没有，右边还紧贴着「刷/背」那一段 —— 用户读到的是「题库名就到这儿」。
 *    （实测截图 shots-app/probe/topbar-longname-当前.png：换成真题集全名后
 *      「2024年国家公务员录用」戛然而止。）
 *    所以这里只加三件事：不折行、裁掉的部分用省略号收尾、允许这格被压窄。
 *    一个字都不改，也不比稿子多藏东西 —— 稿子本来就把它裁掉了，省略号只是把
 *    「这里被截断了」这个信息还给用户。
 *    ⚠ flex-shrink 必须 !important：稿子的 .n-15-24__15-10 写着 flex-shrink:0，
 *      而稿子 CSS 是运行时后加载的，同特异性下它赢；不给 0 的话这格永远按自然宽
 *      铺开，省略号轮不上（压不窄就不会触发）。
 *    ⚠ 类名由 routes/quiz.js 的 paintHead 挂，**不写成 [data-name="题库名"]**：
 *      别的页面也有叫「题库名」的节点（9-187 那个是页面上方的大标题，本来就该折行）。 */
.quiz-bankname {
  flex-shrink: 1 !important;
  min-width: 0;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* ⑯ 同一格里那枚返回箭头：**不许跟着一起被压**。
 *
 *     这是 ⑮ 的**连带**，两条必须成对存在：左槽是个 flex 行，负空间是浏览器
 *     按「基础宽度 × flex-shrink」摊派的。⑮ 把题库名那格的 flex-shrink 放开成 1
 *     之后，箭头就成了同一行里另一个可收缩的项 —— 稿子没给它写过 flex-shrink
 *     （它那句样例名 112px 刚好放得下，轮不到收缩），默认值就是 1，于是跟着缩。
 *
 *     实测（tools/build/probe-quiz-header.py）：题库名换成真题集全名时，
 *     箭头从 24px 被压到 **10.19px**（一个扁成一条的箭头，还带 overflow:hidden），
 *     题库名同时从 x=44 左移到 30.19 —— 也就是**挪过去盖在箭头上**。
 *     这就是用户看到的「顶部栏不对齐」。
 *
 *     ⚠ 只留这一条不行（名字又会硬切半个字，见 ⑮），只留 ⑮ 也不行（箭头压扁）。
 *     ⚠ 这里不需要 !important：稿子那条图标规则只写了 width/height/display，
 *       没写 flex-shrink，不存在同属性竞争。 */
.quiz-backicon {
  flex-shrink: 0;
}

/* ⑰ 稿子那枚 Toast 里的图标（12:74 的 12:157;2:977）：**不许被长文案压扁**。
 *
 *     又一条 ⑯ 那种事，同一个毛病落在提示条上：稿子的样例话是「已收藏」三个字
 *     （42px），放得下，所以稿子 CSS 只给图标写了 width/height，**没写 flex-shrink**
 *     —— 默认 1，可缩。提示条这一行的宽度是稿子写死的 280px，一旦话长了……
 *
 *     实测（tools/build/probe-fav-popup.py）：把话换成一句 63 字的报错文案，
 *     那枚 24×24 的图标被压成 **0px** —— 整枚消失，只剩一句话贴在最左边。
 *     这就是用户看到的「收藏弹窗问题」里那一条。
 *
 *     ⚠ 又不给 !important：稿子那条图标规则只写了 width/height/display，
 *       没写 flex-shrink，不存在同属性竞争（⑯ 同理）。
 *     ⚠ 类名由 app/toast-gaozi.js 挂 —— 不能写 [data-name="Icon/CheckCircle"]：
 *       否定的话换的是 Icon/CloseCircle，按图层名选会漏掉一半。 */
.toast-gaozi__icon {
  flex-shrink: 0;
}

/* ⑱ 同一枚 Toast 里那句话：长文案要**折行**，不是被裁掉。
 *
 *     稿子给提示条写了 `width:280px` + `overflow:hidden`，文字格写着
 *     `flex-shrink:0` —— 短话（「已收藏」）看着没问题，长了就是裁掉，
 *     而且**连省略号都没有**：实测那句 63 字的报错文案，文字框算出来 594.9px 宽，
 *     卡片只到 280px，**右边 345px 是无声无息没的**，用户读到的是
 *     「题库名没取到（左槽写「练习」）：Reque」戛然而止。
 *     截图：shots-app/probe/fav-弹窗-长文案.png。
 *
 *     HIG 对这类「浮在内容上的短消息」的要求是**不要截断**（截断了就等于没说完），
 *     何况这些话里有接口报错，用户要照着它判断出了什么事。
 *     所以这里让文字格可收缩、并在卡片宽度内折行 —— 卡片宽度**仍然是稿子的 280px**，
 *     只有高度按内容长（稿子本来也没写高度，是内容撑的）。
 *
 *     ⚠ flex-shrink 必须 !important：稿子的 .n-12-157__2-978 写着 flex-shrink:0，
 *       而稿子 CSS 是运行时后加载的，同特异性下它赢 —— 不给 !important 压不窄、
 *       压不窄就不折行（⑮ 踩的是同一个坑）。
 *     ⚠ min-width:0 不能少：可收缩的 flex 项默认最小宽度是 max-content，
 *       不加这条它还是不肯窄到能折行的宽度。
 *     ⚠ overflow-wrap:anywhere 是给那种一长串没有空格的报错文案（HTTP 报文、
 *       接口路径）用的 —— 没有它，一个长 token 会顶出卡片而不是折行。 */
.toast-gaozi__msg {
  flex-shrink: 1 !important;
  min-width: 0;
  overflow-wrap: anywhere;
}

/* ⑲ 单行文字槽（题库名 / 章节名 / 文件名）：长名字也收省略号。
 *
 *     跟 ⑮ 是**同一条规则**，落在别的几格上 —— 没合并成一条，是因为 ⑮ 的说明
 *     只对刷题页顶栏成立（那一格的裁是稿子的 clipsContent，截图也是那一张）；
 *     两条各自独立、各自可回退，不会因为改一处牵连另一处。
 *
 *     稿子给这几格的样例都是短名字（章节名那句「言语理解与表达」8 个字），
 *     所以稿子 CSS 里既没写省略号、文字也不许缩（flex-shrink:0）——
 *     可用户自己建的题库 / 章节 / 文件名比这长得多：
 *     实测（tools/build/probe-longname-slots.py，30 字的章节名）：
 *       题库详情·顶部题库名  右缘 463.1 vs 格 329      —— 越出 134.1
 *       题库详情·章节名      右缘 479.2 vs 格 285.6    —— 越出 193.6
 *       管理·题库详情·章节名  右缘 411.9 vs 格 361      —— 越出 50.9
 *       管理·资料·文件名      右缘 474.8 vs 格 361      —— 越出 113.8
 *     格子都是 overflow:hidden，于是**硬切半个字**、没有省略号 —— 用户读到的是
 *     「名字就到这儿」，而不是「这里被截断了」。HIG 要的是后者。
 *
 *     ⚠ 只修**量出来真会变长**的那几格。audit-hig-overflow.py 第二遍会报 175 处，
 *       但绝大多数是死文案（「上一页」「删除」「第 1 题」「2026-09-21」），
 *       服务器永远不会把它们变长 —— 按「这一格装的是不是服务器给的自由文本」筛完，
 *       真会变长的就是上面这四处（题库名、章节名×2、文件名）。
 *     ⚠ flex-shrink 必须 !important：稿子那些文字格写着 flex-shrink:0，
 *       稿子 CSS 是运行时后加载的，同特异性下它赢（⑮ 踩过同一个坑）。
 *     ⚠ 类名由各控制器挂（bank.js / admin-bank-detail.js / admin-resources.js），
 *       不按 [data-name="字段1"] 这类名字选 —— 「字段1」在后台十几张卡上都叫这个名，
 *       含义各不相同，按名字选会连日期、计数那些格子一起改掉。 */
.slot-1line {
  flex-shrink: 1 !important;
  min-width: 0;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* ⑳ 后台表单（app/routes/admin-dialog.js）。**这是唯一一条没有稿子的版式**，
 *    所以要说清楚它为什么非写不可、以及它**没**做什么决定。
 *
 *    后台有 15 处按钮点下去没地方去（新建题库／编辑公告／上传资料／封禁用户……），
 *    后端接口全都在，缺的只有表单 —— 设计一张都没画。2026-09-24 用户拍板照旧后台补。
 *    表单里能搬的零件全是从稿子里 clone 的（面板/标题/关闭/输入框/下拉/按钮，
 *    见 admin-dialog.js 文件头那张表），**颜色、字号、圆角一个都没在这儿写**。
 *    剩下两件事 ardot 表达不了，只能落在 CSS：
 *
 *    1) 一行的方向。稿子里没有「字段」这个容器 —— 15-111 是「类型提示」和
 *       「Component/Input/Default」两个**平级**节点前后挨着，靠面板自己的 gap 分开的。
 *       一行字段要么横排要么竖排，这是布局事实，不是设计决定；竖排（标签在上、
 *       控件在下）跟稿子里那两句提示的位置关系一致。行内间距取 var(--sp-2)：
 *       15-111 的标题行内部就是 sp-2，跟面板的 sp-4 一起正好是稿子的节奏。
 *    2) 按钮等宽。9-33 那两枚的 `flex:1 1 0` 写在**各自的 class** 上
 *       （.n-9-37__2-1140 / __2-1141），而从 15-111 换一枚主按钮进来就不带这条了，
 *       于是一边撑满、一边只有字宽。这里按稿子那两枚的做法补上等宽。
 *    3) 多行框的高度放开（见下面那条，稿子画死 88 高 + 隐藏溢出）。
 *    4) 选项行的三个零件排成一行（徽标/输入框/删除图标），以及那枚 24px 图标的
 *       命中区撑到 44（跟 ⑤ 一套做法）。这三样在稿子里从来没同框出现过
 *       —— 编辑题目这一屏设计没画，所以只能照着稿子各元件的性格摆。
 *    这些类只挂在后台表单自己的节点上（.admin-field / .admin-form-actions /
 *    面板里的 Icon/Close），对稿子本身、对别的页面零影响。
 *
 *    ⚠ 上面这段注释原来**少了一个收尾**（CSS 注释是「一路读到第一个收尾符为止」），
 *      于是它顺带吃掉了下一条注释的开头 —— 文件照样能解析、样式一条不少，
 *      只是看着像少了一行说明，而且以后每改一次都容易再错一次。已补上收尾。 */

/* 一行字段：标签在上、控件在下 */
.admin-field {
  display: flex;
  flex-direction: column;
  gap: var(--sp-2);
  align-self: stretch;
}

/* 按钮行：两枚等宽（照 9-33 的「Frame 78」） */
.admin-form-actions {
  display: flex;
  flex-direction: row;
  align-items: center;
  gap: var(--sp-3);
  align-self: stretch;
}
.admin-form-actions > * { flex: 1 1 0; min-width: 0; }

/* 药丸在这几屏的稿子里躺在**横排**（工具行）里，`flex:1 1 0` 管的是**宽度**；
 * 搬进竖排的 .admin-field 之后同一个 flex 管的就是**高度** —— 于是被压成 0：
 * 实测（probe-admin-feedback-edit）原生 select 量到 h=0，**点不动、也选不了**，
 * 界面上就是「这一格空着」。所以在这儿把主轴尺寸交还给它自己的 height。
 * 宽度不用管：.admin-field 是 stretch，它自己撑满。 */
.admin-field > [data-name^="Component/Select/Default"] { flex: 0 0 auto; }

/* 选项行（编辑题目用）：[字母徽标][输入框][删除图标] 一行。
   徽标和图标都是稿子里的元件，宽度是稿子给的，**不许被输入框挤瘦**。 */
.admin-optrow {
  display: flex;
  flex-direction: row;
  align-items: center;
  gap: var(--sp-2);
  align-self: stretch;
}
.admin-optrow > [data-name^="Component/Badge"],
.admin-optrow > [data-name="Icon/Close"] { flex-shrink: 0; }

/* 只读选项行里「圆点/方勾 + 字母徽标」那一小撮（`admin-dialog.js` 的 `optionsView`
   把它们裹进了 `.admin-optmark`，2026-10-02）。
   ⚠ 这一条是**基准写法，不分平台**：裹了盒子之后，不加这一条它在任何地方都会
    变成两块上下摞着的 div（圆点在字母上面）—— 桌面分栏那半边有专门的栅格规则
    兜着（app.css ㊿-6），**手机档和弹窗没有**，就靠这一条保持原样（横排）。
   `gap` 跟 `.admin-optrow` 那条取同一个值：裹之前这两个孩子是 `.admin-optrow`
     的直接子女，间距就是它给的 `--sp-2`；裹之后间距归这一层管，取同值才不变样。 */
.admin-optmark {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  min-width: 0;
}
/* 徽标换了一层父级，`.admin-optrow > …` 那条 flex-shrink 够不着它了 ——
   不补这一条，徽标会被文字挤瘦（那是设计系统给的宽度，不许被挤）。 */
.admin-optmark > [data-name^="Component/Badge"] { flex-shrink: 0; }

/* 「添加选项」那枚幽灵按钮跟左边缘对齐（它是按钮，稿子的按钮都是自己撑开的） */
.admin-optadd { align-self: flex-start; }

/* 「选文件」那一格（admin-dialog.js 的 buildFileField）选完之后的字。
 * 文件名写的是**框里原来那枚「占位」节点**（那里本来就是画「请输入…」这种提示字的地方，
 * 字号/行高/位置全都对），所以只需要把它从提示色改成正文色 ——
 * 稿子的占位提示用的是 tertiary，这是一条**设计决定**，不是布局事实，
 * 所以这里取的是稿子自己的令牌，不是随手挑一个灰色。 */
.admin-field--file .admin-file-picked { color: var(--cl-text-primary); }

/* 面板上那枚关闭图标（稿子画 24×24）的命中区往四周撑到 44 —— 跟 ⑤ 一套做法：
   纯伪元素、不占空间、不改绘制位置。选项行里的删除图标是同一枚节点，
   一并算进来（它也在面板里，所以这一个选择器就够）。 */
.dialog-gaozi [data-name="Icon/Close"] { position: relative; cursor: pointer; }
.dialog-gaozi [data-name="Icon/Close"]::after {
  content: "";
  position: absolute;
  inset: -10px;   /* 24 + 10×2 = 44 */
}

/* 表单里的真输入框。跟 ④ 填空、⑦ 搜索框、⑬ 登录同一套路：
 * 稿子画的是「占位」那个 div，控制器把它换成真 <input>/<textarea>，
 * 并**继承占位那枚节点的 class**（字号/字色/行高因此跟稿子一致）。
 * 这里只补 div 变 input 之后必然要补的几条：div 没有边框、没有背景、
 * 默认不继承字体 —— input 三者都有，会把稿子那一格撑歪。 */
input[data-name="表单输入"],
textarea[data-name="表单文本域"] {
  flex: 1 1 0;
  min-width: 0;
  width: 100%;
  background: transparent;
  border: 0;
  outline: 0;
  padding: 0;
  margin: 0;
  /* ⚠ 2026-10-02（第五十六轮）：这里原来是 `font: inherit` —— 它的特异性是
     **`(0,1,1)`**（元素 + 属性选择器），而帧里那条 `.n-8-1095__2-304` 是
     **`(0,1,0)`** ⇒ 帧本来给的 `font-size: var(--fs-body)`(15px) 与
     `line-height: 22px` **被静默换成了从外层继承来的 16px / normal**。
     这个不是"没调好"，是**被压住了**：规则在、值对、就是不生效，
     而且屏幕上看着只是"字有点大"，不会报任何错。实测三个控件
     （输入框 / 文本域 / 下拉值）全是 16px、行高 normal。
     ⇒ 改成**显式写出帧的那两个值**。
     ⚠ 为什么不是"删掉这行让帧赢"：删了以后，万一某个控件没挂上帧的类，
       它就退回浏览器给 `<input>` 的默认字号 13.33px —— 那更难看，也更难查。
       写死这两个数是**确定的**：值跟帧一致，优先级自己说了算。
     `font-family` / `font-weight` / `color` 照旧继承（帧给的字族就是全站那一套）。 */
  font-size: var(--fs-body);
  line-height: 22px;
  color: inherit;
  resize: none;   /* 手机上拖右下角那个把手的把手势打架，高矮由内容说了算 */
}
input[data-name="表单输入"]::placeholder,
textarea[data-name="表单文本域"]::placeholder { color: inherit; opacity: .72; }

/* 多行框（题干/解析/描述）要能跟着内容长长。
 *
 * 稿子 15-111 的 15:134 是**固定** 88 高 + overflow:hidden —— 那是给刷题页
 * 「详细描述」画的两行框。题干一长，固定高 + 隐藏溢出就成了「自己打的字看不见」，
 * 那是 bug 不是风格：所以放开成 auto，下限 88**就是稿子画的 88**
 * （改了稿子这张数就变，视觉下场只是框略高略矮，不会坏功能，所以没做断言）。
 * 高度往上顶由控制器做（admin-dialog.js 的 grow()，按 scrollHeight）。
 * 只挂 .admin-field--area 上，够不到刷题页那一枚。 */
.admin-field--area > [data-name="Component/Input/Default"] {
  height: auto;
  min-height: 88px;
  overflow: visible;
}

/* ㉑ 后台卡片上的「操作」写成文字，不画图标。
 *
 *    用户（2026-09-24）：「删除编辑用文字，不要用图标。遵循hig操作规范」。
 *    HIG 那条也确实是这么说的：图标的门槛是**一看就懂**，铅笔和垃圾桶在列表里
 *    其实要靠猜（有的产品用它表示「编辑资料」、有的表示「重命名」），
 *    写成「编辑」「删除」谁都不用猜。所以后台这几张卡上的操作一律换成文字。
 *
 *    这一格原本是**照 24×24 图标画的**（稿子：display:flex / gap:sp-3 / height:24 /
 *    overflow:hidden / flex-shrink:0），换成文字之后有一件事绕不过去：
 *    **文字比图标宽**。量过稿子的次行（9-138 那五张卡的字段1/2/3 加一起
 *    已经把 286 宽的次行占到 258，剩下那 28 是「弹性」那格的全部余量）——
 *    两枚 2 字按钮（约 100 宽）在用户管理那页上就顶到边了，三枚必然溢出。
 *    所以就着一格硬塞是错的，改法是把这一格**放到自己一行**上：
 *
 *    · `.admin-ops-line`（次行）放开换行。这两条都得 !important ——
 *      稿子给次行写的是 `flex-wrap:nowrap; overflow:hidden`，稿子 CSS 是运行时后加载的，
 *      同特异性下它赢。
 *    · `.admin-ops`（操作格）`margin-left:auto`：一行放得下就留在原行右端
 *      （跟稿子里它被「弹性」顶到右缘是同一个位置），放不下就整格落到第二行、
 *      仍然贴右缘。**宽度交给浏览器算**，不按页写死 ——
 *      组织/题库那几页余量有 99～144，两枚按钮根本不换行，白白多一行才难看。
 *    · 高 44、宽至少 44：HIG 的可点目标下限。这一格原来是 24 高，
 *      所以直接**把按钮本身撑到 44**（撑格子没用 —— 撑的是格子的话，
 *      文字周围那圈还是点不到，得靠伪元素，而伪元素会被 overflow:hidden 裁掉）。
 *      `.admin-ops` 的 gap 归零、改由按钮自己的左右内边距（12）隔开，
 *      这样相邻两枚的命中区**相接但不重叠**（跟 ⑤ 里答题卡格子同一条讲究：
 *      重叠会让边界上的那一下不知道该算谁的）。
 *
 *    颜色取稿子令牌，不另挑灰：普通动作 = 稿子给图标定的 --cl-text-primary，
 *    危险动作 = 10-127 那格红字用的 --cl-danger-700。这不改设计语言，
 *    只是把稿子上「图标是主色」这件事换成「文字是主色」。
 *    类由 admin-list.js 的 wireActions 挂 —— **只有真换成文字的那一格**才挂，
 *    没写的动作照旧是图标，稿子的 DOM 一个节点没动。 */
.admin-ops-line {
  flex-wrap: wrap;
  overflow: visible !important;
}

.admin-ops {
  margin-left: auto;
  gap: 0;
  height: auto !important;
  min-height: 44px;
  overflow: visible !important;
}

.gaozi-act {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
  min-height: 44px;
  min-width: 44px;
  padding: 0 12px;
  white-space: nowrap;              /* 「解除关联」这种四字文案不许折行 */
  color: var(--cl-text-primary);
  font-size: var(--fs-caption);
  font-family: "Noto Sans SC", var(--ff);
  font-weight: var(--fw-regular);
  line-height: var(--lh-caption);
  cursor: pointer;
  /* 这里不单独写 user-select:none —— 全站那一条在 ㉒（全局禁止复制），
     它是**所有**节点都盖住的，这一格自然也盖住。在这儿再写一遍是第二个真相来源。 */
}
.gaozi-act--danger { color: var(--cl-danger-700); }

/* 只看不能改的那一行（admin-dialog.js 的 readonly，用户查看那一屏用）：
 * 框用的还是稿子那枚单行输入框，字写在它那枚「占位」节点上 ——
 * 占位节点的颜色是**提示色**（它本来是写「请输入…」的地方），
 * 而这一格装的是真数据，得是正文色。跟 ⑳ 里选文件那格一个道理、一个写法。 */
.admin-field--readonly .admin-ro { color: var(--cl-text-primary); }

/* ⛔ 只改颜色还不够 —— 只读值长一点就会被**裁掉**，这是同一个「写了但你看不见」的毛病。
 *
 *    量到的实情（390×844，注销风险那一屏）：
 *      · 框（稿子 8-1095 的单行框）：display:flex / height:48px / overflow:hidden /
 *        padding:0 16px，**高是写死的**。
 *      · 里面那枚「占位」节点：稿子给它写的是 `flex: 0 0 auto` ——
 *        于是它的宽度按内容撑（量到 393px），**既不缩、也不折行**，
 *        就这么横着冲出框外，被框的 overflow:hidden 切掉。
 *      结果：「！你有 1 个还在有效期内的激活码，注销后名额会退回组织」这条，
 *      390 宽右端切掉 59px、360 宽切掉 89px、320 宽切掉 129px；
 *      320 宽下九行里有六行被切。只读那一屏装的正是要用户看清的东西
 *      （注销风险、用户资料里的邮箱/备注），裁掉就等于没写。
 *
 *    改法跟 ㉒ 多行框（.admin-field--area）同一条路数：**高度放开、溢出可见**；
 *    再把那枚占位节点从「按内容撑」改成「跟着框宽、允许折行」。
 *    折行本身不用写 white-space —— 稿子给它的就是 normal（量过），
 *    这里只做真正被稿子挡住的那两条（它是 flex:0 0 auto，min-width 也得归零，
 *    不然 flex 项的下限还是内容宽）。
 *    只挂 .admin-field--readonly 上：别的字段（真输入框、多行框、下拉）一个够不着。 */
.admin-field--readonly > [data-name="Component/Input/Default"] {
  height: auto;
  min-height: 48px;      /* 单行时**就是稿子画的 48**，视觉不变；两行才往下长 */
  overflow: visible;
}
.admin-field--readonly .admin-ro {
  flex: 1 1 auto;
  min-width: 0;
}

/* 调试条：跟稿子无关，纯粹给我核对用（?dev=1 才出现）。
   悬浮在顶部、不参与文档流，所以不会把稿子的版式挤歪。 */
.devbar {
  position: fixed;
  top: 0; left: 0; right: 0;
  z-index: 9998;
  display: flex; gap: 6px; align-items: center; flex-wrap: wrap;
  font: 11px/1.6 var(--ff, sans-serif);
  color: #eee;
  background: rgba(28, 27, 34, .9);
  padding: 4px 8px;
}
.devbar .sep { width: 1px; height: 14px; background: #55545e; margin: 0 4px; }
.devbar button {
  font: inherit; color: #cfc6ff; background: #2b2a33;
  border: 1px solid #47454f; border-radius: 6px; padding: 1px 6px; cursor: pointer;
}
.devbar button.on { background: var(--cl-primary-600, #7B61FF); color: #fff; border-color: transparent; }

/* ㉒ 全局禁止复制（用户 2026-09-25：「全局禁止复制」）。
 *
 *    为什么连「选中」都要关，而不是只拦复制事件：手机上「复制」这条路是
 *    **长按划出选区**开始的。只拦 copy 事件的话，用户长按照样会看到选择手柄、
 *    以及那本字典/搜索菜单 —— 看着像能复制，点了没反应，比干脆不给选更差。
 *    所以从根上关掉选中（手指这一路），再把三条事件拦掉（键盘/右键/程序那一路）：
 *      · `user-select` 管的是「能不能划出选区」；
 *      · copy / cut / contextmenu 管的是 Ctrl+C、右键菜单、以及脚本直接调
 *        `document.execCommand('copy')` 之外的那些路。
 *    两条都要有 —— 少了哪一条都留着一条能复制的路。
 *
 *    ⚠ 输入框和可编辑区**必须放行**。它们是用户往里打字的地方：
 *      `user-select:none` 会让插入光标进不去、也选不中已经打进的字，
 *      用户看到的是「这个框坏了」。后台那些表单（admin-dialog.js 造的）也在里面。
 *      所以用 `:not()` 把这几类摘出去，再单独给它们写回 `text`。
 *
 *    ⚠ 只删「用户划出来的那一块」，别碰页面自己的选区：
 *      刷题页那些稿子节点本来就没做文本选择，不影响。
 *
 *    ⚠ 这条**不是**安全措施，是产品要求。真想拿数据的人从接口就能取 ——
 *      别把它当成防抓取的闸门（那会在别处放松警惕）。
 */
*:not(input):not(textarea):not(select):not([contenteditable="true"]) {
  -webkit-user-select: none;
  -moz-user-select: none;
  user-select: none;
  -webkit-touch-callout: none;   /* iOS 长按弹出的「拷贝/查询」那一条 */
}
input, textarea, select, [contenteditable="true"] {
  -webkit-user-select: text;
  -moz-user-select: text;
  user-select: text;
}

/* ㉓ 刷题页「纠错管理」面板里那个「详细描述」框。
 *
 *    跟 ③ 是**同一件事**，当时只补了填空那一枚、漏了这一枚：
 *    稿子 15-111 的 15:134 画的是一个 `Component/Input/Default`，里面摆着一个
 *    叫「占位」的 div；quiz.js 的 openFeedback 把这个 div 换成真 <input>，
 *    并且**沿用占位那枚的 class**（字号/行高/字色都从它继承）。
 *    div 没有边框、没有底色，<input> 两样都有 —— 于是那个灰底的多行框里
 *    **又套出一个白底黑框的小方框**。这就是用户 2026-09-25 报的
 *    「纠错管理输入框模块有个方框」。量到的是 `2px inset rgb(118,118,118)` 配
 *    白底，宽 209（该有 321）。
 *
 *    ⚠ 这里**不写 `font: inherit`**，③ 写了。差别是量出来的：
 *      占位那枚 class 已经把整套字写死了（15px / 22px / Noto Sans SC /
 *      color 91,90,102），而外面那个容器的字是另一套（16px / line-height normal /
 *      字族里没有 Noto Sans SC）—— `font: inherit` 会把这几个数整套换掉，
 *      等于把稿子的字改了。③ 那边两边的字恰好一样，所以继承过去看不出来；
 *      这一枚不一样，不能照抄。 */
input[data-name="纠错输入"] {
  flex: 1 1 0;
  min-width: 0;
  width: 100%;
  border: 0;
  background: transparent;
  outline: none;
  padding: 0;
  margin: 0;
  appearance: none;
}

/* ㉔ 【已废弃 2026-09-26】「我的」页上那枚「＋ 激活组织」。
 *
 *    ★ 这一条和它下面原本的两条规则（.org-head / .org-activate）**已经删掉了**，
 *      留着这段字只为说清它去哪了 —— 免得以后有人翻 git 看见它没了，又照旧站的
 *      样子把它做回来。
 *
 *    当初为什么做它：稿子的「我的」页没画激活入口，而「我的组织」那页底下那句
 *    「可在「我的」页激活」指着它，所以照旧站补了一枚 —— 位置在「我的组织」标题
 *    右边，做法照旧前端 Profile 的 `org-card-header`。
 *
 *    为什么废弃（用户 2026-09-26 原话）：
 *      「激活组织呢，我们也单独弄一个入口。现在激活组织有点小这个按钮，
 *        我们在"我的"这个地方可以直接激活，然后弄一个单独的入口吧。」
 *    ——「有点小」说的就是这一枚：它跟着标题那一行排，字号只能是 fs-body，
 *      在一屏里是个不起眼的角落。现在换成列表下面**一整张大卡片**
 *      （跟组织卡片一样宽，有图标、标题、一行说明），见下面 ㉚。
 *    入口的去向、面板、话术、后续动作**一个字都没改**（还是 POST /api/web/activate-code
 *    那个面板），改的只是它在「我的」页上长什么样、摆在哪。
 *
 *    ⚠ 所以「我的」页上**不该**再出现 .org-head / .org-activate 这两个类。
 *      真机上要是又看见了，说明有人把旧的那段搬回来了 —— 那是回退，不是修复。 */

/* ⑱ 首页 Hero 卡：紫色底纹 + 「环=正确率 / 横条=学习进度」
 *   2026-09-25 用户两句话：「紫色加个底纹好看现代」+「圆环是进度、正确率是数字，
 *   在一块容易混淆，这个卡片显示内容重新优化一下」。
 *
 *   ① 底纹是四层背景叠出来的，从下往上：紫色底 → 右上白光 → 左下弱光 → 点阵。
 *      点阵用 `background-size` 单层生效（多层简写里 `/16px 16px` 只作用于它自己那层），
 *      间距 16px、点半径 1.1px、白 15% —— 再密就成噪点了，再淡就看不见。
 *      ⚠ 别用 `filter` 或额外节点做底纹：这卡 overflow:hidden，加伪元素就得再调层级，
 *        四层 background 是零节点方案。
 *
 *   ② 特异性必须压过稿子（.n-2-1227 是 0,1,0，而稿子 CSS 运行时后加载，
 *      同特异性它赢）。这里 .screen + 属性 = 0,2,0。 */
.screen [data-name="Hero"] {
  background:
    radial-gradient(88% 120% at 100% 0%, rgba(255, 255, 255, 0.26) 0%, rgba(255, 255, 255, 0) 62%),
    radial-gradient(70% 100% at -10% 100%, rgba(255, 255, 255, 0.14) 0%, rgba(255, 255, 255, 0) 58%),
    radial-gradient(circle at 1px 1px, rgba(255, 255, 255, 0.15) 1.1px, transparent 1.1px) 0 0 / 16px 16px,
    var(--cl-primary-700);
  /* 投影 2026-10-18 重做（用户：「轮播卡片下的投影也不好看」）。
     旧版是一条 `0 8px 22px` 的饱和紫光（rgba(124,58,237,.26)）——
     亮紫 + 全向同宽，出来的是一圈**发光晕**，卡不像浮在底上，像贴了圈霓虹边。
     换成两层「贴地 + 环境」：
       ① 1px/2px 的深色近影把卡钉在底纹上（判断高度靠的是这条接触影，不是光晕）；
       ② 大半径、**负扩散**的深紫环境影 —— spread -18px 把影子收进卡的正下方，
          四周不再一圈同宽的边；颜色取深紫而非亮紫，跟卡身同色系但不抢戏。
     轮播内容位（.xq-car-slide--content）用同一套数值：滑动时两张卡交替，
     影子不同步会看出「闪一下」。
     ⚠ 2026-10-18 晚 用户再打回：「用户首页轮播卡片下边的投影关掉」——
       两层影整条撤掉，不是再调一版数值。卡片与底之间靠页底渐变+颗粒分层，
       那圈影本来就是多余的重量（`.xq-car` 上那大段 overflow 注释里的
       「往下最远 ~24px」也随之作废，留着不裁也不占版式）。 */
  box-shadow: none;
}

/* ③ 环**里**那行「正确率」（2026-09-25 用户：「正确率几个字在圆环内部」）。
 *    它是「环文字」那个 64×64 居中竖列里的第二行，紧跟在那个百分数底下。
 *    ⚠ 环内径 = 64 − 2×7（描边粗细见 home.js 的 THICK）= 50px；
 *      这一行 11px 三个字 = 33px，放得下。字号取 --fs-micro，跟四格标签同档，
 *      不为这一行新造字号。
 *    ⚠ 别加 letter-spacing：环内本来就窄，多一个字距就顶到描边上。
 *    ⚠ 上一版这一行是挂在环**外面**的（「环 + 标签」竖成一列）。搬进来放得下
 *      是**算出来的**，不是靠放开裁剪：这一行 33px 宽，加上面那个百分数那一行，
 *      离环心最远 23.3px < 环内半径 25px（tools/build/probe-ring.py 的 ⑤ 会量它）。
 *      环那两格（进度环 / 进度弧容器）到现在都还是 overflow:hidden —— 别以为放开了。 */
.hero-ringcap {
  flex-shrink: 0;
  margin-top: 2px;
  color: rgba(255, 255, 255, 0.72);
  font-size: var(--fs-micro, 11px);
  font-family: "Noto Sans SC", var(--ff);
  font-weight: var(--fw-medium);
  line-height: 1;
}

/* ⑤ Hero 卡的精修三件（2026-09-25 用户：「主页，顶部卡片设计一下样式」）。
 *    类名由 home.js 的 markHero() 挂上去 —— 为什么非要绕这一道、不直接选稿子那个
 *    `.n-2-1241`，写在那个函数顶上。
 *
 *    ★ 颜色这一层：这卡上**只有**「紫底 + 白色前景」两套东西 ——
 *      稿子的四层底纹、环里那句「正确率」，用的都是同一个写法
 *      `rgba(255,255,255,·)`。这里跟着走，不引任何新色相，也不新造令牌：
 *      紫色永远从 --cl-primary-* 来，白的那几层永远是白的透明度。
 *      （本 App 锁亮色，index.html 上写着 data-theme="亮色"，
 *        所以「白」在这里恒等于 --cl-text-inverse。）
 *
 *    ★ 特异性一律 .screen 打头（0,2,0）：稿子 CSS 是运行时后加载的，
 *      同特异性下它赢，不打头就被盖回去（① 那段踩过同一个坑）。
 */

/* 下半那三格：一块半透明的「玻璃面」底盘，把三格收成一组。
 * ⚠ 上下内边距是**必须**的，不是留白好看：三格在稿子里是
 *   26px 数字 + 2px + 16px 标签 = 44px 整，一点余量都没有，
 *   不加内边距数字会贴着底盘上沿。取 --sp-1(4) 是这一屏最小的一档间距。 */
.screen .hero-tiles {
  align-items: stretch;
  gap: 0;                       /* 格与格的间隔改由细线担任，不留空档 */
  padding: var(--sp-1) var(--sp-2);
  background: rgba(255, 255, 255, 0.12);
  border-radius: var(--rd-md);
}
/* 格与格之间一条竖细线。**只占 18px 高、居中** —— 通栏的竖线在 44px 高的
   格子里会读成表格的行列，短一截才像「分组」。
   ⚠ 用伪元素不用 border：border 会占宽，三格就不再等宽了。 */
.screen .hero-tiles > * + * {
  position: relative;
}
.screen .hero-tiles > * + *::before {
  content: "";
  position: absolute;
  left: 0;
  top: 50%;
  transform: translateY(-50%);
  width: 1px;
  height: 18px;
  background: rgba(255, 255, 255, 0.18);
}

/* 「N / M 题」那一行：三种字号共用一条基线。
 * 稿子是 align-items:center，会让「/ 300 题」吊在 28px 大数字的半腰上。 */
.screen .hero-numline {
  align-items: baseline;
}
/* 长数字降一档：题库总数上到六七位时，这一行会被稿子的 overflow:hidden 硬切。
 * 降档取 --fs-title(22) —— 还是本屏最大的一档，降完仍然是「大数字」，
 * 只是不再和环抢宽度。判定在 home.js 的 markHero 里，是量出来的不是猜的。 */
.screen .hero-numline--tight > *:first-child {
  font-size: var(--fs-title);
  line-height: var(--lh-title);
}

/* 三格里的数。
 * ⚠ 收住宽度的是 min-width:0 + max-width:100%，**不是 flex-shrink**：
 *   这三格是竖排（flex-direction:column），flex-shrink 管的是**高度**那条轴，
 *   宽度是交叉轴，由 align-items 的 fit-content 决定 —— 写 flex-shrink 管不着它。
 *   稿子给这几格写 flex-shrink:0，是它那句样例「128」本来就不需要收，
 *   不是「不许收」；高度那条轴上我们也确实不需要收（行高没有富余）。
 * ⚠ 也**不给**稿子那层 overflow:hidden 硬切：省略号是最后一层兜底，
 *   主路径是 home.js 量出来之后降一档字号（「1234…」比「1234」更像没数完）。 */
.screen .hero-tile-val {
  min-width: 0;
  max-width: 100%;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.screen .hero-tile-val--long {
  font-size: var(--fs-heading);
}

/* ⑰ 登录 / 注册页的「同意勾选」（合规，运行时现造，稿子上没有）。
 *
 * 为什么在 app.css 而不是改稿子：稿子（web/pages/8-1043.html、8-1045.html）
 * 与它们的样式表是**生成物，一个字节都不许动**（见 notes/19 波 C）。
 * 这一格跟 ⑬（登录输入位）是同一类东西：稿子画不了「要用户点一下」这件事，
 * 只能在运行时补，补出来的样式写这里。
 *
 * ⚠ 一个字都没改稿子的版式：这一行是**新加**的一块，挂在「表单块」的最后
 *   （主按钮底下），字号/颜色全部取设计系统的令牌，不新造颜色。
 *   12px 不是随手写的 —— 跟稿子「底部块」里那两行「底部说明」同一档
 *   （8-1043.css:30 `.n-8-1081` 就是 12px/17px）。 */
.login-agree {
  display: flex;
  flex-direction: column;
  gap: 6px;
  align-self: stretch;
}
/* 整个 label 都是热区 —— 手指不用去戳那个 18px 的小方块。
   上下各 10px 内边距把命中区撑到 ~38px（HIG 要 44，这里是次要控件，
   先取一个不把版式顶开的折中值；要再大就得动「表单块」的间距，属于改版式）。 */
.login-agree-hit {
  display: flex;
  flex-direction: row;
  align-items: flex-start;
  gap: var(--sp-2);
  align-self: stretch;
  padding: 10px 0;
  cursor: pointer;
}
/* margin-top 2px 是让方块的**中线**落在第一行文字的中线上（文字 17px 行高）。
   不加这一条，多行文案时方块会贴着第一行顶部，看着像没对齐。 */
.login-agree-box {
  flex-shrink: 0;
  width: 18px;
  height: 18px;
  margin: 2px 0 0 0;
  accent-color: var(--cl-primary-700);
  cursor: pointer;
}
/* 键盘/无障碍聚焦时要看得见 —— 稿子的输入框有 outline:none，
   那是给「框本身已经很明显」的输入位用的，这一枚小方块不行。 */
.login-agree-box:focus-visible {
  outline: 2px solid var(--cl-primary-700);
  outline-offset: 2px;
}
.login-agree-text {
  color: var(--cl-text-tertiary);
  font-size: 12px;
  font-family: "Noto Sans SC", var(--ff);
  font-weight: var(--fw-regular);
  line-height: 17px;
}
/* 协议名：用主色 + 中粗 —— 跟稿子「注册引导-链接」是同一档表达（那是可点的）。 */
.login-agree-link {
  color: var(--cl-primary-700);
  font-weight: var(--fw-medium);
  cursor: pointer;
}
/* 没勾就提交时的红字。挂在勾选框底下，**不是 toast** ——
   toast 会自己消失，而「你还没同意」这件事必须一直摆在眼前，
   直到用户勾上（勾上时 paintAgree 里那一段会把它撤掉）。 */
.login-agree-err {
  align-self: stretch;
  color: var(--cl-danger-600);
  font-size: 12px;
  font-family: "Noto Sans SC", var(--ff);
  line-height: 17px;
}

/* ⑲ 练习报告的「下一组」行（result.js 运行时建的那一枚，见 buildNextGroup）。
   稿子没画这一行，但**按钮本身是稿子的** —— 克隆的正是同一行里那枚 Primary，
   它自带 `flex:1 1 0`。所以这里只要把「行」的那套布局照抄过来（对照 .n-2-1665：
   横向 + gap + 撑满 + 不收缩），克隆出来的按钮就自然占满一整行。
   ⚠ 不写 height：按钮自己的 .n-2-1666 里钉着 `height: var(--sz-control)`，
     在这里再写一遍就是两处管同一件事，改一处忘一处必然不一致。 */
.result-nextrow {
  display: flex;
  flex-direction: row;
  gap: var(--sp-3);
  align-self: stretch;
  flex-shrink: 0;
}

/* ⑳ 登录页上「11 位数字账号」这句话，一屏说了三遍 —— 只留输入框里那一处。
 *
 * 用户 2026-10-03：「登录页11位数字账号提示太多次，必要的输入框提示即可」。
 * 量过稿子 8-1043，同一句确实三处：
 *   8:1074 品牌副标题「11 位数字账号登录」
 *   8:1075 输入框占位「请输入 11 位数字账号」   ← 这一处留着
 *   8:1081 底部说明「注册 11 位数字账号」
 * 底下那两处**都不是可点的**（真正的注册入口是上面那块「没有账号？去注册」，
 * 8:1080），所以盖掉它们不挡任何一条路，只是不再把同一句话说三遍。
 * 顺带：8:1081 盖掉之后，底部块还剩「登录后可输入激活码…」+ 备案号，不会空。
 *
 * ⚠ 为什么用 CSS 而不是改稿子：web/pages/8-1043.html 是生成物，一个字节都不许动
 *   （跟 ⑰ 同一个理由）。这里是**只在这一屏上盖掉两行字**，稿子原样。
 * ⚠ 只认 body 上那个 ardot（app.js 会把稿子根节点的编号搬到 body）：
 *   注册页 8-1045 那句「注册 11 位数字账号」是**它自己的屏标题**（上面只有 App 名，
 *   没有第二层标题），两处不算啰嗦，用户点名的也是登录页 —— 因此这一条不碰它。
 * ⚠ 「登录·校验错误」那帧（11:25，只有 ?s=err 调试态会整帧挂）里同样有一份，
 *   顺手一起盖掉，免得同一件事两种截图。 */
body[data-ardot="8:1043"] [data-ardot="8:1074"],
body[data-ardot="8:1043"] [data-ardot="8:1081"],
body[data-ardot="11:25"] [data-ardot="11:32"],
body[data-ardot="11:25"] [data-ardot="11:45"] {
  display: none;
}

/* ══════════════════════════════════════════════════════════════════
 * ㉕ 管理后台的**桌面版外壳**（电脑上才挂，见 app/admin-shell.js）
 *
 * 这**不是新设计**：它就是稿子 8-402 的根节点那两行属性
 *   `.n-8-402 { display:flex; flex-direction:row; … }`
 *   `.n-8-403 { width:220px; align-self:stretch; }`
 * 用在「只有手机稿」的那七个后台页上 —— 电脑上让侧边栏常驻，
 * 右边那一列跟着窗口走，而不是永远 393 宽居中一条。
 *
 * ⚠ 下面每一条都**只在 .admin-shell 里面**生效。手机上一个字节都碰不到：
 *   壳只有壳自己会挂，而壳只在电脑上挂（app.js 的 desktopAdmin 那一句）。
 * ══════════════════════════════════════════════════════════════════ */

.admin-shell {
  display: flex;
  flex-direction: row;
  /* 撑满 #app（#app 是 flex + 居中，所以要自己说要撑开） */
  flex: 1 1 auto;
  width: 100%;
  height: 100dvh;
  min-height: 0;
  overflow: hidden;
  background: var(--cl-bg-page);
}

/* 左边那一栏：**220px 是稿子的值**（8-402 的「后台侧边栏」自己写的 width），
   不是这里定的。 */
.admin-shell-side {
  flex: 0 0 220px;
  width: 220px;
  min-width: 0;
  display: flex;
}
/* 抽屉那一套样式（.admin-nav-panel 的 fixed/absolute + translateX(-100%)）
   在一块常驻的侧边栏上是错的：它会往屏幕左边滑出去一整个身位。
   ⚠ 只在这一层里改，抽屉那边（手机）用的还是原来那套。 */
.admin-shell-side > .admin-nav-panel {
  position: static;
  transform: none;
  transition: none;
  width: 100%;
  height: 100%;
}

/* 右边那一列：顶栏 + 内容区，就是稿子那一屏本身 */
.admin-shell-main {
  flex: 1 1 auto;
  min-width: 0;
  min-height: 0;
  display: flex;
  flex-direction: column;
}

/* ★ 稿子那一屏在电脑上**不再收窄**。
   `.screen` 原规则是 `max-width:var(--design-w,393px)` + `#app` 居中 ——
   手机版式靠它成为中间一条。桌面后台要的是「铺满右边那一列」。
   width/height 都交给父层（.admin-shell-main 是 flex 列），
   这样里面那个 `flex:1` 的内容区才量得出该滚多少。 */
.admin-shell-main > .screen {
  max-width: none;
  width: 100%;
  height: 100%;
  flex: 1 1 auto;
  min-width: 0;
  min-height: 0;
}
/* 稿子根节点本来被撑到 100dvh（见上面 .screen > *）。壳里它该填的是
   **右边那一列**，不是整个视口 —— 差的就是侧边栏那 220px 和外面那一层。
   ⚠ 这条的选择器必须比 `.screen > *:not(...)` 更具体，否则压不住它的 !important。
     `body .admin-shell-main > .screen > *` 正好多两层，且位置更靠后。 */
body .admin-shell-main > .screen > * {
  height: 100% !important;
  min-height: 0 !important;
}

/* ── 面包屑 ──────────────────────────────────────────────────────
 * 稿子里**一张都没有**（全仓 grep「面包屑」是 0 条），是用户点名要的。
 * 放法见 admin-shell.js 文件头：**塞进顶栏「标题」那一格**，
 * 把标题藏掉 —— 末尾那一节就是原来那个标题，信息一点没少。
 * 所以这里一个高度、一个底色都不写：顶栏的都由稿子那几行管着。 */
.admin-crumb {
  display: flex;
  flex-direction: row;
  align-items: center;
  gap: 6px;
  overflow: hidden;
  white-space: nowrap;
  font-size: var(--fs-body);
  color: var(--cl-text-secondary);
}
.admin-crumb-item {
  /* 长名字（题库名、组织名）收省略号，而不是把右边那些挤出去 ——
     跟 ⑮ ⑲ 那两条同一个规矩。 */
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.admin-crumb-item.is-link {
  color: var(--cl-primary-700);
  cursor: pointer;
}
.admin-crumb-item.is-link:hover { text-decoration: underline; }
/* 当前那一节：字重跟着稿子的标题那一档（它在顶栏里就是「你在哪」） */
.admin-crumb-item.is-current {
  color: var(--cl-text-primary);
  font-weight: var(--fw-semibold);
}
.admin-crumb-sep {
  color: var(--cl-text-tertiary);
  flex: 0 0 auto;
  /* 分隔符和文字之间本来有 gap，这里再给一点点，免得「›」贴着字 */
  padding: 0 2px;
}

/* ── 桌面档的面包屑「可点」标记（2026-10-06 第五十八轮）─────────────────────
 * 用户报「**所有面包屑都没法点击**」。桌面档的面包屑是**稿子画的四个 TEXT 节点**
 * （`面包屑0/2/4/6`），没有一个是可点控件；而手机档那条是 `admin-shell.js`
 * 用代码造的，中间几节是 `<a>` + `is-link`。两档口径不一致，电脑上反而更差。
 *
 * 接线在 `admin-desk.js` 的 `wireDeskCrumb`。这里只管长相：
 *   · `cursor:pointer` —— 鼠标形状是最低成本的「这里能点」提示；
 *   · hover 时下划线 + 品牌色，**不 hover 时与稿子一模一样**（一个字色都不改）；
 *   · 键盘 Tab 过去要看得见 —— 那一条合到上面那一大串 `:focus-visible` 里
 *     （`admin-crumb-item` 已经在了，桌面档这个是**另一个类**，必须单独加）。
 *
 * ⚠ 为什么不直接复用 `.admin-crumb-item.is-link`：那一份是**壳造出来的**节点
 *   （flex 布局、有 `overflow:hidden`），稿子这几格是**绝对定位**的 TEXT，
 *   套上去会把字号/行高/宽度带偏。名字分开、规则只写「可点的样子」，风险最小。
 */
.admin-desk-crumb-link {
  cursor: pointer;
}
.admin-desk-crumb-link:hover {
  color: var(--cl-primary-700);
  text-decoration: underline;
}
.admin-desk-crumb-link:focus-visible {
  outline: 2px solid var(--cl-primary-600);
  outline-offset: 1px;
  border-radius: 2px;
}

/* ══════════════════════════════════════════════════════════════════
 * ㉖ 后台各页的**电脑版版式**（只在桌面壳里生效）
 *
 * 为什么要逐页写：稿子是按 393 宽画的，里面的东西大多用 `flex:1 1 0` +
 * `align-self:stretch` 撑开（量过 9-51：75 个节点里 30 个 align-self:stretch、
 * 24 个 flex:1 1 0，只有 5 个写死了像素宽）。所以**把画布放宽，它们就跟着放宽** ——
 * 这也是为什么「电脑版」在这里不是八张新稿子，而是**每页几行**：
 * 只有那些「按 393 分成两格才好看」的地方，在 1220 宽下会变样。
 *
 * ⚠ 选择器拿 `body[data-ardot="9:51"]` 当**页面的身份证**。
 *   9:51 就是这条路由的 slug（app.js 的 ROUTES 里写着 `slug: "9-51"`），
 *   是**已经声明过的依赖**，不是临时抠出来的节点号。
 *   稿子里的类名（`.n-9-55`）是 gen.mjs 按节点号生成的、改版就全变，
 *   所以下面一条类名都没有 —— 认的全是**图层名**（跟全仓其它地方一致）。
 * ══════════════════════════════════════════════════════════════════ */

/* ── 数据概览（9-51）：七张统计卡 ────────────────────────────────
 *
 * 手机上那七张卡是**两行一组**装在「行1~行4」里（每行卡片 `flex:1 1 0`），
 * 393 宽下正好两张一行。电脑上内容区有 1220 宽，还是两张一行的话
 * 每张会摊到 600 宽 —— 一个数字加一个标签占 600×90，中间一大片空。
 *
 * 改法：电脑上把内容区变成 **一行四张**；「行1~行4」这几层在手机上只是分组，
 * 电脑上让它们 `display:contents`（不生成盒子），七张卡就直接落进这一行里。
 *
 * ── ★★ 2026-10-02：为什么从「4 列栅格」改成「会换行的 flex 行」────────
 *
 * 用户 2026-10-02 的原话是数据概览有两处观感问题，这是第一处：
 * **第二排只有 3 张，最右空着一格 289×92 的洞**（7 张卡按 4 列排，
 * 7 ÷ 4 除不尽，第 4 格必然是空的）。那个洞在白底页面上看得很清楚，
 * 读起来不像「就 7 个」，像「少了一张」。
 *
 * 栅格**做不到**「最后一行补齐」：`repeat(4,1fr)` 的轨道数是死的，
 * 第 3 张卡不会为了填满而变宽；`auto-fit` 也只在「卡比轨道少」时才收轨道，
 * 7 > 4 用不上。要让最后一行自己长胖，只有 flex 换行这一条路。
 *
 * 基准宽度写成 `calc((100% - 3 * gap) / 4)` —— 一行正好四张，**除得尽**，
 * 所以第一行四张一个字都不长；第二行剩三张时富余的 305px 由它们三个平分，
 * 各 385 宽。**没有任何写死的像素**，窗口怎么变都成立。
 *
 * ⚠ 手机档本来就是这么做的：帧的「行4」里只有 1 张卡（封禁），`flex:1 1 0`
 *   让它独占一整行 361 宽。所以「最后一行补齐」不是我发明的，是稿子自己的分法，
 *   电脑档只是同一个意思换到四列的宽度上。
 *
 * ⚠ `flex-direction: row` 一个字都不能省：帧给内容区写的是 `column`
 *   （手机档是竖排的），光加 `flex-wrap: wrap` 就成了「竖排 + 换列」——
 *   七张卡会变成七**列**。同一个坑在试题检索（㊿-13）那天刚踩过一次。
 *
 * ⚠ 那个 `>` 不能省：在线用户卡里也有叫「行1/行2/行3」的图层名（表格行），
 *   不加 `>` 会把表格行也变成 contents，表格会散架。
 * ⚠ 「除统计卡外的直接子节点横跨整行」用的是 `> *` 的写法 ——
 *   统计卡**不是**内容区的直接子节点（它们在「行N」里面），所以那条碰不到它们；
 *   写在这里是为了说清楚意图，不是为了压谁。 */
body[data-ardot="9:51"] .admin-shell-main [data-name="内容区"] {
  display: flex;
  flex-direction: row;
  flex-wrap: wrap;
  gap: var(--sp-4);
  align-content: flex-start;
  /* ★★ 撤掉的 `grid-auto-rows: min-content` 挡的是哪个病（**必须记住**）：
   *
   * 2026-09-30 实测（1280 宽，修复前）：在线用户卡 **0px 高**（连卡头带里面的
   * 「刷新」按钮一起没了），图表卡 **40px**（只剩一行标题，图是空的），
   * 而稿子给它们的内容分别是 311 / 237。
   *
   * 根因是**「栅格项」和「滚动盒」这两个身份撞在一起**：这两张卡都被帧 CSS 给了
   * `overflow:hidden`（圆角卡片本来就该裁内容），于是各自是一个滚动盒；而
   * `overflow != visible` 的盒子在块轴上算出来的固有尺寸是 0，自动行高照这个 0
   * 分配，行就塌成 0，卡片再被自己的 `overflow:hidden` 把内容全裁掉。
   * 当时在页面上现做实验验证过：只把那张卡的 `overflow` 临时改成 `visible`，
   * 高度立刻从 0 变 311。
   *
   * ⚠ 改 flex 换行是**冲着另一件事**去的（见上：栅格没法补齐最后一行），
   *   但这个塌卡的风险跟着换了容器、**必须重新证明没复发**，不能假设。
   *   探针里量着那两个数：图表卡 237、在线用户卡 311（1455 宽实测），
   *   外加「两张卡都不为 0」。**哪天这两条红了，就是这里又回来了。** */
}
body[data-ardot="9:51"] .admin-shell-main [data-name="内容区"] > * {
  flex: 0 0 100%;
}
body[data-ardot="9:51"] .admin-shell-main [data-name="内容区"] > [data-name^="行"] {
  display: contents;
}
/* 卡片在手机上是 `flex:1 1 0` + 写死 90 高（靠 flex 撑开宽度）。
   进了这一行就不该再自己定宽度；高度改成「至少 90」——标签折行时不该被裁。 */
body[data-ardot="9:51"] .admin-shell-main [data-name="后台统计卡"] {
  /* 见上：基准 = 一整行四等分，第一行四张不长，第二行三张平分整行。 */
  flex: 1 1 calc((100% - 3 * var(--sp-4)) / 4);
  height: auto;
  min-height: 90px;
}
/* ★★ 2026-10-02：图表卡里那块绘图区撑满卡片宽（用户点名的第二处观感问题）。
 *
 * 帧给 `.n-9-69` 写死了 `width:321px` —— 那是 393 稿子上「361 − 左右各 20」的量。
 * 卡片本身在电脑上是 1203 宽，于是图上只有**左边那一小块**有柱子，
 * 右边 880px 全空。同一屏下面的「答题量」那张卡（控制器自己建的 `.dash-bars`）
 * 本来就是满宽的，并排两张卡一张满宽一张缩在角上，更像坏了。
 *
 * `align-self: stretch` 单独写没用：帧那条 `width: 321px` 是显式的，
 * 显式宽度压过拉伸。所以要**同时**把宽度放开成 `auto`。
 *
 * 柱子那层（帧的「柱」/「柱（今日）」）本来就是 `flex:1 1 0; min-width:0`
 * （宽度无关），撑开之后会自己重新分配 —— 但**必须给个上限**，
 * 否则一根柱子会摊到 160 宽，糊成一片。`28px` 不是新定的数：
 * 就是 `.dash-bar-fill` 那条 `max-width` 的值（见 ㊹），两张卡用同一个上限。
 * ⚠ `justify-content` 要一起改成 `space-between`：柱子被上限卡住之后，
 *   多出来的空间会全部堆到右边（帧写的是 `flex-start`），
 *   结果是「七根细柱子挤在左边」——比不改还难看。space-between 把它们摊开。
 * ⚠ 全文收在 `.admin-shell-main` 里 ⇒ 手机档一个像素都碰不到（探针守着）。 */
body[data-ardot="9:51"] .admin-shell-main [data-name="图表占位"] {
  align-self: stretch;
  width: auto;
  justify-content: space-between;
}
body[data-ardot="9:51"] .admin-shell-main [data-name="图表占位"] > * {
  max-width: 28px;
}
/* ★ 2026-10-07：同一块绘图区在**窄手机**上还会把右边顶出屏幕。
 *
 * 上面那两条只管电脑。手机档这块是 `left:36px` + 帧写死的 `width:321px`
 * （= 393 稿上「361 − 左右各 20」）—— 屏幕一窄到 320，右边就出界 **37px**，
 * 而外面又不允许横向滚动 ⇒ 用户永远看不到被切掉的那一段，也不报错。
 * 这条从 2026-10-02 之前就在，是**干净的 HEAD 上就红**的那一条（全档闸门两条 ✘）。
 *
 * ⚠ **必须收在 `@media` 里**，且 `max-width` 用 **`100vw` 不是 `100%`** —— 这两条
 *   都是试错试出来的，原因写清楚免得有人改回去：
 *     ① `100%` 解析成**父盒子**的宽，而父盒子本身就是 321 ⇒ 在 393 上也会被收成
 *        265，把本来好好的版式改坏（**放得下还收**比溢出更难发现，因为它不报错）；
 *     ② 光有 `max-width` 没有断点也不行：`100vw - 56px` 在 360 上 = 304 < 321
 *        ⇒ 360 那一档也被收窄 17px，同样是不该动的地方动了。
 *   所以断点取 **356px**：这是「36 + 321 = 357」刚好还放得下的那一条线，
 *   低于它才真的顶出屏幕。`-56px` 里的 56 = 稿子的**左 36 + 右 20**。
 * ⚠ 桌面那条 `.admin-shell-main` 里 `width:auto` 仍然优先，两条不冲突。 */
@media (max-width: 356px) {
  body[data-ardot="9:51"] [data-name="图表占位"] { max-width: calc(100vw - 56px); }
}

/* ══════════════════════════════════════════════════════════════════
 * ㉗ 组织详情（10-1）：批量操作那一行
 *
 * 手机上「取消选择 / 删除选中 / 清空未使用」三枚按钮各占三分之一宽
 * （每枚 `flex:1 1 0`，361÷3 ≈ 120，正好是一个指头宽的目标）。
 *
 * 电脑上内容区 1188 宽，同一套 flex 会把每枚摊到 **392 宽** ——
 * 三枚加起来铺满一整行，其中两枚还是**红色**的（删除选中 / 清空未使用）。
 * 屏幕上最重、最显眼的三个东西变成了两个危险操作，而它们只是这一小节的
 * 批量按钮 —— 主次反了（同一页里「+ 添加成员」这种正常操作反而很小）。
 * 这是「照抄手机的比例」在宽屏上必然出的问题，不是稿子错。
 *
 * 改法：电脑上让三枚按钮**按自己的字宽**（flex:0 0 auto）+ 一个最小宽度，
 * 左对齐排一行。跟题库详情（9-187）那一行「下载模板 / 开始导入」是同一种样子 ——
 * 那一行在手机上就是自然的字宽，所以宽屏上本来就是对的，不用动。
 *
 * ⚠ 认的是**图层名**「批量行」，不是类名 —— 稿子重生成后 `.n-10-27` 会变。
 * ⚠ `10:1` 是这一条路由的 slug（app.js 的 ROUTES 里写着 `slug: "10-1"`）。
 * ⚠ 那个 `>` 要留着：只改这三枚按钮，别把「批量行」里面别的东西一起改了。
 * ══════════════════════════════════════════════════════════════════ */
body[data-ardot="10:1"] .admin-shell-main [data-name="批量行"] > * {
  flex: 0 0 auto;
  /* 手机上靠 flex 撑出 120 宽，现在不撑了，得给个下限 ——
     不然「取消选择」这种四字按钮会缩成紧贴文字的一条。 */
  min-width: 104px;
  /* 上面那条 `min-width:0` 是手机版式里为了让文字能省略号截断，
     宽屏上三枚按钮都短得很，用不上；留着它反而会把 width 算歪。 */
}

/* ══════════════════════════════════════════════════════════════════
 * ㉘ 左上角那枚「回首页」（电脑上才插，见 admin-shell.js 的 insertExit）
 *
 * 用户 2026-09-25：「管理后台切换回去的出入口按钮放在左上角"回首页"」。
 * 插在克隆出来的侧边栏最前面，所以它拿得到侧边栏那份 `padding:var(--sp-4)` 和
 * `gap:var(--sp-2)` —— 这里**只写这一枚自己的长相**，外层的间距一个字节不碰。
 *
 * ⚠ 数值全部照稿子的**导航行**（8-402 的「数据概览」那一行，`.n-8-403__2-1155`）：
 *   `gap:10px` / `padding:11px var(--sp-3)` / `border-radius:10px` / 图标 `24px` /
 *   文字 `14px`。照抄它是因为这枚按钮就长在那一列里，跟它们不是一个尺寸就看得出来。
 * ⚠ 要跟导航行**区分开**（不然像第十项菜单）：颜色用的是**主色**
 *   （`--cl-primary-700`，全站「能点」的那个色），不是导航行那档次要灰字；
 *   并且**没有选中态**——它永远不表示「你在哪」。
 * ⚠ `flex-shrink:0` 必须写：侧边栏是纵向 flex，不写的话窗口一矮它就被压成一条缝
 *   （app.css 规则 ⑧ 是同一个坑，那条只管内容区，管不到这里）。
 * ══════════════════════════════════════════════════════════════════ */
.admin-exit {
  display: flex;
  flex-direction: row;
  gap: 10px;
  align-items: center;
  align-self: stretch;
  flex-shrink: 0;
  padding: 11px var(--sp-3);
  border-radius: 10px;
  color: var(--cl-primary-700);
  font-size: 14px;
  font-family: "Noto Sans SC", var(--ff);
  font-weight: var(--fw-medium);
  line-height: var(--lh-caption);
  text-decoration: none;
  cursor: pointer;
}
/* hover 用**选中行那个底色**（`--cl-primary-100`）——它跟前面那九行是邻居，
   悬停时跟「选中」看起来一样，用户不会觉得这是两种东西。 */
.admin-exit:hover { background: var(--cl-primary-100); }
/* 键盘走到它上面时给个明显的圈（全站其它可点元素也没有，但这枚是**出口**，
   键盘用户找不到出口就困在后台里了） */
.admin-exit:focus-visible {
  outline: 2px solid var(--cl-primary-700);
  outline-offset: 2px;
}
.admin-exit > svg {
  width: var(--sz-icon-lg);
  height: var(--sz-icon-lg);
  flex: 0 0 auto;
}

/* ══════════════════════════════════════════════════════════════════
 * ㉘-b 手机端后台顶栏右上角那枚「回首页」（见 admin-nav.js 的 insertTopbarExit）
 *
 * 用户 2026-09-26：「就是在手机端的那个管理后台右上角，现在是显示头像的那个东西，
 * 把那个替换成回首页。」——是**替换**，所以它占的是头像那一格的位置。
 *
 * ⚠ 长相**照抄它顶掉的那一格**（`Component/Avatar/Small`，`.n-*-__9-47`）：
 *   高度 32、药丸圆角、底 `--cl-primary-50`、字 `--cl-primary-700`、字重 medium，
 *   文字尺寸也照它里面那个「字」用的 `--fs-caption`。
 *   照抄的理由：那一格本来就在这条 56 高的横条里，换成别的尺寸/别的底就会
 *   显得是「塞进来一个东西」；现在是「同一格换了内容」。
 * ⚠ 宽度**不定**（头像那格写死 32×32 是因为里面只有一个字）：这是三个字加一枚图标，
 *   要按内容撑开。所以 `flex:0 0 auto` —— 顶栏是横向 flex，
 *   不定住的话标题那格（`flex:1 1 0`）会把它挤扁。
 *   量过：顶栏 393 宽减去左右内边距各 16，剩 361；汉堡 24 + 两处间距 12×2 +
 *   这一枚约 85，留给标题 228 —— 标题是最长的「纠错管理」（4 字），绰绰有余。
 * ⚠ 图标 16px：稿子那枚小房子是按 18×18 画的（底部导航一格），
 *   直接按 18 放进 32 高的药丸里比旁边的字高出一截，缩到 16 才跟 13px 的字配平。
 * ⚠ hover/focus 跟电脑版那枚**同一套**（悬停 `--cl-primary-100`、键盘给圈）：
 *   两枚「回首页」是同一个东西的两个位置，不该有两种手感。
 * ══════════════════════════════════════════════════════════════════ */
.admin-exit-top {
  display: flex;
  flex-direction: row;
  align-items: center;
  gap: 6px;
  flex: 0 0 auto;
  height: 32px;
  padding: 0 12px;
  border-radius: var(--rd-pill);
  background: var(--cl-primary-50);
  color: var(--cl-primary-700);
  font-size: var(--fs-caption);
  font-family: "Noto Sans SC", var(--ff);
  font-weight: var(--fw-medium);
  line-height: var(--lh-caption);
  text-decoration: none;
  cursor: pointer;
}
.admin-exit-top > svg {
  width: 16px;
  height: 16px;
  flex: 0 0 auto;
}
.admin-exit-top:hover { background: var(--cl-primary-100); }
.admin-exit-top:focus-visible {
  outline: 2px solid var(--cl-primary-700);
  outline-offset: 2px;
}

/* ══════════════════════════════════════════════════════════════════
 * ㉙ 后台的**弹窗**在电脑上的宽度（用户 2026-09-25：「全局适配，包括弹窗子页面」）
 *
 * 手机稿的弹窗有两条规矩，都是按 393 宽定的：
 *   · 底部面板 `.dialog-gaozi`：`left:0; right:0; bottom:0` —— 铺满整个视口宽；
 *   · 确认框 `[data-name="弹窗/确认"]`：311 宽、居中 —— 这条电脑上本来就是对的。
 *
 * 铺满视口宽的那条在 1440 上就是**一条 1440 宽、贴底的板子**：抓手在正中间，
 * 标题在最左边，关闭图标在最右边，三者隔着一整个屏幕，读写要在屏幕上横着找。
 * 手机上是「从底下滑上来的面板」，电脑上不该是这个东西。
 *
 * ⚠ 改成什么：**水平居中 + 限宽**，仍然贴底、仍然只圆上面两个角
 *   （它就是同一块面板，只是不再漫到整屏）。**没改成居中大对话框** ——
 *   那要动它的圆角、抓手、以及「从下面滑上来」这个进入方式，
 *   那就不是自适应，是换了一个控件（用户要的是「适配」）。
 * ⚠ 限宽挑 560：稿子的面板在手机上就是 393 减不掉的内边距，560 大约是一倍半，
 *   最长的那张（纠错/公告详情）一行读得完，不用转头。
 * ⚠ 选择器必须带 `.admin-shell`：这条只管**后台**里的弹窗。
 *   刷题页的纠错弹窗（15-111）在手机上，一个像素都不该碰 ——
 *   而手机上压根没有 `.admin-shell`（壳只在电脑上挂），所以这条天然碰不到它。
 * ══════════════════════════════════════════════════════════════════ */
.admin-shell .dialog-gaozi {
  max-width: 560px;
  left: 50% !important;
  right: auto !important;
  transform: translateX(-50%);
}
/* ⚠ 查过：`.dialog-gaozi` 身上**没有** transition、也没有 is-open 之类的进入态
 *   （app.css 里 `is-open` 只有 `.admin-nav` 那一处）—— 面板是直接出现的。
 *   所以上面那个 transform 不会顶掉任何动画，不用再补一条。 */

/* ㉙-b 上面那条**桌面档收不到** —— 它认的是 `.admin-shell`，而桌面档**不挂壳**
 *   （新稿整页自带侧栏 + 面包屑，见 `tools/build/probe-admin-table-desk.py` 第 3 条：
 *   「没有两条侧栏」）。所以电脑上从桌面档那一套界面的「新建题库 / 生成激活码 /
 *   新建公告 / 新建组织」点进去，弹出的是一条 **1440 宽、贴底**的板子 ——
 *   抓手在正中间、标题在最左、关闭图标在最右，读写要在屏幕上横着找。
 *   **那正是 ㉙ 当初要治的那个东西**，只是当时没发现这条路也走得到它。
 *   ⚠ 实测（2026-10-06，1600×1000 视口、zoom=1.111）：四条桌面档主按钮弹出来的
 *     面板宽度都是 **1440**（= 整张纸）。改完是 560 + 居中。
 *   ⚠ 取值与 ㉙ 一致（560），理由见 ㉙ 那段：稿子的面板在手机上就是 393 减内边距，
 *     560 大约一倍半，最长的那张一行读得完。
 *   ⚠ **没改成居中大对话框** —— 那要动它的圆角、抓手、以及「从下面滑上来」这个进入
 *     方式，那就不是自适应，是换了一个控件（同 ㉙ 的判断）。 */
.screen[data-ds="xq"] .dialog-gaozi {
  max-width: 560px;
  left: 50% !important;
  right: auto !important;
  transform: translateX(-50%);
}

/* ㉙-c 桌面档那张纸上的**遮罩**盖不满视口。
 *
 * 稿子 9-33 的遮罩写的是 `width:100%; height:852px` —— **852 是手机画布的高**。
 * 上面那条 `[data-name="遮罩"]{position:fixed;inset:0}` 把它钉成了固定定位，
 * 但 `height:852px` 那条声明还在（它没有 !important，而 `inset:0` 只覆盖 left/top/
 * right/bottom 四个属性）⇒ 在桌面档上它只盖到 **852 高**，纸是 900。
 * 实测（2026-10-06）：遮罩 1440×852，纸 1440×900 —— 底下那 48 没盖到，
 * 表现是「点弹窗下方那片空白关不掉弹窗」，而且底边不暗、看着像没铺满。
 * 手机上量不出来：手机视口正好 393×852，`100%` 与 `852px` 两个数**都对**。
 *
 * ⚠ 固定定位在**被 zoom 缩放的子树**里，是按「视口在这个缩放坐标系里的尺寸」算的
 *   （实测：`bottom:0` 的落点正确、`100%` 得到的是视口宽 1440 = 1600 ÷ 1.111）
 *   ⇒ 这里把视口尺寸按 `--xq-zoom` 反算一次就对了。系数由
 *   `admin-desk.js` 的 `applyDeskScale()` 写在屏上（跟 `zoom` 同一个值）。
 */
.screen[data-ds="xq"] [data-name="遮罩"] {
  width: calc(100vw / var(--xq-zoom, 1));
  height: calc(100vh / var(--xq-zoom, 1));
}

/* ══════════════════════════════════════════════════════════════════
 * ㉚ 2026-09-26 那一批：「我的」页的激活大卡片 + 管理组织 +「我的组织」那页的退出
 *
 * 这一批里没有一个类名来自稿子 —— 全是**运行时现造**的节点（稿子上没有这几格）：
 *   · .org-slot / .org-shelf-add / .sheet-buy-link   「我的」页组织区的激活入口
 *                                   （2026-10-20 从大卡片改书架第一格，routes/profile.js）
 *   · .card-set                     「我的」页账号卡右边那枚「设置」
 *   · .org-mcell--on / .org-cell-quit / .org-bar   「管理组织」那页的勾选与退出
 *                                   （2026-10-20 从行卡片改书架格，routes/organizations.js）
 * 所以它们不进 web/assets/pages/*.css（那些是 gen.mjs 从稿子生成的，勿手改），
 * 一律写在这儿。手写帧 settings.html 的样式是另一回事，在
 * web/assets/pages/settings.css（那一份自己带文件头说明）。
 *
 * ★ 一条颜色/字号/圆角都不是新造的：全部取 tokens.css 的变量。
 *   写死一个色号，暗色主题下就会有一块是亮的，而且不会报错。
 * ══════════════════════════════════════════════════════════════════ */

/* ── 「我的」页 · 组织区里那两枚自己造的入口 ─────────────────────
 * 2026-10-14 之前，激活卡 + 管理组织是**装在一个 `.org-foot` 里**一起摆在
 * 组织列表下面的；用户要求「激活组织排在所有组织的第一个」之后，两枚分开了：
 *   激活大卡片 → 组织卡片们 → 管理组织
 * 于是那个「两枚共用的外框」没有理由再存在（各自是组织区里的一个兄弟），
 * 只留下它真正在做的那件事 —— `align-self: stretch`。
 * ⚠ 这一条**不能省**：组织区那一格是 `align-items: flex-start`（稿子 8-939 的
 *   `.n-8-993`），子节点不会自动撑满 ⇒ 少了它，卡片的宽度会缩成内容宽，
 *   跟旁边的组织卡片不一样宽（而「跟组织卡片同宽」正是当初做这张卡的唯一理由）。 */
.org-slot {
  align-self: stretch;
  flex-shrink: 0;
}

/* ① 激活组织 —— 2026-10-20 从「一整张大卡片」改成**书架第一格**（虚线「+」位）。
 *
 *    墓碑：.org-activate-card / -ico / -text / -title / -sub / -buy 这一族
 *    （2026-09-26 大卡片版）已随 JS（profile.js orgActivateCell）一起删干净 ——
 *    用户原话「激活组织要加成一个那种书架类似类型的同一行」。别再把大卡样式
 *    加回来；「没有激活码？去购买 →」那行链接改住在激活面板里（.sheet-buy-link）。
 *
 *    新样子照后台书架的 shelfAdd（ax-scard-add 那一格）：虚线框 + 「+」+
 *    下面「激活组织」四个字，尺寸跟旁边的组织书同格（56 宽 3:4 比例）。
 *    ⚠ 虚线框宽度按书的投影收窄（cos26°≈0.9，同后台 ax-add-box 的算法）：
 *      书转了 26° 之后看到的宽度是 56×0.9≈50，虚线框要跟**看到的那本书**齐。 */
.org-shelf-add { cursor: pointer; }
.org-add-box {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 50px;                              /* = 书 56 宽 × cos26°，见上 */
  aspect-ratio: 3/4;                        /* 跟 mountBook 的书同一比例 */
  border: 1.5px dashed var(--cl-border-strong, var(--cl-border-base));
  border-radius: var(--rd-md);
  background: var(--cl-bg-card);
  color: var(--cl-text-tertiary);
  transition: border-color .16s, background .16s;
}
.org-add-box > svg { width: var(--sz-icon-md); height: var(--sz-icon-md); display: block; }
.org-shelf-add:active .org-add-box {
  border-color: var(--cl-primary-600);
  background: var(--cl-primary-50);
}
.org-shelf-add:focus-visible { outline: 2px solid var(--cl-primary-600); outline-offset: 2px; }
/* 格子下面的「激活组织」四个字：跟组织名同一档（.org-shelf-name），
   颜色用次要色 —— 它是动作不是数据，别抢组织名的视觉重量。 */
.org-shelf-add .org-shelf-name { color: var(--cl-text-tertiary); font-weight: var(--fw-regular); }

/* 「没有激活码？去购买 →」—— 激活面板里那一行链接（2026-10-17 用户要的，
   2026-10-20 随大卡改书架格一起从卡片上挪进面板，spec.after 那条口子）。
   主色 + 手型 + 下划线：语义是**另一个动作**（去购买），所以染主色。
   ⚠ 不加底色/边框 —— 它只是面板里的一句引导，不是第二张卡。 */
.sheet-buy-link {
  padding: 2px 0 4px;
  color: var(--cl-primary-600, #6d4aff);
  font-size: var(--fs-caption);
  font-family: "Noto Sans SC", var(--ff);
  font-weight: var(--fw-medium);
  line-height: var(--lh-caption);
  text-decoration: underline;
  text-underline-offset: 2px;
  cursor: pointer;
}
/* ⚠ 标题那个 16px 是**抄组织卡片那一格的字号**（稿子那张卡的标题就是 16px），
   两边并排在一个列表里，字号不一样会显得其中一张"轻"。不是随手挑的数。 */

/* ② 管理组织 —— 一枚文字按钮，**不加底色**。
 *    它跟上面那张大卡片是两件事：一个是「加进来」，一个是「管已有的」。
 *    都做成大卡片的话，两个动作看着一样重；这里故意让它轻一点 ——
 *    右边一枚箭头，是稿子里「这一行点进去还有一屏」的一贯画法。 */
.org-manage-btn {
  position: relative;
  display: flex;
  flex-direction: row;
  align-items: center;
  justify-content: center;
  gap: var(--sp-1);
  align-self: stretch;
  min-height: var(--sz-tap-min);
  padding: var(--sp-2) var(--sp-4);
  border-radius: var(--rd-lg);
  color: var(--cl-text-secondary);
  font-size: var(--fs-body);
  font-family: "Noto Sans SC", var(--ff);
  font-weight: var(--fw-medium);
  line-height: var(--lh-body);
  cursor: pointer;
}
.org-manage-btn > svg { width: var(--sz-icon-md); height: var(--sz-icon-md); flex-shrink: 0; }
.org-manage-btn[data-press="down"] { background: var(--cl-bg-sunken); }
.org-manage-btn:focus-visible {
  outline: 2px solid var(--cl-primary-600);
  outline-offset: 2px;
}

/* ── 「我的」页 · 账号卡右边那枚「设置」 ─────────────────────────
 * 稿子的账号卡是一张卡里两行（昵称 + 账号）。这一枚是 append 到**卡**上的，
 * 定位在右上角，所以给卡片右边留出一段位置给它，免得压在名字上。 */
.card-set {
  position: absolute;
  /* 垂直居中、右边距取**卡片自己的**水平内边距（稿子的 .n-8-991 写的就是 sp-5），
     这样那两个字跟卡片里的字右对齐。不写 top:Npx —— 卡片高度是稿子定的，
     写死一个数，等稿子改了内边距就错位（错位不报错，只是看着别扭）。 */
  top: 50%;
  right: var(--sp-5);
  transform: translateY(-50%);
  display: inline-flex;
  flex-direction: row;
  align-items: center;
  gap: 2px;
  color: var(--cl-primary-600);
  font-size: var(--fs-caption);
  font-family: "Noto Sans SC", var(--ff);
  font-weight: var(--fw-medium);
  line-height: 1;
  text-decoration: none;
  cursor: pointer;
}
.card-set > svg { width: 16px; height: 16px; flex-shrink: 0; display: block; }
/* 触控目标往四周撑到 44（HIG）—— 纯伪元素、不占空间、不改绘制位置
   （跟 ⑤ 那枚汉堡、㉔ 那枚旧的「＋ 激活组织」同一套做法）。 */
.card-set::after {
  content: "";
  position: absolute;
  top: 50%;
  left: 50%;
  width: var(--sz-tap-min);
  height: var(--sz-tap-min);
  transform: translate(-50%, -50%);
}
.card-set:focus-visible {
  outline: 2px solid var(--cl-primary-600);
  outline-offset: 2px;
}
/* 卡片得能给绝对定位当参照。⚠ 只加 position，不改稿子给的任何一条版式。
   （查过：稿子那张卡的 CSS 里没有 position，所以这条不跟它抢。） */
[data-name="账号卡"] { position: relative; }
/* ⚠ 名字长的时候会顶到那枚「设置」下面，所以右边留出它的宽度。
   用 :has() 而不是改稿子的类：稿子那几条是 gen.mjs 生成的，手改会被下一次同步冲掉；
   而 :has() 只在「真有这一枚」时才改排法，没插进去的屏一个像素都不动。
   （name 有 ellipsis，最坏是少显示几个字，不会串行、不会压在一起。）
   ⚠ 选择器带 :has() 是**特异性 (0,2,0)**，压得住稿子那条 (0,1,0) —— 这不是巧合：
    稿子 CSS 是运行时后加载的，同特异性时后加载的赢，所以想赢稿子就必须更高。 */
[data-name="账号卡"]:has(.card-set) { padding-right: calc(var(--sp-5) + 52px); }

/* ── 「管理组织」那页（8-937）的勾选 / 退出 / 底栏 ─────────────
 *
 * 墓碑：.org-row / .org-tick / .org-quit（2026-09-26 行卡片版）已随 JS
 *   （organizations.js paintCell）改书架一起删干净 —— 用户 2026-10-20
 *   「激活组织页面我们应该像新建组织那样，弄个大小的一个卡片」。
 *   勾选画在书格上（.org-mcell--on 内描边），单独退出变成格角「×」
 *   （.org-cell-quit）。别再把行卡样式加回来。
 */

/* 书架格选中态（点格 = 勾选）。**一圈内描边 + 淡主色底**：
   ⚠ 选择器双类打头（.org-mshelf .org-mcell--on = 0,2,0）——
     书架本体样式在 9855 段，帧 CSS 又是运行时后加载的，同特异性时后加载的赢，
     单类写法可能被压掉：不报错、不警告，就是点了没反应（aria-checked 对，环不出现）。
   ★ 底色这次敢换 primary-50 了：旧版不换底是因为「我的」页那张激活大卡是紫底，
     撞脸；2026-10-20 大卡已删（改书架第一格虚线「+」），撞脸对象不存在了。
   ★ 选中用 box-shadow（内描边）、键盘焦点用 outline（外描边）——
     两条不同的边，能同时看见，不是随手挑的。 */
.org-mshelf .org-mcell--on {
  box-shadow: inset 0 0 0 2px var(--cl-primary-600);
  background: var(--cl-primary-50);
}
.org-mshelf .org-shelf-cell:focus-visible {
  outline: 2px solid var(--cl-primary-600);
  outline-offset: 2px;
}

/* 格子右上角那枚「×」= 单独退出这一个（原行卡右边那枚「退出」换了地方，
   动作没变）。**危险色**：往外的动作，跟「设置」页「注销账号」同一个 token。
   ⚠ 它在 role=checkbox 的格子上（checkbox 里套 button 不算干净的无障碍，
     取舍理由见 organizations.js paintCell 的注释）；
   ⚠ 「×」的 click 事件在 JS 里 stopPropagation —— 点它不算勾选。 */
.org-cell-quit {
  position: absolute;
  top: 2px;
  right: 2px;
  z-index: 2;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 22px;
  height: 22px;
  border-radius: 999px;
  background: var(--cl-bg-card);
  box-shadow: 0 1px 2px 0 rgba(28, 27, 34, 0.10);
  color: var(--cl-text-tertiary);
  font-size: 15px;
  font-weight: var(--fw-medium);
  line-height: 1;
  cursor: pointer;
  transition: color .12s, background-color .12s;
}
.org-cell-quit[data-press="down"] { background: var(--cl-danger-50); color: var(--cl-danger-600); }
/* 触控目标撑大：「×」本体只有 22，直接点费劲。伪元素 40×40（不是常见的 44）——
   格子总共才 ~76 宽，44 会把书的大半个点击区吞掉（点书=勾选，误吞就点不准）；
   40 压住书的右上一角，但「×」在 DOM 后面 + z-index 更高，重叠区命中的是它，
   这是 iOS 照片/通讯录删除角标的同款取舍。 */
.org-cell-quit::after {
  content: "";
  position: absolute;
  top: 50%;
  left: 50%;
  width: 40px;
  height: 40px;
  transform: translate(-50%, -50%);
}
.org-cell-quit:focus-visible {
  outline: 2px solid var(--cl-danger-600);
  outline-offset: 2px;
}

/* 底栏（「退出所选」）。挂在**稿子根节点**里（.n-8-937，也就是 .screen 里那一层）、
   排在 content 后面 —— 所以它是贴在屏幕底下的一条常驻栏，不跟着卡片一起滚。
   ⚠ **不是**挂在 .screen 上：app.css 那条 `.screen > *` 会给「稿子根节点」这一层
     统设 height:100dvh，直接挂 .screen 会被抓住、变成一整屏高（见 ensureBar 的注释）。
   ⚠ 它必须 flex-shrink:0：不写的话内容区被压扁时它会跟着缩。 */
.org-bar {
  display: flex;
  flex-direction: row;
  align-items: center;
  align-self: stretch;
  flex-shrink: 0;
  padding: var(--sp-3) var(--sp-5) var(--sp-4);
  background: var(--cl-bg-page);
  box-shadow: inset 0 1px 0 0 var(--cl-border-light);
}
.org-bar-btn {
  position: relative;
  display: flex;
  flex-direction: row;
  align-items: center;
  justify-content: center;
  flex: 1 1 0;
  min-height: var(--sz-control);
  border-radius: var(--rd-lg);
  background: var(--cl-primary-600);
  color: var(--cl-text-inverse);
  font-size: var(--fs-body);
  font-family: "Noto Sans SC", var(--ff);
  font-weight: var(--fw-medium);
  line-height: 1;
  cursor: pointer;
}
.org-bar-btn[data-press="down"] { background: var(--cl-primary-700); }
.org-bar-btn:focus-visible {
  outline: 2px solid var(--cl-primary-700);
  outline-offset: 2px;
}
/* 一个都没勾：**压灰、留在原地**（不是藏起来）——
   一会有一会没有的按钮，用户会以为刚才点错了。
   ⚠ 同时 organizations.js 把 tabindex 摘到 -1（键盘别停在一个按不动的格子上），
     所以这里只管长相。 */
.org-bar-btn[data-off="1"] {
  background: var(--cl-bg-sunken);
  color: var(--cl-text-disabled);
  cursor: default;
}
/* 有底栏时，content 底部那 40px（稿子的 sp-10）就没用了 —— 它原本是给
   「内容一直排到屏幕底」留的安全边，现在下面压着一条 76px 的栏，
   再留 40px 就是一条**看着像出错了**的空带。收紧到跟底栏的上内边距同一个数。
   ⚠ 用 :has(+ .org-bar) 而不是改稿子那条 .n-8-984：稿子是 gen.mjs 生成的，
     手改下次同步会被冲掉；而且这条只在**真挂了底栏**时才生效，
     设计索引里单独打开这一帧时一个像素都不动。
   ⚠ 特异性 (0,2,0)：[data-name] 一条 + :has() 里最具体的那个 (0,1,0)，
     压得住稿子的 .n-8-984 (0,1,0) —— 必须压得住，稿子 CSS 是后加载的。 */
[data-name="content"]:has(+ .org-bar) { padding-bottom: var(--sp-3); }

/* ══════════════════════════════════════════════════════════════════
 * ㊱ 头像 = 星奇那一枚品牌图标（登录页那枚红色像素小人）
 *
 * 用户 2026-09-26：「头像不准修改吧，默认星奇 icon 图标」，
 * 紧接着纠正地址：「不是，登陆页 icon 才是！」
 * → 地址跟 routes/login.js 那处是同一枚图（红色像素兔子）。
 *
 * ★★ 2026-10-06 订正地址：原先写的是 `url("/web/logo.png")` —— 一个**绝对路径**，
 *   而那个文件**不在载荷里**（`pack-steps.mjs` 的 TOP_LEVEL 里没有它）。
 *   它能在线上显示，只是因为**服务器磁盘上碰巧还躺着一份孤儿副本**；
 *   本地（`node app/serve.mjs`，站点根是 `/app/`）和 `05_部署应用.sh --dry`
 *   预演里它**必然 404**，表现是**头像是一枚空白淡紫圈**（跟「没图标」几乎一样，
 *   不报错，所以一直没被发现）。
 *   量到的现场：预演跑完那句「预演请求里的 404 条数」从 0 变 1，
 *   那一条就是它（`/logo.png`）。
 *
 *   现在改成**相对本样式表的路径** `logo.png`：
 *     · CSS 里的相对 url 一律相对**样式表自己**解析，跟 `<base>` 无关 ——
 *       本地 app.css 在 `/app/app.css` ⇒ `/app/logo.png`；
 *       线上在 `/web/xq/app.css` ⇒ `/web/xq/logo.png`。两边都是载荷里那一份。
 *     · `logo.png` 就住在 `app/` 里，跟着载荷走、有源码、在打包清单里。
 *   ⚠ 别再写回绝对路径 —— 「线上能显示」不代表这个依赖是安全的。
 *
 * 挂在**圈**上（背景图），不是往圈里塞 <img>：见 app/avatar.js 文件头那一段 ——
 * 头像圈本来就有 overflow:hidden + 圆角 + 居中，背景图天然落在圈里；
 * 塞 <img> 的话每处头像位的圈尺寸都不一样，就得每处单配一条尺寸规则。
 *
 * ★ 那枚图本身就是一张**圆角方的完整图标**（红底 + 白像素小人 + 两道星光），
 *   所以用 `cover` 让它在圈里铺满 —— 出来的就是一个正常的圆头像。
 *   用 `contain` 的话圈里会留一圈底色，看着像图标没加载全。
 *
 * ⚠ 为什么带 `.screen` 前缀（而不是光写 .avatar-brand）：
 *   两个圈各自都有一条 `background:` 简写（.set-avatar 是
 *   `background: var(--cl-primary-100)`，稿子那条 .n-8-996 同理）。
 *   简写会把 background-image 一起重置成 none，而**稿子/手写帧的 CSS 是
 *   运行时后加载的、特异性同为 (0,1,0)** —— 只写一个类的话图标会被它压掉，
 *   圈里还剩一坨淡紫。屏幕上不报错，只是图标不见了。
 *   加 .screen 之后是 (0,2,0)，压得住。这个坑这一轮已经踩过两次
 *   （.org-row--on、.org-bar），见 app.css 里那两处的注释。
 *
 * ⚠⚠ 2026-09-26 补第三条：**侧边栏那一枚不在 `.screen` 里。**
 *   上面这条 `.screen .avatar-brand` 加上的当天，后台侧边栏底部那枚圈就漏了 ——
 *   它不是挂在屏里的，是挂在壳上的：
 *     手机：`body > div.admin-nav.is-open > aside.admin-nav-panel > …`
 *     电脑：`body > div.admin-shell > div.admin-shell-side > aside.admin-nav-panel > …`
 *   两条链里都**没有 `.screen`** ⇒ 选择器够不着 ⇒ 又被 8-402.css 那条
 *   `.n-8-403__2-1184{background:var(--cl-primary-50)}`（0,1,0）压掉 ⇒
 *   用户看到的是**一枚空白淡紫圈**（比「没有图标」更像坏了）。
 *   `.admin-nav-panel` 那一层是抽屉与常驻侧边栏**共用**的（见下面 §.admin-shell-side），
 *   所以加它一条就够，手机电脑一起修好。两条都是 (0,2,0)，压得住 (0,1,0) 的简写。
 *
 *   怎么被发现的：闸门第 8 步（`probe-admin-nav.py`）量到
 *   `background-image 'none'` —— **它红得有道理，是屏上的真缺陷**，
 *   跟同一档里那条「探针自己坏了」的假红（见 probe-admin-nofake.py 文件头）不是一回事。
 * ══════════════════════════════════════════════════════════════════ */
.screen .avatar-brand,
.admin-nav-panel .avatar-brand {
  background-image: url("logo.png");
  background-repeat: no-repeat;
  background-position: center;
  background-size: cover;
}
/* 圈里原来烤着的样字（稿子的「星」）清掉之后可能还留着占位，藏掉它，
   免得某些圈里它压在那枚图标上。 */
.screen .avatar-brand > [data-name="字"] { display: none; }

/* ══════════════════════════════════════════════════════════════════
 * ㊲ 卡片组 / 文章组：五张手写帧共用的几条容器
 *
 * 为什么这几条在 app.css 而不在帧自己的 CSS 里：
 *   ① **三态是从别的帧 clone 来的**（2-1309 / 2-1313 / 2-1483，见
 *      `app/routes/articles.js` 的 `fillState`）。借来的是**节点**，
 *      它**不带原来那一屏的版式上下文** —— 2-1313 的空态在原来那一屏里
 *      靠 `flex:1 1 0` 撑满（`.n-2-1317` 反而是 `flex-shrink:0`，
 *      撑满那件事是外层 `content` 干的）。挂到新屏上要**新给一个容器**，
 *      否则空态会贴在顶上、骨架屏会塌成 0 高（`flex-basis:0` + 没高度可长），
 *      **两条都不报错**。
 *   ② **写进帧是错的**：那五张帧是手写的，但它们的 CSS 只管自己画的节点；
 *      三态零件不属于任何一张帧（三张源帧的服务对象是旧的题库列表那几屏）。
 *      写进哪一张手写帧都只是「恰好那张在用」，换一屏就没了。
 *   ③ 选择器带 `.screen` 前缀 = (0,2,0)，**压得住手写帧 CSS 的 (0,1,0)**
 *      —— 手写帧是在运行时后加载的，同特异性时它赢。这是本项目踩过两次的
 *      那个坑（见上面 ㊱ 那段，还有它提到的 .org-row--on / .org-bar）。
 *
 * ⚠ 三态是**就地换一块**，不换整屏：`态位` 亮着的时候，主内容由控制器
 *   `hidden`（那些是控制器的事，这里只管容器）。
 * ══════════════════════════════════════════════════════════════════ */

/* ① 三态落点。
   `flex:1 1 0` 是给**骨架屏**（2-1309 的「骨架屏/列表」是 `flex:1 1 0;min-height:0`）
   用的：它在原来那一屏里靠 flex 撑满，这里必须给它一个能撑满的父容器。
   空态 / 错误态那两块自己带 `flex-shrink:0` + `--sp-12` 的上下内边距，
   放进这个盒子里会**贴在顶部**（跟它们在原来那一屏里的位置一致，不是 bug）。 */
.screen [data-name="态位"] {
  display: flex;
  flex-direction: column;
  flex: 1 1 0;
  min-height: 0;
}
/* 态位里那一块零件本身不许被拉伸成半高——它是**内容**，不是容器。 */
.screen [data-name="态位"] > * { flex-shrink: 0; }
.screen [data-name="态位"] > [data-name="骨架屏/列表"] { flex: 1 1 0; min-height: 0; }

/* ★ 2026-10-14 修用户报的「加载屏先看到的东西和最终不在一个地方」。
 *
 *   骨架屏是**借**来的节点：文章库（`reading-list`）用的是题库列表那张状态帧
 *   `2-1309` 里的「骨架屏/列表」，而那一块自己带着 `.n-2-1312` 的内边距
 *   `padding: var(--sp-4) var(--sp-5) …`（**左右各 20** —— 那 20 是题库列表那一屏
 *   的容器内边距，它带不过来也不该带）。
 *   而文章库的内容区 `.rl-body` **自己也给了 20**（`padding: 16 20 24`）。
 *   两笔一叠 ⇒ 骨架卡落在 x=40、真正的文章卡落在 x=20，骨架被多缩进一格；
 *   数据一到、切过去的那一瞬间，左边缘整列跳 20px。
 *   （实测：骨架块 x=20 / 骨架卡 x=40；对照练习记录那条 —— 它借的 `11-183`
 *     没有多余内边距，骨架卡与真卡都是 x=20，Δ=0，看不到跳。）
 *
 *   为什么在这里归零、而不是去改 `2-1309`：那一块在**它自己那一屏**（题库列表）
 *   是对的（那一屏的卡片本来就在 40 上）。错的是**这一屏的嵌套关系**，
 *   所以只在这一屏的容器（`.rl-body`）里把那两笔里多出来的一笔去掉。
 *   ⚠ 只归**左右**：上下那 16 是骨架自己的节奏，跟真卡的顶边差（页面头/汇总区）
 *     不是一回事，动了反而会贴顶。 */
.rl-body [data-name="态位"] > [data-name="骨架屏/列表"] {
  padding-left: 0;
  padding-right: 0;
}

/* ⚠ **空着的态位必须让出位置**（`:empty` → `display:none`）。
   起因：闪卡页（`flashcard.html`）正常画着卡的时候，`态位` 是空的；而上面那条
   `flex:1 1 0` 会让一个**空的**盒子照样把剩余空间吃光。后果是卡片下面那块
   「剩余空间」被一个看不见的东西占了，`.fc-card` 的 `margin:auto 0`（内容短时
   垂直居中，§3.1）就没有空间可分 —— 表现是**卡片贴到顶上去了**，而且不报错。
   阅读页同理（正文区下面被一个空盒子顶开，长正文的滚动区变短）。
   `:empty` 在四个控制器里都成立：三种态都走 `replaceChildren(节点)`（非空），
   正常态都走 `replaceChildren()`（真空 —— 帧里那两个 `<div …></div>` 之间
   一个空白字符都没有，`.replaceChildren()` 也不留）。 */
.screen [data-name="态位"]:empty { display: none; }

/* ② 文章列表的「一节」——控制器生成的两层容器（`articles.js` 的
   `paintSection`）。⚠ 这两格**不在** §5.2b 那张表里，是我在控制器里
   为了分段新起的（表上只有 `节头`/`节名`/`节数`/`文章卡` 那一套）。
   为什么不干脆摊平：摊平之后 `.rl-list` 那条 16 的 gap 会把
   **节头跟它自己的卡**也隔开 16，节看起来就不成组了。
   间距取法：节与节 16（`.rl-list` 自己的 gap 给的，这里不重复写）、
   节头到它第一张卡 12（`--sp-3`）、卡与卡 16（`--sp-4`，跟列表那一层一致）。 */
.screen [data-name="节段"] {
  display: flex;
  flex-direction: column;
  gap: var(--sp-3);
  flex-shrink: 0;
}
.screen [data-name="节下"] {
  display: flex;
  flex-direction: column;
  gap: var(--sp-4);
  flex-shrink: 0;
}

/* ══════════════════════════════════════════════════════════════════
 * ㊳ 卡片组 / 文章组那三屏（后台第二十二轮，2026-09-26）
 *    · admin-cards        卡片管理（表格）
 *    · admin-articles     文章管理（表格）
 *    · admin-article-edit 编辑文章（双栏）
 *
 * ── 这一节原来是什么，为什么整节重写 ─────────────────────────────
 * 原来它管的是两张「壳子帧」（正文摆五个空挂载点、内容全 clone 自 9-187），
 * 末尾还写着「电脑 1440：量过了，**一条都不用加**」—— 那个结论**已经不成立**：
 * 那两屏现在是**表格**（设计稿 24:1164 那一版），而表格在 393 上排不出来，
 * 桌面那几条是必须的。
 *   旧那几条（`.ac-slot:empty`、`.ac-slot[data-name="列表"]`）随帧一起作废，
 *   这里删掉 —— 留着它们就是给下一个人一份「看起来还在管、其实谁也匹配不上」的规则。
 *
 * ── 分工（本项目一贯的）────────────────────────────────────────
 * 帧自己的 CSS 只管 **393**；带 `body[data-ardot="…"]` 前缀的这几条只管**电脑**。
 * 两条都别改形状：`data-ardot` 是帧根节点上的标记，也是 `mountScreen` 写到
 * `document.body` 上的那个值；桌面端换屏那一步（mountGroup / admin-article-edit
 * 的 mount 里）**必须把壳的 `data-ardot` 一起换掉**，忘了换的表现是
 * 「这一页的桌面版式全不生效」，而且不报错。
 *
 * ── 列宽是从设计稿量来的 ───────────────────────────────────────
 *   · 卡片：正面 fill / 背面 134 / 章节 192 / 操作 92
 *   · 文章：标题 fill / 章节 154 / 时长 180 / 音频 92 / 操作 92
 *   表格总宽 = 1440 − 220（侧边栏）− 48（内容区左右内边距）= 1172。
 *
 * ⚠⚠ 2026-09-26 用户报「列表文字排版混乱，标题竖着一个字一行」——
 *   根因**不在这一节**，在帧 CSS 里：`web/assets/pages/admin-{articles,cards}.css`
 *   那条 `.ac-cell`（顶栏左右槽的 44×44 点按格）**没限定作用域**，
 *   而表格里的单元格也叫 `.ac-cell`。帧 CSS 运行时后加载、同特异性它赢
 *   ⇒ 表格每一格被压成 44×44、`flex-shrink:0`，中文只能一个字一行。
 *   表头用的是另一个类名（`.ac-th`）所以列位置全对 —— 画面就成了
 *   「表头正常、数据行整片竖排」，看着像这一节的网格算错了，其实不是。
 *   已修（`→ .ac-top > .ac-cell`）。**这一节一条都没动。**
 *   ⚠ 教训：这一屏里 `.ac-cell` 一个类名指两件东西（顶栏格 / 表格格），
 *     以后在这一屏按类名写全局样式，先问「这个类名还指什么」。
 * ══════════════════════════════════════════════════════════════════ */

/* ① 那枚小徽标：底色从**绿**压成**灰**。
 *
 * 稿子画的是 `--cl-success-50` 底 + `--cl-success-700` 字 —— 而绿色在这个设计
 * 系统里是**成功**：拿它表示「这是一个卡片组」「这篇没听过」，都会被读成
 * 「这个没问题」。12 号 §2.2 那张类型胶囊本来就是灰底灰字，这里是照它来的。
 * （现在这两屏的行上已经没有徽标了 —— 列名写清楚了「章节 / 音频」；
 *   这条留给还在用它的题库管理列表与题库详情。）
 *
 * ⚠ 底和字**要两条**：那枚药丸是「一个盒子 + 盒子里一格字」，稿子把底色写在外层、
 *   字色写在里层，只压外层的话盒子里那行字还是绿的。
 * ⚠ 里层这条**故意不加 `!important`**：需要抢的那一处特异性较低（抢得到），
 *   不需要抢的那一处同分且稿子 CSS 后加载（输了也还是灰的，它自己就是灰字）。 */
.screen .admin-tag { background: var(--cl-bg-sunken); }
.screen .admin-tag > * { color: var(--cl-text-secondary); }

/* ② 表格里那一段三态（`data-name="态位"`）。
 *
 * 它在 `.ac-table` 里、表头行之后 —— 设计稿那五档（默认/空/加载中/错误/极值）
 * 都是**画在表格框内**的：表头与工具栏照常显示，换掉的只有表格里那一段。
 * 居中是因为 `fillState` 借来的是**整屏高**的空态/错误态（2-1313 / 2-1483），
 * 靠左顶格会看起来像「内容没铺开」。
 *
 * ⚠ 空着时整格让位（`:empty`）：它是 `.ac-table` 的最后一个孩子，空着会把
 *   表格的下边框推下去一段看不见的空。控制器用 `replaceChildren()` 清它，
 *   所以 `:empty` 成立（这也是它**不许**写成 `display:none` 常驻的原因）。 */
.screen [data-name="态位"]:empty { display: none; }
.screen .ac-table > [data-name="态位"] {
  display: flex;
  justify-content: center;
  padding: var(--sp-6) 0;
}

/* ③ 「组设置」那枚小字菜单（控制器造的节点，所以落在这儿而不是帧里）。
 *
 * 一条浅色遮罩吃掉外部点击（点外面关掉），菜单贴在触发按钮的右缘下方 ——
 * 坐标由控制器按 offsetLeft/offsetTop 现算（帧 CSS 给 `.ac` 写了 position:relative，
 * 那两值就是相对整屏的坐标）。这里只给**长什么样**，不给坐标。 */
.screen [data-role="overlay"] .ac-menu-mask {
  position: absolute;
  inset: 0;
  z-index: 1;
}
.screen [data-role="overlay"] .ac-menu {
  position: absolute;
  z-index: 2;
  display: flex;
  flex-direction: column;
  gap: var(--sp-1);
  min-width: 180px;
  padding: var(--sp-2);
  background: var(--cl-bg-card);
  border-radius: var(--rd-lg);
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.16), inset 0 0 0 1px var(--cl-border-base);
}
.screen .ac-menu-item {
  display: flex;
  align-items: center;
  min-height: var(--sz-tap-min);
  padding: 0 var(--sp-3);
  border-radius: var(--rd-md);
  cursor: pointer;
  color: var(--cl-text-primary);
  font-size: var(--fs-body);
  font-family: "Noto Sans SC", var(--ff);
  line-height: var(--lh-body);
}
.screen .ac-menu-item:hover { background: var(--cl-bg-sunken); }
/* 危险动作（删组）用 10-127 那格红字用的同一个红（跟后台其它地方的「删除」一致） */
.screen .ac-menu-item--danger { color: var(--cl-danger-700); }

/* ══ 电脑（1440）：卡片管理 / 文章管理 —— 把表格摊开 ══════════════
 *
 * 设计稿这两块是 1440×900（220 侧边栏 + 顶栏 64 + 内容区 24 内边距、16 间距），
 * 而帧那份 CSS 写的是**手机档**（16 内边距、工具栏竖排、表头藏掉、每行降级成一张卡）。
 * 这里把两者差的那几处翻回来。 */
body[data-ardot="admin-cards"] .admin-shell-main .ac-body,
body[data-ardot="admin-articles"] .admin-shell-main .ac-body {
  padding: var(--sp-6);
  gap: var(--sp-4);
}
/* 工具栏：横排（设计稿是 搜索框 fill + 三枚按钮 hug，间距 12） */
body[data-ardot="admin-cards"] .admin-shell-main .ac-toolbar,
body[data-ardot="admin-articles"] .admin-shell-main .ac-toolbar {
  flex-direction: row;
  align-items: center;
  gap: var(--sp-3);
}
body[data-ardot="admin-cards"] .admin-shell-main .ac-search,
body[data-ardot="admin-articles"] .admin-shell-main .ac-search {
  flex: 1 1 0;
  min-width: 0;
}
body[data-ardot="admin-cards"] .admin-shell-main .ac-tools,
body[data-ardot="admin-articles"] .admin-shell-main .ac-tools {
  flex: 0 0 auto;
  flex-wrap: nowrap;
}

/* 表头行 + 每一行：同一套网格列（这样列才对得齐）。
   ⚠ 两处都要写：只写一处的话表头与行各按各的算，列会错位，
     而错位的表现是「看着就是没对齐」，查起来要量每一格。 */
/* ⚠ 2026-09-26 第二十三轮：最左多了一列（批量管理的勾选框，24 = --sz-icon-lg），
   原来那四/五列都在它右边。列宽一个都没动，表头与行两边同时改（见下面那段注释）。
   ⚠ 2026-09-27：**操作列 92 → 124**（两张表都改）。那一格放的是「编辑 / 删除」两枚
   文字按钮（`.gaozi-act`），量出来的内容宽是 **100**，而列只给 92 —— 差的那 8px
   会让两枚按钮**换行**，行高从 48 被撑成两倍，一整列看着像错位。
   （同一天修的 `.ac-cell` 44×44 是「格子太窄」的另一半，两个一起才叫「排版乱」。）
   多出来的 32px 从右边那列 `minmax(0, 1fr)` 里出，主标题/正面都不吃亏。 */
/* ⚠ 2026-09-27：文字列从 `minmax(0, 1fr)` 改成 `minmax(240px, 1fr)` /
   `minmax(260px, 1fr)`（两张表分别）。`minmax(0, …)` 的下界是 **0** ——
   可用宽不够时它真的会归零，整列就成一根竖线（用户这次报的第二件事，
   根因与算式写在下面那段里）。给了真下界之后这一列**挤不扁**，
   窗口再窄走的是横向滚动。 */
body[data-ardot="admin-cards"] .admin-shell-main .ac-thead,
body[data-ardot="admin-cards"] .admin-shell-main .ac-row {
  display: grid;
  align-items: center;
  gap: var(--sp-4);
  padding: 0 var(--sp-4);
  grid-template-columns: var(--sz-icon-lg) minmax(240px, 1fr) 134px 192px 124px;
}
body[data-ardot="admin-articles"] .admin-shell-main .ac-thead,
body[data-ardot="admin-articles"] .admin-shell-main .ac-row {
  display: grid;
  align-items: center;
  gap: var(--sp-4);
  padding: 0 var(--sp-4);
  grid-template-columns: var(--sz-icon-lg) minmax(260px, 1fr) 154px 180px 92px 124px;
}

/* ── 窄桌面：**不许再压文字列，让整张表横向滚** ──────────────────────
 * （用户 2026-09-27 报的：「卡片、文章的列表，题目文字的版式很乱，
 *   会把文字压缩为竖行」。这一屏**第二次**报这句话 —— 上一次的根因是帧 CSS
 *   那条 `.ac-cell` 没限定作用域（见本节开头那段），这一次是另一件事。）
 *
 * 根因：这两张表**除了文字列，其余全是定值**。卡片的固定段
 * 24+134+192+124 = 474，再加四道 gap(64) 与行内边距(32) 一共吃掉 570；
 * 文章的固定段 24+154+180+92+124 = 574，加五道 gap(80) 与内边距(32) 是 686。
 * 而文字列原来写的是 `minmax(0, 1fr)` —— 可用宽掉到那两个数以下时它**归零**；
 * 再加上那条 `overflow-wrap: anywhere`（它是为「用户写的超长英文 / URL
 * 不许撑破卡片」写的），中文就一个词一个字地往下掉，整列成一根竖线。
 *   桌面壳的可用列宽 = 视口 − 220（侧边栏）− 48（内容区左右内边距）：
 *   卡片要 570 ⇒ 视口要 **838**；文章要 686 ⇒ 视口要 **954**。
 *
 * ⚠ 这**不是「手机档」的问题**，别去改帧 CSS：手机壳是**整屏换**、走的是
 *   「一行一张卡」那套排法，根本走不到这里。出问题的是**桌面壳 + 窄窗口**
 *   （鼠标在、窗口窄），而桌面壳是按「主指针是不是鼠标」认的、**不看窗口宽度**
 *   （见 app/device.js 开头那段）—— 所以把浏览器窗口往回拖一拖就中招。
 *
 * 修法三条，一起才叫「不压字」：
 *   ① 文字列给**真下界**（上面两条规则里的 minmax）—— 有下界就不可能归零；
 *   ② 文字列换 `overflow-wrap: break-word` —— `anywhere` 会把这一列的
 *      **min-content 宽度降成一个字符**（它就是这个语义：允许在任意两字之间断），
 *      于是任何能挤它的布局都能把它挤成竖线；`break-word` 照样断长串不溢出，
 *      但 min-content 仍按「最长的一个词」算 ⇒ 挤到词为止就该横向滚了；
 *   ③ 表格容器 `overflow-x: auto` + 表头与表体**同一份** `min-width` ——
 *      ①② 顶住之后窗口还是不够宽，就让**整张表**横向滚（鼠标在，滚动条能用），
 *      而不是把列挤扁。
 *   下界合计（= 固定段 + 文字列下界 + gap + 行内边距，算式就在①上面那两行）：
 *   卡片 **810**、文章 **946**。
 *
 * ⚠ 为什么这一屏能用横向滚动、而 admin-codes 宁可藏一列（见后面 ㊾ 那段）：
 *   那边**手机档与桌面档共用同一份帧 CSS**，滚动条在手机壳里很难用；
 *   这一屏的窄档只可能出现在桌面壳（手机整屏换），跟那边不是同一个局面。
 * ⚠ `min-width` 必须 thead 与 .ac-rows **都写**：它们是 `.ac-table` 的两个
 *   孩子，宽度各算各的。只写一个，另一个还按容器宽排 ⇒ 表头与数据列错位，
 *   而 `overflow-x` 会把这点错位藏进滚动区，屏幕上只说「没对齐」，
 *   查起来要一格一格量。 */
body[data-ardot="admin-cards"] .admin-shell-main .ac-table,
body[data-ardot="admin-articles"] .admin-shell-main .ac-table {
  overflow-x: auto;
}
body[data-ardot="admin-cards"] .admin-shell-main .ac-thead,
body[data-ardot="admin-cards"] .admin-shell-main .ac-rows {
  min-width: 810px;
}
body[data-ardot="admin-articles"] .admin-shell-main .ac-thead,
body[data-ardot="admin-articles"] .admin-shell-main .ac-rows {
  min-width: 946px;
}
/* 文字列那两格（卡片「正面」/ 文章「主标题」）：这两格是**标题**，
   版式上最不能容忍一个字一行。②那一条就落在这里（其余格保持帧 CSS 的
   `anywhere` —— 背面/章节也可能塞长串，它们有定宽兜着，不需要这一条）。 */
body[data-ardot="admin-cards"] .admin-shell-main .ac-cell--front,
body[data-ardot="admin-articles"] .admin-shell-main .ac-cell--front {
  overflow-wrap: break-word;
}
/* ⚠ 手机那份 CSS 给每格写了 `grid-column`（勾选框与标题同在第一行、其余跨满两列）。
   电脑这一边是**一整张网格**，自动放置才是对的 —— 所以在这儿把它们全部复位。
   不写这一条的后果：桌面第 3 列起会整体错位（`--ops` 那条 `1 / -1` 会把操作格
   拉成跨满整行），而且**不报错**。 */
body[data-ardot="admin-cards"] .admin-shell-main .ac-row .ac-cell,
body[data-ardot="admin-articles"] .admin-shell-main .ac-row .ac-cell {
  grid-column: auto;
  grid-row: auto;
}
body[data-ardot="admin-cards"] .admin-shell-main .ac-thead,
body[data-ardot="admin-articles"] .admin-shell-main .ac-thead {
  height: 44px;
  /* ★ 2026-09-26 体检：表头加一层灰底。表头那条线原来只有 **1.22:1**（几乎看不见），
     而令牌里没有一条能到 3:1 的浅线（唯一 ≥3 的是 --cl-border-input，那是输入框描边，
     拿来画表格网格会变成「表格线特别黑」的老后台）。所以「表格有结构」这件事
     改用**一层底色**表达 —— 三条实测写在 ㊴ 开头。 */
  background: var(--cl-bg-sunken);
  border-bottom: 1px solid var(--cl-border-base);
}
body[data-ardot="admin-cards"] .admin-shell-main .ac-th,
body[data-ardot="admin-articles"] .admin-shell-main .ac-th {
  color: var(--cl-text-secondary);
  font-size: var(--fs-caption);
  font-weight: var(--fw-semibold);
}
body[data-ardot="admin-cards"] .admin-shell-main .ac-rows,
body[data-ardot="admin-articles"] .admin-shell-main .ac-rows {
  gap: 0;
}
/* 行：从「一张卡」退回「表格里的一行」—— 底色/圆角/内边距/外阴影全部撤掉，
   只留一道淡分隔线。行高给 min-height 而不是固定高：**极值档那一行要换行长高**
   （设计稿 24:1524 画的就是超长正反面把行撑高，不截断、不省略号）。 */
body[data-ardot="admin-cards"] .admin-shell-main .ac-row,
body[data-ardot="admin-articles"] .admin-shell-main .ac-row {
  /* 行高 52 → 48（2026-09-26 体检）：一屏从 13 行到 15 行。后台是拿来扫的，
     密度就是效率；48 在 --sz-tap-min（44）之上留一点余量，桌面鼠标点得准。 */
  min-height: 48px;
  padding: var(--sp-2) var(--sp-4);
  background: transparent;
  border-radius: 0;
  box-shadow: none;
  /* 分隔线 border-light(#F1F1F6) → border-base(#E8E8EF)：前者对白底只有 **1.13:1**，
     肉眼基本看不见（实测）。换完仍到不了 3:1，所以真正的结构靠**表头底色 + 行悬停**。 */
  border-bottom: 1px solid var(--cl-border-base);
}
body[data-ardot="admin-cards"] .admin-shell-main .ac-row:hover,
body[data-ardot="admin-articles"] .admin-shell-main .ac-row:hover {
  /* 悬停用**品牌浅紫**（--cl-primary-50）而不是灰（--cl-bg-sunken）：
     灰底在这个系统里是「不可用」的语义，而浅紫读起来就是「你指的这一行」，
     对比度也够（那格字对它是 15.4:1）。 */
  background: var(--cl-primary-50);
}
/* 选中行（批量管理那一轮会用）：只多出「一层品牌浅底 + 左侧 3px 实线」，
   颜色不另发明 —— 多一种色就多一个要维护的决定。 */
body[data-ardot="admin-cards"] .admin-shell-main .ac-row.is-selected,
body[data-ardot="admin-articles"] .admin-shell-main .ac-row.is-selected {
  background: var(--cl-primary-50);
  box-shadow: inset 3px 0 0 var(--cl-primary-700);
}
/* 列名已经在表头行里了，格子上那几条 `::before` 全部关掉（手机那档才用得上）。 */
body[data-ardot="admin-cards"] .admin-shell-main .ac-cell::before,
body[data-ardot="admin-articles"] .admin-shell-main .ac-cell::before {
  content: none;
}
/* ⚠ 空值那一格在电脑上**必须留着**（`display:block`），不能让它让位 ——
   它是网格里的一列，让位之后右边的列会整体左移一格，表头与行就错位了
   （手机那档相反：那时每行是一张卡，空格子留着才是白占地方）。
   ⚠⚠ 必须带 **`!important`**：空值是控制器写的（`view.js` 的 `setTextOrHide`），
   它把 `node.style.display = "none"` 写成**行内样式**，
   行内样式比任何选择器都硬。只写 `display: block` 这条规则是**隐形的** ——
   看代码像写对了，屏幕上照样错位、且不报任何错。（这一条 2026-10-02 补上：
   给 IP 黑名单做桌面档时同一条判据当场失效，回过头发现这里也一直在失效 ——
   「卡片背面为空 / 文章时长为空」的那几行，列一直是错的。） */
body[data-ardot="admin-cards"] .admin-shell-main .ac-row .ac-cell:empty,
body[data-ardot="admin-articles"] .admin-shell-main .ac-row .ac-cell:empty {
  display: block !important;
}
/* 操作格那一排文字按钮：`placeActions` 会给它挂 `.admin-ops`（㉑ 那三条）。
   电脑上一行放得下，不用换行 —— 但**不要**去关 `.admin-ops-line` 的 flex-wrap：
   它是通用规矩，留着的代价为零。 */

/* ── 批量条（电脑）：文案在左、按钮在右一行排开 ──────────────────
 * 手机那份是竖排（文案一行、按钮换行），因为 393 上一行放不下四枚。
 * ⚠ 「全选本页」这一枚在电脑上**藏起来**：表头上已经有一枚勾了，同一屏两枚
 *   干同一件事的控件只会让人犹豫点哪个。手机上藏不得 —— 表头整个是藏掉的，
 *   藏了就没有全选入口了（那正是它在帧里的存在理由，见帧文件头 ⑦）。 */
body[data-ardot="admin-cards"] .admin-shell-main .ac-bulk,
body[data-ardot="admin-articles"] .admin-shell-main .ac-bulk {
  flex-direction: row;
  align-items: center;
  gap: var(--sp-3);
}
body[data-ardot="admin-cards"] .admin-shell-main .ac-bulk-count,
body[data-ardot="admin-articles"] .admin-shell-main .ac-bulk-count {
  flex: 1 1 0;
  min-width: 0;
}
body[data-ardot="admin-cards"] .admin-shell-main .ac-bulk-all,
body[data-ardot="admin-articles"] .admin-shell-main .ac-bulk-all {
  display: none;
}
body[data-ardot="admin-cards"] .admin-shell-main .ac-bulk-ops,
body[data-ardot="admin-articles"] .admin-shell-main .ac-bulk-ops {
  flex: 0 0 auto;
  flex-wrap: nowrap;
}

/* ══ 电脑（1440）：编辑文章 ────────────────────────────────────── */
body[data-ardot="admin-article-edit"] .admin-shell-main .ac-body {
  padding: var(--sp-6);
  gap: var(--sp-4);
}
body[data-ardot="admin-article-edit"] .admin-shell-main .ac-toolbar {
  flex-direction: row;
  align-items: center;
  gap: var(--sp-3);
}
/* 工具行里那枚小标题占掉剩余宽度，把四枚按钮顶到右边（设计稿 25:54 是
   `fill_container` + 两枚 hug，同一个排法）。 */
body[data-ardot="admin-article-edit"] .admin-shell-main .ae-sub { flex: 1 1 0; }
body[data-ardot="admin-article-edit"] .admin-shell-main .ac-tools {
  flex: 0 0 auto;
  flex-wrap: nowrap;
}
/* 标题 / 章节：电脑上一横排（手机那档是竖排） */
body[data-ardot="admin-article-edit"] .admin-shell-main .ae-meta {
  flex-direction: row;
  gap: var(--sp-4);
}
body[data-ardot="admin-article-edit"] .admin-shell-main .ae-field { flex: 1 1 0; }
/* 双栏：横排 + **撑满剩下的高度**，每栏自己滚。
   设计稿的两栏都是 `fill_container`（正文区固定 706 高），所以这一条不是偏好：
   不撑满的话右栏预览会随正文长短把整屏拉长，而左边源码框固定 220 高 ——
   两边高度不一致，看不出「同一份内容」。
   ⚠ `flex-shrink: 0` 那条（`[data-scroll="1"] > *`，app.css 上面那一段）会被
     这一条压掉：这里的特异性更高，正是为了压它。 */
body[data-ardot="admin-article-edit"] .admin-shell-main .ae-cols {
  flex: 1 1 0;
  min-height: 280px;
  flex-direction: row;
  align-items: stretch;
  gap: var(--sp-4);
}
body[data-ardot="admin-article-edit"] .admin-shell-main .ae-col {
  flex: 1 1 0;
  overflow: auto;
}
body[data-ardot="admin-article-edit"] .admin-shell-main .ae-src {
  flex: 1 1 0;
  min-height: 200px;
}

/* ══════════════════════════════════════════════════════════════════
 * ㊵ 激活码管理（admin-codes，2026-09-26 第二十三轮新增）
 *
 * 这一屏**没有设计稿**（后台那十六块画板没导出过它）—— 是照 ㊳ 那两张表格屏
 * 的体例手写的，所以这里那几列宽也没得抄，是**按内容量出来的**（数字见下）。
 * 分工跟 ㊳ 一样：帧自己的 CSS 只管 393，这一节只管电脑。
 *
 * ── 列宽是按什么定的（2026-09-26 第三版，按实测重算过）────────────
 *   选择   --sz-icon-lg（24）      跟 ㊳ 那两张一样，勾选框跟第一列同一行
 *   激活码 132~176                 码是定长串（19 字符 ≈ 128px）——
 *                                  **下界 132 是硬底线**，再低码串就折成两行
 *   所属组织 88~1fr                唯一可伸缩那一列（组织名长短差得最多）。
 *                                  ⚠ 下界**不能是 0**，见下面 ★ 那段
 *   绑定用户 104~164               昵称或账号（11 位账号 ≈ 88px）
 *   状态   88                      「未使用 / 已使用 / 已过期 / 已撤销」，固定
 *   有效期 110~168                 「2026-12-31（还剩 96 天）」——
 *                                  ⚠ **上界不能低于 168**，实测 160 就折成两行
 *   操作   255~264                 ★ **下界 255 是这一节唯一的硬约束**，见下
 *
 * ★ 为什么「所属组织」是 `minmax(88px, 1fr)` 而不是 `minmax(0, 1fr)` ──
 *   1fr 的 min 是 0 ⇒ **任何一档只要别的列把宽度吃满，它就归零**。
 *   实测（`_diag-codes.py`）：1280 上它正好是 0 —— 屏幕上表现为
 *   **「所属组织」那一列整个看不见**，而表头那一格还在、右边几列也没错位，
 *   所以看着不像坏了，像「这些码不属于任何组织」。
 *   根因是这一轮往网格里插了「绑定用户」那列（七列了），余量被吃干净。
 *   修法：给它一个 88 的下界（6 个汉字，够放「2024 国考冲刺」这类短名），
 *   宽度不够时让 grid 去挤**别的 minmax 列**（它们各自有下界兜着），
 *   而不是把这一列压成 0。
 *   ⚠ 88 不能更小：9 个汉字的组织名（「事业单位联考冲刺营」）在 88 下会折成
 *     两行 —— 那是**内容驱动的**折行，可接受（不像按钮那样会把整行撑高一倍）；
 *     但再小就连「2024 国考冲刺」都要折，那就没意义了。
 *
 * ★★ 为什么操作列写 `minmax(255px, 264px)` 而不是 `minmax(140px, 264px)` ──
 *   「255」不是审美，是**量出来的**：五枚文字按钮（复制 42 / 改期限 55 /
 *   撤销 42 / 解绑 42 / 删除 42）合计 223，加四道 8 的间距 = **255**。
 *   给少了会怎样（实测，见 scratch 的 `_diag-rowheight.py`）：
 *   操作列拿到 222（1366）或 190（1280）时，**四枚按钮那一档放得下（205）、
 *   五枚那一档放不下** ⇒ 同一张表里 active 行 65 高、used 行 121 高 ——
 *   参差。用户 2026-09-24 要的正是「按钮统一大小、高度、对齐」。
 *   （`used` 那行只比别的行多一枚「解绑」，却整行高一倍，很显眼。）
 *
 *   ⚠ 试过「让操作列窄一点、按钮统一换两行」，**不成立**：按钮是流式换行的，
 *     列宽落在不同区间时 4 枚和 5 枚占的行数不一样（≤92 → 4 枚占 4 行、
 *     5 枚占 5 行；92~150 → 分别是 2/2 行但 2 枚的码只占 1 行；
 *     150~205 → 4 枚 2 行、5 枚 2 行；205~255 → 4 枚 1 行、5 枚 2 行）。
 *     只有 **≥255** 这一档能让「有几枚按钮」不再影响行数。所以：
 *     宽档给足 255；窄档**宁可藏一列**，也不让这一列掉到 255 以下。
 *
 * ── 三档是怎么切的（按「列的下界 + gap 要塞得进多宽的窗口」算）────────
 *   行内可用列的宽 = 视口 − 220（侧边栏）− 48（内容区内边距）− 32（行内边距）
 *                    − 96（七列六道 gap）
 *                  = 视口 − 396
 *   七列下界 801（24+132+88+104+88+110+255）⇒ 要 ≥801 ⇒ **视口 ≥ 1197**
 *   ⇒ 断点取整成 **1200**（≥1200 这一档所有行都排一行）。
 *   1200 以下：七列下界降到 696（把操作列下界换成 150）⇒ 视口 ≥ 1092；
 *              这一档按钮必然换行，所以靠 `.ak-cell--ops` 的 min-height
 *              把所有行统一撑到两行高（见下面那条 @media 里的说明）。
 *   1100 以下：七列连 696 都塞不下，**把「绑定用户」整列藏起来**走六列。
 * ⚠ 三档的数字是一组，改一个要重算整组（上面每一步算式都留着了）。
 *
 * ── 表格总宽（1440）─────────────────────────────────────────
 * 1440 − 220（侧边栏）− 48（内容区左右内边距）= 1172；再减行自身左右 32 = 1140；
 * 七个格子之间六道 gap × 16 = 96 ⇒ 七列合计 1044。
 * ══════════════════════════════════════════════════════════════════ */

body[data-ardot="admin-codes"] .admin-shell-main .ak-body {
  padding: var(--sp-6);
  gap: var(--sp-4);
}
/* 工具栏：电脑上一横排（手机那档是三层竖排）—— 搜索框吃剩余宽度、
   组织下拉与按钮各自 hug。跟 ㊳ 的 `.ac-toolbar` 同一个排法。 */
body[data-ardot="admin-codes"] .admin-shell-main .ak-toolbar {
  flex-direction: row;
  align-items: center;
  gap: var(--sp-3);
}
body[data-ardot="admin-codes"] .admin-shell-main .ak-search {
  flex: 1 1 0;
  min-width: 0;
}
/* 组织下拉：给一个固定的宽（手机那档是把剩余宽度全占了）。
   ⚠ `max-width:100%` 是帧 CSS 给的上限，这里收窄到 220 是**电脑上**的取值 ——
     不写的话「全部组织」这种短名字也会把药丸撑成一整行。 */
body[data-ardot="admin-codes"] .admin-shell-main .ak-filters {
  flex: 0 0 auto;
  flex-wrap: nowrap;
}
body[data-ardot="admin-codes"] .admin-shell-main .ak-select {
  width: 220px;
}
body[data-ardot="admin-codes"] .admin-shell-main .ak-tools {
  flex: 0 0 auto;
  flex-wrap: nowrap;
}
/* 状态筛那五个 chip：电脑上一行摆得下（五个加起来约 460），不用换行。
   ⚠ 只改 `flex-wrap` 不删 —— 窗口窄了还是要换行，别把它写死成 nowrap。 */
body[data-ardot="admin-codes"] .admin-shell-main .ak-chips {
  flex-wrap: nowrap;
}

/* ── 表头行 + 每一行：同一套网格列（列才对得齐）────────────────
   ⚠ 两处都要写：只写一处表头与行各按各的算，列会错位。
   ⚠ 手机那份给每格写了 `grid-column`（勾选框与激活码同在第一行、其余跨满两列），
     电脑这边是**一整张网格**，自动放置才是对的 —— 所以下面第 ③ 段全部复位。 */
body[data-ardot="admin-codes"] .admin-shell-main .ak-thead,
body[data-ardot="admin-codes"] .admin-shell-main .ak-row {
  display: grid;
  align-items: center;
  gap: var(--sp-4);
  padding: 0 var(--sp-4);
  /* 七列。每一个数的来历见上面那段（**操作列的下界 255 是关键**，
     别再往下调）。 */
  grid-template-columns:
    var(--sz-icon-lg)
    minmax(132px, 176px)
    minmax(88px, 1fr)
    minmax(104px, 164px)
    88px
    minmax(110px, 168px)
    max-content;
}
/* ── 窄桌面（1200 以下）：**给「分栏没挂上」留的兜底** ────────────────
 * ⚠ 这两条 @media 现在几乎不再生效：分栏屏的列宽由 `data-left-w` 那几张表说了算，
 *   而它们的特异性更高（(0,4,1) 对 (0,3,1)）、又排在后面 —— 实测 960~1150 的
 *   窗口里匹配的是 `data-left-w="480"`，不是这里。
 *   留着是因为还有**一种情形**进得来：`mountSplit` 找不到「正文」那个节点时会
 *   返回 null（`admin-split.js:195`，帧改坏了才会发生），这一屏就退回「一张表
 *   铺满内容区」的老样子 —— 那时没有 `data-left-w`，全靠这两条把七列收窄。
 *   ⚠ 所以这一段的列数**必须等于那一档实际显示出来的列数**（这里 7 列全在）。
 * 下界合计 = 24+132+88+104+88+110+110 = 656，六道 gap 96 ⇒ 752；可用宽只有
 * 732（1000 的窗口）时溢出 20px —— 只是边缘擦一下，不会把哪一列挤没。 */
@media (max-width: 1200px) {
  body[data-ardot="admin-codes"] .admin-shell-main .ak-thead,
  body[data-ardot="admin-codes"] .admin-shell-main .ak-row {
    grid-template-columns:
      var(--sz-icon-lg)
      minmax(132px, 168px)
      minmax(88px, 1fr)
      minmax(104px, 150px)
      88px
      minmax(110px, 150px)
      max-content;
  }
}
/* ── 更窄（1100 以下）：**同样只是兜底**，把「绑定用户」整列藏起来 ────
 * ⚠ 藏的是**表头格 + 每一行的数据格**，两处都藏 —— 只藏一处会错位。
 *   表头那一格的类名是 `ak-th--user`（帧里给的），行格是 `ak-cell--user`。
 * ⚠ 藏 ≠ 删：控制器照样往那一格写「谁绑的」，只是这一档不显示。
 *   要看「谁绑的」有两个办法：点激活码那一格开详情（窄屏也点得开），
 *   或者窗口拉宽一点。 */
@media (max-width: 1100px) {
  body[data-ardot="admin-codes"] .admin-shell-main .ak-th--user,
  body[data-ardot="admin-codes"] .admin-shell-main .ak-cell--user {
    display: none;
  }
  body[data-ardot="admin-codes"] .admin-shell-main .ak-thead,
  body[data-ardot="admin-codes"] .admin-shell-main .ak-row {
    grid-template-columns:
      var(--sz-icon-lg)
      minmax(132px, 180px)
      minmax(88px, 1fr)
      88px
      minmax(110px, 150px)
      max-content;
  }
}
/* ① 表头行 */
body[data-ardot="admin-codes"] .admin-shell-main .ak-thead {
  height: 44px;
  /* 表头那层灰底：跟 ㊳ 同一对取值（只靠 1px 的线在亮色主题里看不见，
     见 ㊳ 里那段实测）。 */
  background: var(--cl-bg-sunken);
  border-bottom: 1px solid var(--cl-border-base);
}
body[data-ardot="admin-codes"] .admin-shell-main .ak-th {
  color: var(--cl-text-secondary);
  font-size: var(--fs-caption);
  font-weight: var(--fw-semibold);
}
body[data-ardot="admin-codes"] .admin-shell-main .ak-rows {
  gap: 0;
}
/* ② 行：从「一张卡」退回「表格里的一行」—— 底色/圆角/内边距/外阴影全撤掉，
     只留一道淡分隔线。min-height 而不是固定高：异常档那行可以换行变高。 */
body[data-ardot="admin-codes"] .admin-shell-main .ak-row {
  min-height: 48px;
  padding: var(--sp-2) var(--sp-4);
  background: transparent;
  border-radius: 0;
  box-shadow: none;
  border-bottom: 1px solid var(--cl-border-base);
}
/* 悬停用品牌浅紫（灰底在这个系统里是「不可用」的语义，见 ㊳ 里那段） */
body[data-ardot="admin-codes"] .admin-shell-main .ak-row:hover {
  background: var(--cl-primary-50);
}
/* 选中行：品牌浅底 + 左侧 3px 实线。⚠ 帧 CSS 里那条 `.ak-row.is-selected`
   带一圈 `inset 0 0 0 1px` 的描边（手机上是张卡，要那圈线）——
   电脑上行不该有描边，所以这里**整条 box-shadow 重写**，不只是加一条。 */
body[data-ardot="admin-codes"] .admin-shell-main .ak-row.is-selected {
  background: var(--cl-primary-50);
  box-shadow: inset 3px 0 0 var(--cl-primary-700);
}
/* ③ 复位：手机那份给每格写的 `grid-column` / `grid-row` 在电脑上全部作废 */
body[data-ardot="admin-codes"] .admin-shell-main .ak-row .ak-cell {
  grid-column: auto;
  grid-row: auto;
}
/* 激活码那一格**可点**（点它开详情面板，见 admin-codes.js 的 fillCodeLink）。
   下划线和手型光标是帧 CSS 里 `.ak-cell--code` 给的（两档共用一条），
   这里只补**电脑专属**的那一分反馈：鼠标悬到码上时它变品牌色。
   ⚠ 只改悬停色，不改常态色 —— 它是这一行的主标题，常态必须是正文色。 */
body[data-ardot="admin-codes"] .admin-shell-main .ak-cell--code:hover {
  color: var(--cl-primary-700);
}
/* 列名已经在表头行里了，格子上那几条 `::before` 全部关掉（手机那档才用得上） */
body[data-ardot="admin-codes"] .admin-shell-main .ak-cell::before {
  content: none;
}
/* ⚠ 空值那一格在电脑上**必须留着**（`display:block`），不能让它让位 ——
   它是网格里的一列，让位之后右边的列整体左移一格，表头与行就错位了。
   （手机那档相反：那时每行是一张卡，空格子留着才是白占地方。）
   ⚠ 操作格例外：它里面永远有按钮，从来不是 `:empty`。
   ⚠ 操作格那排文字按钮**不要**去关 `flex-wrap`：电脑上一行放得下（最宽的五枚
     约 255px，列宽给到 264），但窗口窄的时候要靠它换行兜底 —— 关掉就变成
     横向溢出（内容被裁，不报错）。 */
body[data-ardot="admin-codes"] .admin-shell-main .ak-row .ak-cell:empty {
  display: block;
}

/* ── 批量条（电脑）：文案在左、按钮在右一行排开 ──────────────────
 * ⚠ 这一段**只动 flex-direction / align / gap，不写 display** ——
 *   帧 CSS 里那条 `.ak-bulk[hidden] { display: none }` 的特异性是 (0,2,0)，
 *   这里任何一条带 `.admin-shell-main` 的选择器都比它高（(0,3,1)），
 *   一旦顺手写上 `display: flex`，**电脑上那条批量条就永远藏不住了**
 *   （没选中也占着 68px 高，而且不报错）。㊳ 那一段不写 display 正是因为这个。 */
body[data-ardot="admin-codes"] .admin-shell-main .ak-bulk {
  flex-direction: row;
  align-items: center;
  gap: var(--sp-3);
}
body[data-ardot="admin-codes"] .admin-shell-main .ak-bulk-count {
  flex: 1 1 0;
  min-width: 0;
}
/* 「全选本页」在电脑上藏掉：表头上已经有一枚勾了，同一屏两枚干同一件事的控件
   只会让人犹豫点哪个。手机上藏不得 —— 表头整个是藏掉的（跟 ㊳ 同一条理由）。 */
body[data-ardot="admin-codes"] .admin-shell-main .ak-bulk-all {
  display: none;
}
body[data-ardot="admin-codes"] .admin-shell-main .ak-bulk-ops {
  flex: 0 0 auto;
  flex-wrap: nowrap;
}

/* ── 分页行：计数顶左、两枚翻页顶右（设计稿 24:1164 那条的排法）──
   帧 CSS 已经给了 row + count flex:1，电脑上只补一句「按钮靠右对齐同一高度」。 */
body[data-ardot="admin-codes"] .admin-shell-main .ak-foot {
  align-items: center;
  justify-content: space-between;
}

/* ── 分栏里的「宽度档位」：左栏窄到哪一步，就收掉哪一列 ──────────────
 * ⚠ 为什么非要有这一段：上面那两条 `@media (max-width: 1200px / 1100px)`
 *   的判据是**视口宽**，它们假设这张表独占一行。可这一屏进了分栏之后
 *   （`app/admin-split.js`，HIG 的 split view），**窄的是左栏、不是视口** ——
 *   在 1440 的电脑上视口就是 1440（那两条 @media 一条都不生效），
 *   而左栏只有 610，七列的下界 897 根本塞不进去 ⇒ 表格右边被裁、
 *   整块列看不见（实测）。所以这里需要一套**按左栏宽**收敛的规则：
 *   骨架把左栏宽分档、挂在 `.admin-split` 的 `data-left-w` 上
 *   （值 = **这一档适用的最小栏宽**，见 `admin-split.js` 的 DEFAULT_STEPS），
 *   CSS 只认这一个属性 —— 判据只有一处，不会出现两套算法打架。
 * ⚠ 那两条 @media **一条都不能删**：手机上不分栏（骨架返回 null、整条不挂），
 *   那时整张表独占一行、窄的确实是视口，那两条才是对的。
 *
 * ── 档位阈值**是量出来的，不是算出来的**（这一点很重要，别再用算式推）──
 *   第一版按「列下界之和 + gap×(列数−1) + 行左右内边距」推了一组阈值，
 *   实测**全对不上**，而且每次都**固定小 16**（推出来五列要 546，实测要 562）。
 *   那 16 是从哪来的我没查清，而**猜公式只会一路错下去** ⇒ 改成直接扫：
 *     把 `--split-left` 从 240 往上每 2px 设一次，量 `.ak-table`（表头与行的共同
 *     祖先）和 `.ak-row` 各自的 `scrollWidth − clientWidth`，
 *     取**第一个两侧都不溢出**的宽。
 *
 *   ★ 2026-09-30（这一轮把操作列从 255 收成 `max-content`）**重新扫了一遍**，
 *     实测（1440 视口）：
 *       七列 762 / 六列 646 / 五列 520 / 四列 416 / 三列 316（两列在 240 下限就够）
 *     同样比算式（752 / 632 / 506 / 402 / 298）大 10~18 —— 那 16 的老账还在，
 *     所以这一组仍然是**扫出来的**，不是算的。
 *   档名 = 实测值**向上取整到 10、再留余量**。这一轮顺手把档名从「数字」改成
 *   `w7…w2`（**几列**）：原来的名字是栏宽（940/710/580…），列宽一改名字就变成
 *   假话，而且读代码时容易把「档名」当成「当前宽度」。名字只当代号用。
 *     七列 w7=770 / 六列 w6=650 / 五列 w5=530 / 四列 w4=420 / 三列 w3=320 / 两列 w2=0
 *   ⚠ 阈值改了必须**同步 `admin-codes.js` 的 `steps`**（两边是同一组数），
 *     改完再跑 `tools/build/probe-admin-split.py` —— 它会从 240 下限一档一档
 *     往宽走、逐档量「档位对不对 / 列数对不对 / 有没有被裁 / 表头与行的可见列数一致」。
 *     这条探针就是这次能发现「算式算错」的原因，别绕过它。
 *   ⚠ 收列的顺序是**按「这条信息在右栏详情里还看得到吗」**定的：
 *     先收「绑定用户」和「有效期」（右栏详情里两样都有），再收「所属组织」，
 *     最后连「状态」也收掉 —— 那时一行只剩勾选框 + 激活码，是「极窄栏」下的
 *     最后一档（正常拖不到；`admin-split.js` 的下界是 240）。
 *
 * ── 为什么宁可收列，也不让表格横向滚动 ─────────────────────────
 *   表头行 `.ak-thead` 与数据行 `.ak-row` 是**两个各自独立的网格**（帧的结构），
 *   两者没有共同的滚动容器；横滚只能落在外层 `.ak-table` 上，而给 `.ak-table`
 *   加 `overflow-x` 会连带把 `overflow-y` 的计算值也变成 `auto`
 *   （CSS 规定：一根轴不是 visible，另一根轴写的 visible 会算成 auto）
 *   ⇒ 这一块会凭空多出一条纵向滚动条。所以宁可收列 —— 收掉的列在右栏都还看得见。
 *
 * ⚠ 隐藏的两条纪律：
 *   ① **表头格与行格必须成对藏**（只藏一半，后面的格子整体串位）；
 *   ② 表头那排 `ak-th` 里**只有 `--pick` / `--user` 两个有类名，其余表头格没有类**
 *      ⇒ 表头一律用 `[data-name="列·xx"]` 定位，行格用帧给的 `ak-cell--xx`。
 * ⚠ 选择器里 `[data-left-w]:not([data-left-w="…"])` 这两段都省不得：
 *   `[data-left-w]` 保证只有挂了档位的屏（就这一屏）被匹配；
 *   而**帧 CSS 是后加载的**（同特异性它会赢），所以每条都得写到
 *   `.admin-shell-main .admin-split[data-left-w]…` 这么长 —— 少写一段
 *   整条静默失效，宽度照旧（不报错）。
 * ⚠ 这里用的是「**这一列从哪一档起就藏**」的反向写法（`:not`）：
 *   同一列只写一次，窄档自然继承宽档的收敛，不必逐档重复。
 *   `:not()` 里装的是属性选择器（简单选择器），不存在浏览器支持问题。 */

/* 「绑定用户」：只有七列那一档（w7）摆得下 */
body[data-ardot="admin-codes"] .admin-shell-main .admin-split[data-left-w]:not([data-left-w="w7"]) .ak-thead .ak-th[data-name="列·绑定人"],
body[data-ardot="admin-codes"] .admin-shell-main .admin-split[data-left-w]:not([data-left-w="w7"]) .ak-row .ak-cell--user {
  display: none;
}
/* 「有效期」：六列那一档（w6）起就摆不下了 */
body[data-ardot="admin-codes"] .admin-shell-main .admin-split[data-left-w]:not([data-left-w="w7"]):not([data-left-w="w6"]) .ak-thead .ak-th[data-name="列·有效期"],
body[data-ardot="admin-codes"] .admin-shell-main .admin-split[data-left-w]:not([data-left-w="w7"]):not([data-left-w="w6"]) .ak-row .ak-cell--term {
  display: none;
}
/* 「所属组织」：五列那一档（w5）起 */
body[data-ardot="admin-codes"] .admin-shell-main .admin-split[data-left-w]:not([data-left-w="w7"]):not([data-left-w="w6"]):not([data-left-w="w5"]) .ak-thead .ak-th[data-name="列·组织"],
body[data-ardot="admin-codes"] .admin-shell-main .admin-split[data-left-w]:not([data-left-w="w7"]):not([data-left-w="w6"]):not([data-left-w="w5"]) .ak-row .ak-cell--org {
  display: none;
}
/* 「状态」：四列那一档（w4）起 */
body[data-ardot="admin-codes"] .admin-shell-main .admin-split[data-left-w]:not([data-left-w="w7"]):not([data-left-w="w6"]):not([data-left-w="w5"]):not([data-left-w="w4"]) .ak-thead .ak-th[data-name="列·状态"],
body[data-ardot="admin-codes"] .admin-shell-main .admin-split[data-left-w]:not([data-left-w="w7"]):not([data-left-w="w6"]):not([data-left-w="w5"]):not([data-left-w="w4"]) .ak-row .ak-cell--state {
  display: none;
}
/* 「操作」：只剩两列那一档（w2）才收 —— 那一档一行只剩勾选框 + 激活码。 */
body[data-ardot="admin-codes"] .admin-shell-main .admin-split[data-left-w="w2"] .ak-thead .ak-th[data-name="列·操作"],
body[data-ardot="admin-codes"] .admin-shell-main .admin-split[data-left-w="w2"] .ak-row .ak-cell--ops {
  display: none;
}

/* ── 各档的列宽：表头与行两处都写（只写一处，列会错位）────────────
 * 一档写几列 = 那一档**没收掉**的列数，按帧里的列顺序从左往右对应。
 * ⚠ 顺序别对错：模板第 3 列在五列档是「组织」、在四列档是「状态」——
 *   因为 w5 档收掉的是「组织」、w4 档才收「状态」。
 *   **改收列的顺序，就要同步改这里**（这是两张必须对齐的表）。
 *
 * 最后一列（操作）写 `max-content`：里面就两枚东西（「复制」+「更多 ⋯」），
 * 让它自报宽度比写死一个数稳 —— 将来按钮文案变了（「更多」→「···」）
 * 这里不用跟着改，也不会因为少算了 8px 就把按钮挤换行。 */
body[data-ardot="admin-codes"] .admin-shell-main .admin-split[data-left-w="w7"] .ak-thead,
body[data-ardot="admin-codes"] .admin-shell-main .admin-split[data-left-w="w7"] .ak-row {
  grid-template-columns:
    var(--sz-icon-lg) minmax(132px, 176px) minmax(88px, 1fr)
    minmax(104px, 164px) 88px minmax(110px, 168px) max-content;
}
body[data-ardot="admin-codes"] .admin-shell-main .admin-split[data-left-w="w6"] .ak-thead,
body[data-ardot="admin-codes"] .admin-shell-main .admin-split[data-left-w="w6"] .ak-row {
  grid-template-columns:
    var(--sz-icon-lg) minmax(132px, 176px) minmax(88px, 1fr)
    88px minmax(110px, 168px) max-content;
}
body[data-ardot="admin-codes"] .admin-shell-main .admin-split[data-left-w="w5"] .ak-thead,
body[data-ardot="admin-codes"] .admin-shell-main .admin-split[data-left-w="w5"] .ak-row {
  grid-template-columns:
    var(--sz-icon-lg) minmax(132px, 176px) minmax(88px, 1fr)
    88px max-content;
}
body[data-ardot="admin-codes"] .admin-shell-main .admin-split[data-left-w="w4"] .ak-thead,
body[data-ardot="admin-codes"] .admin-shell-main .admin-split[data-left-w="w4"] .ak-row {
  grid-template-columns:
    var(--sz-icon-lg) minmax(132px, 176px) 88px max-content;
}
body[data-ardot="admin-codes"] .admin-shell-main .admin-split[data-left-w="w3"] .ak-thead,
body[data-ardot="admin-codes"] .admin-shell-main .admin-split[data-left-w="w3"] .ak-row {
  grid-template-columns:
    var(--sz-icon-lg) minmax(132px, 176px) max-content;
}
/* 两列那一档：激活码那一列吃掉剩余宽度（那一档没有别的列跟它抢） */
body[data-ardot="admin-codes"] .admin-shell-main .admin-split[data-left-w="w2"] .ak-thead,
body[data-ardot="admin-codes"] .admin-shell-main .admin-split[data-left-w="w2"] .ak-row {
  grid-template-columns:
    var(--sz-icon-lg) minmax(132px, 1fr);
}

/* ── 操作格：两枚排一行，不许换行 ─────────────────────────────────
 * 老样子是五枚文字按钮全摊在这儿，必须给「两行按钮 + 一道间距」的 min-height
 * 兜住参差（那一整段的历史见 `admin-codes.js` 的 `fillOps`）。
 * 收进「更多」菜单之后这一格只剩「复制」+「更多」，**一行放得下是个硬保证** ——
 * 所以 min-height 那条撤了，改成明确禁止换行：真的换行就说明宽度算错了，
 * 那时宁可让它溢出被看见，也不要悄悄多出一行把整张表变得参差。
 * ⚠ 两列那一档没有操作格（上面藏了），管不着。 */
body[data-ardot="admin-codes"] .admin-shell-main .ak-row .ak-cell--ops {
  flex-wrap: nowrap;
}

/* ── 状态那一格：做成小徽标 ───────────────────────────────────────
 * 改之前它是和「激活码」「所属组织」一样的黑字，一行里四个同等重量的词，
 * 扫一眼分不出哪个是这一行的主语。做成药丸之后，**颜色 + 文字**一起说话
 * （HIG 那条「不许只靠颜色区分状态」—— 这里四个状态的汉字本来就不一样，
 * 颜色只是让人更快找到「已使用」那几行）。
 * ⚠ 配色只用设计系统里已有的令牌，不新挑颜色：
 *      active  未使用 → 品牌浅色（待办、可用）
 *      used    已使用 → 绿（已经生效了）
 *      expired 已过期 → 中性灰（自然到期，不是错）
 *      revoked 已撤销 → 红（人为作废，要显眼）
 * ⚠ `justify-self: start` + `width: max-content`：药丸**贴着字**，不铺满整列
 *   （铺满就成了一个色块，反而比原来的黑字更重）。 */
body[data-ardot="admin-codes"] .admin-shell-main .ak-cell--state {
  justify-self: start;
  width: max-content;
  max-width: 100%;
  padding: 2px var(--sp-2);
  border-radius: var(--rd-pill);
  background: var(--cl-bg-sunken);
  color: var(--cl-text-secondary);
  font-size: var(--fs-caption);
}
body[data-ardot="admin-codes"] .admin-shell-main .ak-cell--state[data-st="active"] {
  background: var(--cl-primary-50);
  color: var(--cl-primary-700);
}
body[data-ardot="admin-codes"] .admin-shell-main .ak-cell--state[data-st="used"] {
  background: var(--cl-success-50);
  color: var(--cl-success-600);
}
body[data-ardot="admin-codes"] .admin-shell-main .ak-cell--state[data-st="revoked"] {
  background: var(--cl-danger-50);
  color: var(--cl-danger-700);
}

/* ── 工具栏：一屏只留一个「实心按钮」───────────────────────────────
 * 改之前这一行有两枚**同色的实心紫按钮**并排站着：「生成激活码」（primary，
 * 深紫底白字）和「导出 xlsx」（ghost，浅紫底紫字）。两者尺寸一样、颜色同族，
 * 扫过去分不出哪个是这一屏的主动作 —— HIG 那条「一屏一个突出按钮，主次靠
 * **形态**区分、不是靠大小」说的正是这件事。
 * 所以把次级那枚改成**描边**（透明底 + 1px 边），它就不再是一块色了。
 * ⚠ 只改这一屏工具栏里那一枚，不是把 `.ak-btn--ghost` 整个改掉 ——
 *   批量条里那枚「批量撤销」用的也是 ghost，它旁边是实心红的「批量删除」，
 *   那边**本来就该是一深一浅**。类在帧 CSS 里（生成物，不能手改），
 *   所以在 app.css 里按「工具栏里的 ghost」定位覆盖。
 * ⚠ 特异性：这里是三层（body 属性 + 后台壳 + 工具行 + 类），帧那条只有一层。 */
body[data-ardot="admin-codes"] .admin-shell-main .ak-tools .ak-btn--ghost {
  background: transparent;
  /* 描边用 primary-300 而不是中性灰：它跟主按钮是**同一个色族**的深浅两档，
     一眼看得出「这是那个主按钮的次要版本」，而不是一枚不相干的灰框按钮。 */
  box-shadow: inset 0 0 0 1px var(--cl-primary-300);
}
/* 「清空未使用」：它是**批量不可逆删除**（清掉所有没用过的码），
   摆在「导出 xlsx」右边、长着跟「复制」一样的灰字 —— 危险动作不该长成
   普通动作的样子。给红字（不发红底：那一排里红底会抢走全部注意力，
   跟帧 CSS 给行内「删除」定的是同一条规矩，见 `.ak-btn--text-danger` 那段）。 */
body[data-ardot="admin-codes"] .admin-shell-main .ak-tools .ak-btn[data-role="clear"] .ak-btn-txt {
  color: var(--cl-danger-700);
}

/* ══════════════════════════════════════════════════════════════════
 * ㊴ 后台通用：交互状态（2026-09-26 视觉体检那一轮新增）
 *
 * 这一节里的东西**七个后台页共用**：六个列表页走 admin-list.js 那条脊柱
 * （行由它建、统一挂 `admin-row`），三张手写帧走 `.ac-*` 与 `.ac-btn`。
 *
 * ── 体检量到的三件事（真把鼠标移上去量 computedStyle 得到的，不是感觉）──
 *  ① **行悬停零反馈**：六个列表页的行（+.n-9-124 / .n-9-219 那些）
 *     默认 bg=白色，悬停还是 bg=白色；后台排文字按钮（`.gaozi-act`）
 *     默认/悬停都是透明底 —— 指上去什么都不变。
 *  ② **一条 focus-visible 都没有**（全仓 0 处）：键盘 Tab 到哪儿看不见。
 *  ③ **按钮没有按下态**：`.ac-btn`（新建/导入/模板）默认与悬停同一个颜色。
 *
 * ── 取色与取值（都能算出来）────────────────────────────────────
 *   · 悬停底 = `--cl-primary-50`（#F4F2FF）：它是**品牌浅色**，语义是「你指的这一行」；
 *     灰色 `--cl-bg-sunken` 在这个系统里已经是「不可用/凹陷」的意思，拿它做悬停
 *     会被读成「这行不能点」。格子里那格字对它是 15.4:1，次要字 6.3:1，都过 AA。
 *   · 按下 = 深一档 `--cl-primary-100`（#EBE7FF）+ scale .98（只缩一点点，
 *     4px 以上的位移会让人以为整行在跳）。
 *   · 焦点环 = `--cl-primary-700` 2px，`outline-offset:-2px`（**内描边**：
 *     外描边会把相邻元素挤动一格，表格里尤其明显）。
 *   · 动效统一 120ms `cubic-bezier(.2,0,0,1)`（跟 app.css 里抽屉那几条同一个曲线；
 *     令牌表里**还没有**动效令牌，这是设计系统的第二个缺口，记在 21 号文档）。
 * ══════════════════════════════════════════════════════════════════ */

/* ① 后台那排文字动作按钮（编辑 / 删除 / 查看 / 上传音频…）。
      以前是「裸文字 + 透明底」，看着不像能点。 */
.gaozi-act {
  border-radius: var(--rd-sm);
  transition: background-color 120ms cubic-bezier(.2, 0, 0, 1),
              color 120ms cubic-bezier(.2, 0, 0, 1);
}
.gaozi-act:hover { background: var(--cl-primary-50); }
/* ⚠ 2026-09-29（第七报）：底色也改挂记号，不再用 `:active`。
   触摸那一路 `:active` 抬手之后才亮，表现是「按着没动静、松手才闪一下」——
   跟几何是同一个病，只是它改的是颜色，前面的兜底规则管不着。
   下面 `.gaozi-act--danger` / `.admin-row` / `.ac-btn--primary|ghost` / `.ae-mdt`
   同样改过来了。
   ★ 第八报：当时**没量到记号、先留在 `:active`** 的那几条（`.org-*`、
   `.ac-btn--text|danger`、`.dialog-gaozi` 里那枚借来的危险键、`.set-row[role=button]`、
   `.rl [data-name="文章卡"]`）**这一轮全量到了，都换完了** —— 量具是
   `tools/build/probe-press-bg.py`（11 个屏态、逐元素看有没有记号，带反证组）。
   还剩**一条不换**：`.admin-split-detail [data-name="Component/Button/Danger/Default"]`
   —— 三条桌面分栏屏 × 5 种交互都构造不出那个元素，理由写在它自己那一段。 */
.gaozi-act[data-press="down"] { background: var(--cl-primary-100); }
/* 危险动作（删除）悬停走红：它跟「编辑」在视觉上必须分得开，
   不然扫一排按钮时最容易被误点的那一枚（删除）和它的邻居长一样。 */
.gaozi-act--danger:hover { background: var(--cl-danger-50); }
.gaozi-act--danger[data-press="down"] { background: var(--cl-danger-50); }

/* ② 六个列表页的行（脊柱挂的 `admin-row`）。
      只动 background —— 那些行是稿子画的卡片，自带投影与圆角，
      这里再覆盖 box-shadow 就会把稿子的卡片感抹掉（那是改版式，不是加反馈）。 */
.admin-row {
  transition: background-color 120ms cubic-bezier(.2, 0, 0, 1);
}
.admin-row:hover { background: var(--cl-primary-50); }
/* ⚠ 挂记号而不是 `:active`（第七报）。这件事在这一排行上**格外要紧**：
   实测 25 行里只有 10 行自己挂了点击监听（题库列表、组织列表那种整行可点的），
   另外 15 行（用户 / 公告 / 资源）**行本身点不动**，动作在行内那枚按钮上 ——
   而 `:active` 会沿着祖先链往上点亮，于是真机上「点行内那枚按钮」会连整行一起染一遍色，
   还是抬手之后才染。挂记号正好把这两种行分开：点得动的才有反馈。 */
.admin-row[data-press="down"] { background: var(--cl-primary-100); }

/* ③ 手写帧那三屏的按钮（新建 / 导入 / 下载模板 / 组设置 / 保存 / 取消）。
      默认色由帧的 CSS 给（那里只管 393 的排法），**状态**放这里 ——
      状态跟屏幕宽度无关，两边都要有。 */
.ac-btn {
  transition: background-color 120ms cubic-bezier(.2, 0, 0, 1),
              color 120ms cubic-bezier(.2, 0, 0, 1);
}
/* ⚠ 三档按钮的悬停/按下**用令牌换底色，不用 `filter: brightness()`**。
   两个理由：
     ① 令牌换色是**可量的**（探针能量到 backgroundColor 变了），而 filter 改的是
        渲染结果、backgroundColor 一动不动 —— 「改了但量不出来」就等于没有尺子；
     ② brightness 的观感随底色深浅飘（浅底几乎看不出来、深底会发灰），
        而令牌阶梯（700 → 800 → 900）在任何底色上都是同一个「深一档」。
   深还是浅：**按下更深**（700 → 800 → 900），这是「按进去」的方向；
   悬停一级、按下两级，落差一眼能分辨，也不会跳。 */
.ac-btn--primary:hover { background: var(--cl-primary-800); }
.ac-btn--primary[data-press="down"] { background: var(--cl-primary-900); }
.ac-btn--ghost:hover { background: var(--cl-primary-100); }
.ac-btn--ghost[data-press="down"] { background: var(--cl-primary-200); }
.ac-btn--text:hover { background: var(--cl-bg-sunken); }
.ac-btn--text[data-press="down"] { background: var(--cl-border-base); }
/* 危险档（批量条那枚「删除」）：同一套取值法，只是换成 danger 那两条令牌。
   ⚠ 方向跟主按钮**相反**（700 → 600 在亮色是变浅、在暗色是变深）——
     这不是写反了：danger 的阶梯在两个主题里就是反着排的（见 tokens.css），
     而这一对跟 ㊴ 里 `.dialog-gaozi` 那条危险键**完全一致**：同一个应用里
     两枚红按钮按下时变成两种颜色，比「方向对不对」更值得避免。 */
.ac-btn--danger:hover { background: var(--cl-danger-600); }
.ac-btn--danger[data-press="down"] { background: var(--cl-danger-600); }
/* 借来的那枚危险键（清空进度那张确认框用的 9-33 零件）也要有悬停态，
   否则同一屏里主按钮会动、它不动。danger-600 是 danger-700 的浅一档。 */
.dialog-gaozi [data-name="Component/Button/Danger/Default"]:hover { background: var(--cl-danger-600); }
/* ⚠ 2026-09-29（第八报）：这一条**原来是空规则** —— 它写 `danger-700`，
   而稿子给这枚键的**静息色本来就是 danger-700**（9-33 那个零件），
   于是按下去颜色一模一样、**一点反馈都没有**（量到的：192,39,45 → 192,39,45）。
   这不是换机制换出来的，是原来就有的；是这一轮真按了一下才看见的。
   改成 `danger-600`：它就**跟上面 `.ac-btn--danger` 的按下色一致**了 ——
   那句「同一个应用里两枚红按钮按下时不能变成两种颜色」本来就是写在这里的意图，
   只是先前这对取值没对上。（方向是变浅，理由见上面 danger 那一段的两条。） */
.dialog-gaozi [data-name="Component/Button/Danger/Default"][data-press="down"] { background: var(--cl-danger-600); }
/* 用 `aria-disabled` 标出来的那几枚（翻页到两端、新建时的音频键）：
   指上去不给任何反馈，鼠标也不给手型 —— 可点与不可点必须一眼分得开。 */
.ac-btn[aria-disabled="true"] { pointer-events: none; }

/* ★ 2026-10-07：上面那条只管「点不动」，**没有外貌** ——
   稿子 `3-8750`（22 · 组件状态规范）给四档按钮各画了 `disabled` 那一枚，
   「看不见的禁用」不是禁用：按钮照旧鲜亮，用户会以为是自己点歪了，
   连点几下之后去别处找原因（"'这个按钮是不是坏了'"比「这一步不许做」难查得多）。

   ⚠ 为什么不写 `:hover` 的反面而是整体调暗：**正因为上面那条挡了 pointer-events**，
   写 hover 是永远不会命中的死规则（写了等于没写，还让人以为它有用）。
   所以这里给的是**静息就看得出来**的样子。
   ⚠ `opacity` 改的是「整枚连同字一起退到背景里」，跟「只换底色」的区别是：
     底色换暗会让品牌色按钮变得像次按钮 —— 那是**另一种语义**，不是禁用。 */
.ac-btn[aria-disabled="true"] { opacity: .45; }
/* ★ 加载态（稿子 `3-8908`「按钮加载」那一枚：文案改「生成中…」+ 一枚转圈 + 整块禁用）。
   为什么要这一条：一次操作要跑几秒时，按钮没有任何动静 ⇒ 用户不知道点到了没有，
   于是再点一次（后端 `admin-question-desk.js` 那条 `busy` 就是为了挡这个，
   但**挡得住是兜底，看得见才是不让用户陷入那个情形**）。 */
.ac-btn[data-busy="1"] { position: relative; pointer-events: none; opacity: .75; }
.ac-btn[data-busy="1"]::after {
  content: "";
  position: absolute;
  top: 50%; left: 10px;
  width: 12px; height: 12px;
  margin-top: -6px;
  border: 2px solid currentColor;
  border-top-color: transparent;
  border-radius: 50%;
  animation: xq-spin .7s linear infinite;
}
@keyframes xq-spin { to { transform: rotate(360deg); } }
/* ⚠ 用户开了「减弱动效」也要能看出在加载：转圈停住，
   靠 opacity 那 0.75 和上面的 `pointer-events:none` 仍然成立。 */
@media (prefers-reduced-motion: reduce) {
  .ac-btn[data-busy="1"]::after { animation: none; opacity: .7; }
}

/* ④ 焦点环：一条统管的规则，别处不用再各写一份。
      `:focus-visible` 只在**键盘**操作时出现（鼠标点不出现），这是要的：
      鼠标点一下弹一圈环会让整页看起来很吵。
   ⚠ 2026-09-30（㊻）：`.admin-tap` 是**借来的帧按钮**统一挂的那个类（见 `wireTap`）。
     加进这一份、不另写一条 —— 就是这段注释第一句的意思。加之前那些借来的按钮
     （对话框的提交/取消/关闭、选项行的删除、判断题那排分段）键盘 Tab 过去
     **一点可见的动静都没有**。 */
.admin-row:focus-visible,
.admin-crumb-item:focus-visible,
.gaozi-act:focus-visible,
.ac-btn:focus-visible,
.ac-row:focus-visible,
.ac-pick:focus-visible,
.ac-menu-item:focus-visible,
.admin-tap:focus-visible,
.admin-shell-side a:focus-visible {
  outline: 2px solid var(--cl-primary-700);
  outline-offset: -2px;
}

/* ⑤ 输入类控件：聚焦时给一圈品牌描边（原来只有一层 --cl-border-input 的灰描边，
   聚焦与不聚焦长得一样）。 */
.ac-search:focus-within { box-shadow: inset 0 0 0 2px var(--cl-primary-700); }
.ae-input:focus,
.ae-src:focus { box-shadow: inset 0 0 0 2px var(--cl-primary-700); }

/* ── 章节管理面板里那棵树（控制器造的节点，所以落在全站这份样式表里）─────
 * 2026-09-27 面板重做时**整节改过**：上一版是「选章节 + 做什么 + 名称 + 执行」
 * 那张表单，用户原话「章节管理组件你写的啥呀？？？你看看那是给人用的吗？」。
 * 现在是一棵**每一行带自己操作**的树：
 *
 *   [+ 新增顶层章节]
 *   第一章 言语理解        12 题
 *     [加子章节][改名][上移][下移][删除]
 *
 * ⚠ 操作**另起一行**、不挤在名字右边：五枚文字按钮 + 可能很长的中文章节名，
 *   在 393 宽的面板里排一行会折成三截（名字被挤到只剩两个字）。
 *   另起一行之后，「哪一排按钮属于哪一章」靠**缩进 + 紧贴**表达（.chap-ops 紧随
 *   它的 .chap-row），所以 .chap-box 不加 gap，行距由各行自己给。
 * ⚠ 缩进由**行内 style 的 padding-left** 给（控制器按 depth 算，一层 16px）——
 *   不用 margin：行要能整行铺满、悬停时底色是一条整的。
 * ⚠ 二级章节的字色压一档（--cl-text-secondary）：层级已经靠缩进表达了，
 *   再加重字号会跟「主文案」抢；这里只用颜色分主次。 */
.chap-box {
  display: flex;
  flex-direction: column;
  align-self: stretch;
  max-height: 320px;
  overflow-y: auto;
  padding: var(--sp-2) 0;
}
/* 顶部那枚「+ 新增顶层章节」：靠左、不铺满 —— 铺满会跟下面的章节行一样重，
   看不出它是个按钮（它是动作，不是数据）。 */
.chap-add {
  align-self: flex-start;
  margin-bottom: var(--sp-2);
  padding: var(--sp-2) var(--sp-3);
  border-radius: var(--rd-pill);
  background: var(--cl-primary-50);
  color: var(--cl-primary-700);
  font-size: var(--fs-caption);
  font-family: "Noto Sans SC", var(--ff);
  font-weight: var(--fw-medium);
  line-height: var(--lh-caption);
  cursor: pointer;
  user-select: none;
}
.chap-row {
  display: flex;
  flex-direction: row;
  align-items: center;
  gap: var(--sp-2);
  padding-top: var(--sp-2);
}
.chap-name {
  flex: 1 1 0;
  min-width: 0;
  color: var(--cl-text-primary);
  font-size: var(--fs-body);
  font-family: "Noto Sans SC", var(--ff);
  font-weight: var(--fw-medium);
  line-height: var(--lh-body);
  overflow-wrap: anywhere;
}
.chap-name--sub { color: var(--cl-text-secondary); font-weight: var(--fw-regular); }
/* 重名的章节（历史遗留）：名字标红 + 后面一枚「重名」小签。
   章节名就是内容的归属标识，两个同名章节 = 内容分不出是哪一章 ——
   这里只**标出来**，改成什么名字是用户的事（编一个出来更糟）。 */
.chap-name--dup { color: var(--cl-danger-700); }
.chap-tag {
  flex-shrink: 0;
  padding: 0 var(--sp-2);
  border-radius: var(--rd-capsule);
  background: var(--cl-danger-50);
  color: var(--cl-danger-700);
  font-size: var(--fs-micro);
  font-family: "Noto Sans SC", var(--ff);
  line-height: 18px;
}
/* 重名提醒（面板顶部，只在真有重名时出现）。放进 `.chap-box` 里当第一块 ——
   它说的是整棵树的事，不该挂在某一行上。 */
.chap-warn {
  margin-bottom: var(--sp-2);
  padding: var(--sp-2) var(--sp-3);
  border-radius: var(--rd-sm);
  background: var(--cl-bg-sunken);
  color: var(--cl-danger-700);
  font-size: var(--fs-caption);
  font-family: "Noto Sans SC", var(--ff);
  line-height: var(--lh-body);
  overflow-wrap: anywhere;
}

.chap-count {
  flex-shrink: 0;
  padding: 0 var(--sp-2);
  border-radius: var(--rd-capsule);
  background: var(--cl-bg-sunken);
  color: var(--cl-text-secondary);
  font-size: var(--fs-caption);
  font-family: "Noto Sans SC", var(--ff);
  line-height: var(--lh-caption);
}
/* 一行操作 + 它下面那条分隔线。线画在**.chap-ops 的下边**（不是 .chap-row 的下边）——
   这样「名字 + 它的操作」是视觉上的一整块，下一章从线下面开始。 */
.chap-ops {
  display: flex;
  flex-direction: row;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--sp-1);
  padding-top: var(--sp-1);
  padding-bottom: var(--sp-2);
  border-bottom: 1px solid var(--cl-border-light);
}
/* 一枚文字操作。**不用图标**（用户：「删除编辑用文字，不要用图标」）。 */
.chap-op {
  padding: 2px var(--sp-2);
  border-radius: var(--rd-sm);
  background: var(--cl-bg-sunken);
  color: var(--cl-text-secondary);
  font-size: var(--fs-micro);
  font-family: "Noto Sans SC", var(--ff);
  font-weight: var(--fw-medium);
  line-height: 18px;
  cursor: pointer;
  user-select: none;
}
.chap-op--danger { color: var(--cl-danger-700); }
/* 到头/到尾的那两枚：**画成禁用样子、点不动**。比点了弹一句「已经是第一个了」好 ——
   一眼看得出到头了，不用先点一次才知道。 */
.chap-op--off { opacity: .35; cursor: default; }
.chap-op--ok { background: var(--cl-primary-700); color: var(--cl-text-inverse); }
/* 编辑框那一行（新增 / 改名共用）。左边与它对应那一行**同一缩进** ——
   位置本身就是「它要加到哪儿」的说明，不用再写一行字。 */
.chap-edit {
  display: flex;
  flex-direction: row;
  align-items: center;
  gap: var(--sp-1);
  padding-top: var(--sp-1);
  padding-bottom: var(--sp-2);
}
.chap-input {
  flex: 1 1 0;
  min-width: 0;
  height: var(--sz-control);
  padding: 0 var(--sp-2);
  border: 0;
  border-radius: var(--rd-sm);
  background: var(--cl-bg-sunken);
  color: var(--cl-text-primary);
  font-size: var(--fs-body);
  font-family: "Noto Sans SC", var(--ff);
  line-height: var(--lh-body);
}
.chap-input:focus { box-shadow: inset 0 0 0 2px var(--cl-primary-700); }
.chap-none {
  padding: var(--sp-4) 0;
  color: var(--cl-text-tertiary);
  font-size: var(--fs-caption);
  font-family: "Noto Sans SC", var(--ff);
  line-height: var(--lh-body);
  text-align: center;
}

/* ══════════════════════════════════════════════════════════════════
 * ㊶ 后台详情面板里的**四种块**（2026-09-27，用户详情重做那一轮）
 *
 * 起因：用户说用户管理「查看的信息一个个列表不好看，不直观，信息不全面」。
 * 查清楚了，根子是**形状不对**：那一屏把每个事实都做成「标签 + 一个 48 高的
 * 只读框」逐行往下堆，十六七行、近两千像素。而**旧后台本来就是分组的**
 * （`public/web/assets/AdminUsers-CtrlMkPM.js` 那张 detail-modal：
 * 基本信息两列网格 / 组织 / 答题统计四个数字块 / 各题库答题 / 错题分布）。
 *
 * 所以 `admin-dialog.js` 的 `buildField` 补了四种整块类型，这一节管它们怎么排：
 *
 *   .admin-sec-row > .admin-sec      分组小标题（`type: "section"`）
 *   .admin-block   > .admin-grid     两列「标签 / 值」（`type: "grid"`）
 *   .admin-block   > .admin-stats    一排等宽数字块（`type: "stats"`）
 *   .admin-block   > .admin-list     条目行：左主词可裁 / 右数值不裁（`type: "list"`）
 *   另加 `.admin-ro--lines`           只读值里的换行要留住（备注那一格）
 *
 * ── 零件全是稿子的，这里只写「一个容器里必须补的那几条」──────────
 *   · 小标题的字 = 15-111 的「类型提示」（13px，本来是灰色的字段名）+
 *     semibold + 正文色 + **一条 1px 上分隔线**；
 *   · 网格的值 = 8-1045 单行框里那枚「占位」节点（15px，本来就是写字的），
 *     只把颜色从提示色改成正文色 —— 跟 ⑳ 里 `.admin-ro` 那条同源。
 *     **外面那层 48 高的框不要**（要了就是原来那张流水账）；
 *   · 数字块 / 网格的皮 = 稿子输入框那套（`--cl-bg-sunken` + 1px 内描边 + `--rd-md`）；
 *   · 条目行的分隔线 = `--cl-border-base`（跟表格屏 ㊳ 同一对取值）。
 *
 * ── 分隔线为什么值得存在（不是「新造一个控件」）────────────────────
 * 分组小标题和字段名**用的是同一枚节点**（都从「类型提示」来），光靠颜色和字重
 * 区分，一眼扫过去还是容易混。一条 1px 的线不表达任何设计语言，
 * 它只表达「下面这块是另一件事」—— 属于版式的接缝，不属于视觉规范。
 * 而这条接缝在这儿是必须的：这一屏有八节，没有边界就是又一张流水账。
 *
 * ── 桌面档 ──────────────────────────────────────────────────────
 * 后台在电脑上是那个 220px 侧边栏的桌面壳（㉕），弹窗限宽 560（㉙），
 * 所以**选择器一律带 `.admin-shell`**：手机上一个字节都碰不到。
 * 电脑上只是**把网格放到三列**（560 比 353 宽得多，两列会浪费掉一半）。
 * ══════════════════════════════════════════════════════════════════ */

/* ── 分组小标题 ──────────────────────────────────────────────────
 * 上边距 + 上分隔线一起给出「另起一块」的信号；下面只留面板自己的 gap(16)。
 * 第一条（「基本信息」）也带线：它正好把面板标题行和内容分开，一样有用。 */
.admin-sec-row {
  margin-top: var(--sp-2);
  padding-top: var(--sp-4);
  border-top: 1px solid var(--cl-border-base);
}
.admin-sec {
  display: block;
  align-self: stretch;
  color: var(--cl-text-primary);
  font-size: var(--fs-caption);
  font-family: "Noto Sans SC", var(--ff);
  font-weight: var(--fw-semibold);
  line-height: var(--lh-caption);
}

/* ── 两列「标签 / 值」─────────────────────────────────────────────
 * 一格的构成：13px 灰标签 + 15px 正文值，共 ~42 高（含行距）。
 * 原来是「13px 标签 + 48 高框」≈ 69 高 —— 七个事实从七行 ~480 变成四行 ~200。 */
.admin-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: var(--sp-3) var(--sp-4);
  align-self: stretch;
}
.admin-grid__cell {
  display: flex;
  flex-direction: column;
  gap: var(--sp-1);
  /* ★ 这两条是折行的前提：网格项默认 `min-width: auto`（= 内容宽），
     值长一点就把格子撑破、把相邻那格挤走。归零之后才会老实换行。 */
  min-width: 0;
}
.admin-grid__k {
  color: var(--cl-text-secondary);
  font-size: var(--fs-caption);
  line-height: var(--lh-caption);
}
/* 值：占位节点那枚 15px 的字 + 正文色（占位本来是提示色）。
   稿子给它的 `flex: 0 0 auto` 在这儿不管用（这一格不是 flex 子项），
   但 `min-width` 归零照样要写 —— 它在 `.admin-grid__cell` 里是撑满的那一个。 */
.admin-grid__v {
  color: var(--cl-text-primary);
  font-size: var(--fs-body);
  line-height: var(--lh-body);
  min-width: 0;
  overflow-wrap: anywhere;
}

/* ── 一排等宽数字块 ──────────────────────────────────────────────
 * 数字 17px（`--fs-heading`）不是 22px（`--fs-title`）：手机上四格要横着放下
 * 「1204 / 900 / 304 / 75%」，22 号在 353 宽里必然折行 —— 而这一排的价值
 * 就是**一眼扫完**。17px 是稿子里「区块标题」那一档，够重、也不挤。 */
.admin-stats {
  display: grid;
  /* `auto-flow: column` + 等分列：块数由数据决定（现在四处），
     以后加一格也不用改 CSS。 */
  grid-auto-flow: column;
  grid-auto-columns: 1fr;
  gap: var(--sp-2);
  align-self: stretch;
}
.admin-stats__cell {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 2px;
  min-width: 0;
  padding: var(--sp-3) var(--sp-1);
  background: var(--cl-bg-sunken);
  border-radius: var(--rd-md);
  box-shadow: inset 0 0 0 1px var(--cl-border-base);
}
.admin-stats__v {
  color: var(--cl-text-primary);
  font-size: var(--fs-heading);
  font-family: "Noto Sans SC", var(--ff);
  font-weight: var(--fw-semibold);
  line-height: var(--lh-body);
  /* 四位数（1204）在窄屏上不能撑破格子 */
  max-width: 100%;
  overflow-wrap: anywhere;
  text-align: center;
}
.admin-stats__k {
  color: var(--cl-text-secondary);
  font-size: var(--fs-caption);
  font-family: "Noto Sans SC", var(--ff);
  line-height: var(--lh-caption);
  /* 「正确率」三个字在 320 宽的四分之一格里（约 62px）刚好放得下，
     真放不下时让它缩字距而不是溢出。 */
  white-space: nowrap;
}

/* ── 条目行 ──────────────────────────────────────────────────────
 * 一行：左主词（可裁）+ 右数值（不裁）。行与行之间一条 1px 线。
 * ★ 关键是 `min-width: 0` + `text-overflow: ellipsis` **只给左边**：
 *   挤的时候是左边让位。右边装的是「96/128 (75%)」「32 题」「机构码」，
 *   全是打开这一屏要读的东西，裁掉这半行就等于这行没写。 */
.admin-list {
  display: flex;
  flex-direction: column;
  align-self: stretch;
}
.admin-list__row {
  display: flex;
  flex-direction: row;
  align-items: baseline;
  gap: var(--sp-3);
  padding: var(--sp-2) 0;
  min-width: 0;
}
.admin-list__row + .admin-list__row { border-top: 1px solid var(--cl-border-base); }
.admin-list__k {
  flex: 1 1 0;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  color: var(--cl-text-primary);
  font-size: var(--fs-body);
  font-family: "Noto Sans SC", var(--ff);
  font-weight: var(--fw-regular);
  line-height: var(--lh-body);
}
.admin-list__v {
  flex: 0 0 auto;
  color: var(--cl-text-secondary);
  font-size: var(--fs-caption);
  font-family: "Noto Sans SC", var(--ff);
  line-height: var(--lh-caption);
  white-space: nowrap;
}
/* 说明行（「只列了错题最多的 10 个」这种）：降成小字、右侧留空。
   它**不是**一条数据，所以不能长得像一条数据 —— 否则会被当成「某个题库，
   名字叫『只列了…』」。 */
.admin-list__row--note { padding-top: var(--sp-1); padding-bottom: var(--sp-1); }
.admin-list__row--note .admin-list__k {
  flex: 1 1 auto;
  color: var(--cl-text-tertiary);
  font-size: var(--fs-caption);
  line-height: var(--lh-caption);
  white-space: normal;   /* 这句是说明，宁肯折行也要说全 */
}

/* ── 只读值要留住换行 ────────────────────────────────────────────
 * 稿子那枚「占位」节点的 `white-space` 是 normal —— 换行会被压成一个空格。
 * 备注是运营自己打的字，他打了三行就该看到三行（「我打的换行不见了」
 * 是那种用户不会报、但每次都在悄悄错的毛病）。只给带 `.admin-ro--lines` 的那几格，
 * 别的只读值照旧（一格里本来也不会有换行）。 */
.admin-field--readonly .admin-ro--lines { white-space: pre-line; }

/* ── 桌面档（只有 `.admin-shell` 里才有）─────────────────────────
 * 弹窗在电脑上限宽 560（㉙），两列网格每格约 250 宽 —— 空得很。
 * 放到**三列**：七格从四行变三行，一屏能多放一块。
 * ⚠ 数字块**不跟着加宽**：四格横排是它的形状，加到六列格子就太瘦了。 */
.admin-shell .admin-grid { grid-template-columns: repeat(3, 1fr); }
.admin-shell .admin-list__k { font-size: var(--fs-body); }

/* ══════════════════════════════════════════════════════════════════
 * ㊷ Markdown 编辑器（编辑文章那一屏，2026-09-27）
 *
 * 用户这一轮的第三件事：「文章编辑补 md 编辑器，模块要设计优雅美观」。
 * 结构那一半（命令表 / 快捷键 / 撤销栈）在 `app/md-editor.js`，
 * 这一节只管**它长什么样**。
 *
 * ── 为什么这一节**不带 `body[data-ardot=…]` 前缀** ────────────────
 * 跟 ㊳㊵ 那两节相反：那两节管的是「电脑上多出来的那套版式」，所以只对
 * `.admin-shell` 里的生效。这一节是**两档共用**的零件（手机上也要有工具条），
 * 所以写成全局类，桌面档再用一段更具体的规则去压高度。
 * ⚠ 前缀规则写在别的节里（帧的 393 CSS 只管手机），这一节两档都吃得着。
 *
 * ── 视觉是从哪儿来的 ─────────────────────────────────────────────
 * 一枚按钮 = 稿子「输入框」那套皮（`--cl-bg-sunken` + 1px 内描边 + `--rd-md`），
 * 只把高度压到 34、字号取 caption —— 它跟旁边的 `标题`/`章节` 输入框是同一族，
 * 一眼看得出「这是这一屏自己的零件」，不是从别处搬来的。
 * 鼠标悬停才上主色（`--cl-primary-50` 底 + 主色字 + 主色描边），静止时是次要色：
 * 一屏十四个按钮都染成主色的话，正文那一块反而没有重点了。
 * ⚠ 配色一律走令牌（`--cl-primary-*` 在暗色主题下是另一套值，自动跟着变）——
 *   写死色号的话暗色下这几个按钮会变成看不清的浅紫底。
 */

/* ── 左栏头那一行：左边「Markdown 原文」，右边读数 ──────────────── */
.ae-headrow {
  display: flex;
  align-items: center;
  gap: var(--sp-3);
}
.ae-headrow .ae-head { flex: 1 1 auto; min-width: 0; }
.ae-count {
  flex: 0 0 auto;
  color: var(--cl-text-tertiary);
  font-family: "Noto Sans SC", var(--ff);
  font-size: var(--fs-micro);
  line-height: var(--lh-caption);
  /* 数字等宽：每敲一个字读数都在变，不等宽的话整格会跟着左右抖 */
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

/* ── 工具条 ────────────────────────────────────────────────────────
 * 393 宽放不下十四枚（量过：每枚 46~56 + 间距）⇒ **横着滚**。
 * 挤扁是最糟的处理：按钮缩到 30 宽之后「小标题」三个字会折成两行，
 * 整条工具条跟着长高，比滚还难看。
 *
 * ⚠ **2026-10-02 改：上面这条「横着滚」现在只管手机档。** 电脑档在下面
 *   「桌面档」那块改成**换行**了 —— 理由见那一段。
 *   手机上滑一下是能用的手势（而且这一条本来就配了触屏滚动），
 *   所以手机档一行不动。 */
.ae-mdtools {
  display: flex;
  align-items: center;
  gap: var(--sp-1);
  flex: 0 0 auto;
  overflow-x: auto;
  overscroll-behavior-x: contain;
  -webkit-overflow-scrolling: touch;
  /* 滚动条不要（手机上它本来也是「滑一下才现」）；键盘/触控板/触屏都能滚 */
  scrollbar-width: none;
  /* 命中区别贴着下一条边框 —— 差 2px 的手感差别很明显 */
  padding-bottom: 2px;
}
.ae-mdtools::-webkit-scrollbar { display: none; }
/* 命令表被清空时不留一条空白横线（`wireMdEditor` 里也会把它整个摘掉，
   这条是兜底：万一哪次它没摘干净） */
.ae-mdtools:empty { display: none; }

.ae-mdt {
  flex: 0 0 auto;
  min-height: 34px;
  padding: 0 var(--sp-3);
  border: 0;
  outline: 0;
  border-radius: var(--rd-md);
  background: var(--cl-bg-sunken);
  box-shadow: inset 0 0 0 1px var(--cl-border-base);
  color: var(--cl-text-secondary);
  font-family: "Noto Sans SC", var(--ff);
  font-size: var(--fs-caption);
  font-weight: var(--fw-medium);
  line-height: var(--lh-caption);
  cursor: pointer;
  /* 只过渡会变的那三样。`transition: all` 会把按下时的位移也带上，
     手感变成「慢慢沉下去」，不是「嗒一下」。 */
  transition: background-color 120ms ease, color 120ms ease, box-shadow 120ms ease;
}
.ae-mdt:hover {
  background: var(--cl-primary-50);
  box-shadow: inset 0 0 0 1px var(--cl-primary-200);
  color: var(--cl-primary-700);
}
/* 「按下去了」的那一下手感。这些按钮按下去**屏幕上不一定立刻有变化**
   （比如光标没在正文里时），按下态至少说明按钮自己是活的。
   ⚠ 2026-10-09 用户三连「不移动」：位移反馈整个撤掉，按下只变透明度
     （跟 [data-press="down"] 主规则同款），按钮照样「看得见自己是活的」但不动。 */
.ae-mdt[data-press="down"] {
  opacity: .78;
}
.ae-mdt:focus-visible {
  box-shadow: inset 0 0 0 2px var(--cl-primary-700);
}

/* ── 「这一行现在是什么格式」——光标所在行对应的按钮亮着 ──────────
 * 这是「一排按钮」和「一个编辑器」的分界：没有它，用户没法知道
 * 自己现在这行**已经是**列表了 —— 而再按一次「列表」是**取消**，不是叠加。
 * 不知道这一点的人会按两下，然后觉得这个按钮时灵时不灵。
 * ⚠ 只有行命令会亮（加粗/斜体/代码那几枚不亮），理由见 md-editor.js 的 paintActive。
 * ⚠ 亮着的那一枚**鼠标悬停时仍然是 hover 色**，所以下面那条要单独写：
 *   两条特异性相同时后写的赢，不写的话悬停看不出反馈（看着像死了）。 */
.ae-mdt.is-on {
  background: var(--cl-primary-100);
  box-shadow: inset 0 0 0 1px var(--cl-primary-300);
  color: var(--cl-primary-700);
}
.ae-mdt.is-on:hover { background: var(--cl-primary-200); }

/* 分组之间那条竖线（四组命令：标题 / 强调 / 块 / 插入） */
.ae-mdt-sep {
  flex: 0 0 auto;
  width: 1px;
  height: 16px;
  margin: 0 var(--sp-1);
  background: var(--cl-border-base);
}

/* ── 桌面档 ────────────────────────────────────────────────────────
 * 电脑上不需要 34 的触摸高度（那只会在正文区上方多占 6px），28 就够；
 * 鼠标比手指准得多，间距也可以更紧。
 *
 * ★★ 2026-10-02（用户：「其他页面也要…按钮乱放、分栏不清晰」那一轮的第 6 项）：
 *    电脑档从「横着滚」改成「换行」。
 *
 *    病是什么：工具条挂在**左栏**里（正文两栏各一半），能用的宽度就是
 *    左栏宽 - 32 内边距。实测这一条**随屏宽时好时坏**：
 *      1280 屏 → 能用的 466，内容 642 ⇒ 切掉 176
 *      1455 屏 → 能用的 554，内容 642 ⇒ **切掉 88**（用户看到的「链接」只剩半枚、
 *                「分隔线」整枚不见）
 *      1920 屏 → 能用的 786 ⇒ 刚好不切
 *    而它写的是 `overflow-x: auto` + `scrollbar-width: none` —— **触屏能滑，
 *    鼠标不能**（这台机器 Chrome 是悬浮滚动条，不指上去根本不画，横着也没有滚轮）。
 *    ⇒ 在电脑上这几条命令不是「藏在右边」，是**根本够不着**，而且看不出能滚。
 *    当初选「滚」是拿 393 宽估的、且默认触屏；鼠标这一路从来没成立过。
 *
 *    为什么是换行而不是别的：
 *      · 不改两栏比例 —— 那是稿子（ardot）里的几何，改它要动设计稿，代价不成比例；
 *      · 不把胶囊缩到折字 —— 287 行那条已经说过这样最难看；
 *      · 换行**每一枚都还在**，且窄到多少都不会切，是最省事又最稳的那条路。
 *
 *    下面那两行「紧凑」是为了让它**今天这个宽度下仍然是一行**：
 *    内边距 12→8（省 88）、分隔线外边距 4→2（省 12）⇒ 内容 642→542，放得下 554。
 *    这只是省出来的一行，**真正兜底的是 `flex-wrap: wrap`** ——
 *    哪天字体换了、屏再窄些，它退回两行，但绝不会再有够不着的命令。
 *    ⚠ 别把 wrap 去掉只留紧凑：那就又回到「按屏宽赌运气」，1280 屏照样切。 */
body[data-ardot="admin-article-edit"] .admin-shell-main .ae-mdt {
  min-height: 28px;
  padding: 0 8px;
}
body[data-ardot="admin-article-edit"] .admin-shell-main .ae-mdtools {
  flex-wrap: wrap;
  column-gap: 2px;
  row-gap: var(--sp-2);
}
body[data-ardot="admin-article-edit"] .admin-shell-main .ae-mdt-sep { margin: 0 2px; }

/* ══════════════════════════════════════════════════════════════════
 * ㊸ 题库「数据报告」那张只读面板（app/routes/admin-bank-report.js）
 *    2026-09-27 第二十五轮新增
 *
 * 用户原话：「题库组里面用一个正确率排名功能，之后在后台能看到，给有此权限的排名」，
 * 追问后补：「本题库的排名，我希望是一个单独的入口，也可以查看本题库用户易错的题目。
 * 相当于一个数据报告。」
 *
 * ── 为什么这一节在 app.css 里，而不是帧 CSS ────────────────────────
 * 面板的内容（表格行、柱子、四格数字）全是**运行时按数据现造的节点**，
 * 稿子上没有这几格 —— 跟 ㉚ 那一批（组织卡 / 勾选行）同一个道理：
 * 凡是控制器自己建节点的样式，一律写这儿。`web/assets/pages/*.css` 是
 * gen.mjs 从稿子生成的（勿手改），放那儿下次就被覆盖了。
 *
 * ── 三张表为什么用同一个 `--rep-cols` ────────────────────────────
 * 表头行与每一行数据行是**各画各的 grid**（不是一个 grid 里的两行），
 * 列宽模板必须**逐字相同**，不然表头与数据会错位 —— 而「同一份模板抄两遍」
 * 是迟早会抄歪的那种重复。所以模板只写一处（挂在容器那个变量上），
 * 行读它。改列宽只改上面那一行。
 *
 * ── 每条都给了**真下界**（`minmax(Npx, 1fr)` 而不是 `minmax(0, 1fr)`）──
 * 这是 ㊳ 那条教训的延续：`minmax(0,1fr)` 的下界是 0，配上换行断词，
 * 窄屏上表格会把一个数字压成**竖排的一列字**（看着像坏了，其实是「还能再压」）。
 * 加了下界之后窄屏装不下，就由 `.rep-tbl` 自己横向滚 —— 宁可能滚，不许压字。
 * ══════════════════════════════════════════════════════════════════ */

.rep {
  display: flex;
  flex-direction: column;
  gap: var(--sp-4);
  align-self: stretch;
  /* 这一块最长（四格数 + 一根柱子 + 三张表）。面板自己是可滚的（.dialog-gaozi），
     这里只负责把「上方不改动的那一段」和「数据回来才有的那几段」排开。 */
  padding-bottom: var(--sp-2);
}

/* ── 面板头：一句标题 + 一句说明 ─────────────────────────────────
   ⚠ 这两句是**这条数据自己的口径**（共多少条、正确率怎么算的、门槛是多少），
     不是装饰文案 —— 少了它，屏幕上那些数就没有出处。 */
.rep-head { display: flex; flex-direction: column; gap: 2px; align-self: stretch; }
.rep-title {
  color: var(--cl-text-primary);
  font-size: var(--fs-body);
  font-family: "Noto Sans SC", var(--ff);
  font-weight: var(--fw-semibold);
  line-height: var(--lh-body);
}
.rep-sub {
  color: var(--cl-text-secondary);
  font-size: var(--fs-caption);
  font-family: "Noto Sans SC", var(--ff);
  line-height: var(--lh-caption);
  overflow-wrap: break-word;
}

/* ── 四格数字 ────────────────────────────────────────────────────
   用 grid 两列（手机上两行两列）而不是 flex wrap：数字格要**等宽**，
   wrap 出来的宽窄会跟着内容走（「1,234」和「—」两种宽度并排很难看）。 */
.rep-kpis {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: var(--sp-2);
  align-self: stretch;
}
.rep-kpi {
  display: flex;
  flex-direction: column;
  gap: 2px;
  padding: var(--sp-3);
  background: var(--cl-bg-sunken);
  border-radius: var(--rd-md);
}
.rep-kpi-num {
  color: var(--cl-text-primary);
  font-size: var(--fs-heading);
  font-family: "Noto Sans SC", var(--ff);
  font-weight: var(--fw-semibold);
  line-height: 1.2;
  /* 数就是数：一个都不许断行（千分位里有逗号，断了会像两个数）。 */
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.rep-kpi-label {
  color: var(--cl-text-secondary);
  font-size: var(--fs-caption);
  font-family: "Noto Sans SC", var(--ff);
  line-height: var(--lh-caption);
}

/* ── 一个小节：标题 + 内容 ───────────────────────────────────────
   ⚠ 标题那一格是个 flex 行：后面还要往里插一小句说明（`.rep-note`）——
     两者是**同一句话的两半**（「正确率排名（答满 10 题才进榜…）」），
     所以说明**不另起一行**，接在标题后面。 */
.rep-sec {
  display: flex;
  flex-direction: column;
  gap: var(--sp-2);
  align-self: stretch;
  padding-top: var(--sp-3);
  border-top: 1px solid var(--cl-border-light);
}
.rep-sec-title {
  display: flex;
  flex-direction: row;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 4px;
  color: var(--cl-text-primary);
  font-size: var(--fs-body);
  font-family: "Noto Sans SC", var(--ff);
  font-weight: var(--fw-semibold);
  line-height: var(--lh-body);
}
.rep-note {
  color: var(--cl-text-tertiary);
  font-size: var(--fs-caption);
  font-family: "Noto Sans SC", var(--ff);
  font-weight: var(--fw-regular);
  line-height: var(--lh-caption);
  overflow-wrap: break-word;
}
.rep-empty {
  padding: var(--sp-4) 0;
  color: var(--cl-text-tertiary);
  font-size: var(--fs-caption);
  font-family: "Noto Sans SC", var(--ff);
  line-height: var(--lh-body);
  text-align: center;
  overflow-wrap: break-word;
}

/* ── 近 7 天那几根柱子 ──────────────────────────────────────────
   ⚠ 高度是**内联样式**给每一根柱子（控制器按当天数 / 最大值算的）——
     不在这儿写死高度，也不写 transition（这一块是数据，不是动效）。
   ⚠ `align-items: flex-end`：柱子从底线往上长，不是从顶往下。 */
.rep-bars {
  display: flex;
  flex-direction: row;
  align-items: flex-end;
  justify-content: space-between;
  gap: var(--sp-1);
  align-self: stretch;
  min-height: 118px;
}
.rep-bar {
  display: flex;
  flex: 1 1 0;
  min-width: 0;
  flex-direction: column;
  align-items: center;
  justify-content: flex-end;
  gap: 2px;
}
.rep-bar-fill {
  width: 100%;
  max-width: 28px;
  min-height: 3px;          /* 0 也要看得见那一条底线（见 admin-bank-report.js 那段） */
  background: var(--cl-primary-700);
  border-radius: var(--rd-capsule) var(--rd-capsule) 0 0;
}
.rep-bar-date,
.rep-bar-num {
  color: var(--cl-text-secondary);
  font-size: var(--fs-caption);
  font-family: "Noto Sans SC", var(--ff);
  line-height: var(--lh-caption);
  white-space: nowrap;
}
/* 当天那个数比日期重一档：一眼要能读出来「这天多少条」。 */
.rep-bar-num { color: var(--cl-text-primary); font-weight: var(--fw-medium); }

/* ── 三张表 ──────────────────────────────────────────────────────
   ⚠ `overflow-x: auto` 是**必需**的（见文件头那段：宁可横滚，不许压字）。
     滚动条落在 `.rep-tbl` 上，不是落在外层面的板上 —— 面板竖着也要能滚。 */
.rep-tbl {
  align-self: stretch;
  overflow-x: auto;
}
.rep-tr {
  display: grid;
  grid-template-columns: var(--rep-cols);
  gap: var(--sp-3);
  align-items: center;
  padding: var(--sp-2) 0;
  border-bottom: 1px solid var(--cl-border-light);
}
.rep-tr:last-child { border-bottom: 0; }
.rep-tr--head {
  padding-top: 0;
  border-bottom: 1px solid var(--cl-border-base);
}
.rep-th {
  color: var(--cl-text-tertiary);
  font-size: var(--fs-caption);
  font-family: "Noto Sans SC", var(--ff);
  font-weight: var(--fw-medium);
  line-height: var(--lh-caption);
}
.rep-td {
  min-width: 0;
  color: var(--cl-text-primary);
  font-size: var(--fs-caption);
  font-family: "Noto Sans SC", var(--ff);
  font-weight: var(--fw-regular);
  line-height: var(--lh-caption);
  overflow-wrap: break-word;
}
/* 数值列：贴右、不许断（「1,234」断了会读成两个数）。 */
.rep-c--num { text-align: right; white-space: nowrap; }
.rep-th.rep-c--num { text-align: right; }
/* 主行 + 附注（附注 = 同一格里第二行的淡字，比如「答案 A」）。
   ⚠ 附注**不另开一列**：这一屏刚为「列一多、手机上每格压成竖排」改过 ㊳。 */
.rep-td-main { min-width: 0; }
.rep-td-meta {
  margin-top: 1px;
  color: var(--cl-text-tertiary);
  font-size: var(--fs-caption);
  line-height: var(--lh-caption);
  overflow-wrap: break-word;
}
/* 榜次 / 正确率 / 章节名：比正文重一档（这张表要一眼扫出前几名）。 */
.rep-td--rank,
.rep-td--strong { font-weight: var(--fw-semibold); }
.rep-td--rank { color: var(--cl-text-secondary); white-space: nowrap; }
/* 题干那一格：**截到两行**，完整的一份在 title 上（见 admin-bank-report.js）。
   `-webkit-box` 那一套是老写法，但它是唯一在 Android WebView 上真生效的多行截断。 */
.rep-td--stem .rep-td-main {
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

/* 三张表各自的列宽模板（**只写这一处**，行与表头都读它）。
   ⚠ 固定段的和要小到能在 393 宽的稿子上装下（可用宽约 350）：
     rank  32+52+52+64 = 200（余下给账号那格）
     hard  68+84       = 152
     chap  52+46+68    = 166
   下面每个 minmax 的**下界就是「再窄就压字」的那条线**（㊳ 的教训）。 */
.rep-tbl--rank { --rep-cols: 32px minmax(112px, 1fr) 52px 52px 64px; }
.rep-tbl--hard { --rep-cols: minmax(150px, 1fr) 68px 84px; }
.rep-tbl--chap { --rep-cols: minmax(120px, 1fr) 52px 46px 68px; }

/* ── 电脑档 ──────────────────────────────────────────────────────
   面板在电脑上最宽 560（㉙），四格数字排成一行更好读；
   柱子那一段也放开一点高度 —— 但**不加 transition**（这是数据，不是动效）。 */
.admin-shell .rep-kpis { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.admin-shell .rep-bars { min-height: 132px; }

/* ══════════════════════════════════════════════════════════════════
 * ㊹ 数据概览的「概览图表区」（app/routes/admin-dashboard.js）
 *    2026-09-27 第二十五轮新增
 *
 * 用户原话：「数据概览可以用更多图表，换样式」。
 * 五块图：①答题量竖柱 ②全站正确率环形 ③题库类型堆叠占比条 ④组织 Top5 横条
 *        ⑤练习量 Top5 题库横条。
 *
 * ── 为什么这一节在 app.css 里，而不是帧 CSS ────────────────────────
 * 这一块**整个是控制器建的**。为什么不能摆进 9-51.html：那是 `tools/build/gen.mjs`
 * 从 `design/spec/raw/9-51.jsonl` 生成的（文件头写着勿手改），`build.sh` 一跑
 * 整份重写、手加的节点下一次就没了；而帧的「稿子属性契约」那把尺（check.mjs）
 * 是拿稿子**逐节点对账**的，多一个稿子上没有的节点在它那儿是红的。
 * 所以跟 ㉚㊸ 同一条规矩：**控制器自己建节点的样式，一律落这儿**。
 *
 * ── 形状一项都不发明（逐项照着稿子那两张卡）────────────────────────
 *   .dash-card        ← 9-51.css 第 41 行 .n-9-59（图表卡）
 *                        bg-card · rd-md · padding sp-5 · gap sp-3
 *   .dash-card-title  ← 第 42 行 .n-9-68   text-primary · fs-heading · semibold · 25px
 *   .dash-bars        ← 第 43 行 .n-9-69   bg-sunken · rd-sm · padding sp-3
 *   .dash-bar-fill    ← 第 44 行 .n-9-92   primary-600 · 圆角 4px（今天那根 primary-700）
 *   .dash-card-head   ← 第 55 行 .n-9-70（卡头）那套：一行、居中、gap
 * 变的只有「图怎么画」。改这一节之前先回去看那几条 —— 别让同一件事在两处
 * 长得不一样（同一张屏上并排的两张卡，圆角差 2px 是最刺眼的那种「好像哪里不对」）。
 *
 * ── `[hidden]` 那一行是必需的，不是啰嗦 ───────────────────────────
 * `.dash-charts` 自己声明了 `display: grid`，作者样式会**压过**浏览器给 `[hidden]`
 * 的那条 `display: none` —— 于是 `setHidden(box, true)` 在屏幕上毫无效果
 * （五块全没数据时留下一个空容器，还占着内容区一整行）。帧 CSS 那两份的
 * `.ac-chap[hidden]` / `.ac-chap-box[hidden]` 是同一个坑的另外两次。
 *
 * ── 色只从既有令牌里取 ────────────────────────────────────────────
 * 三档类型用的 a/b/c 就是 primary-700 / accent-700 / success-700 —— 没有新色值。
 * `dash-tone--d` 是**兜底档**（后端哪天给个我们不认识的 type），用的是
 * text-disabled，一个中性灰：那一段照画、不消失。理由写在 admin-dashboard.js
 * 的 TONE_FALLBACK 那段（少画一档会让别的档宽度凭空变大，图上还看不出来）。
 * ══════════════════════════════════════════════════════════════════ */

.dash-charts {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: var(--sp-4);
  align-self: stretch;
  min-width: 0;
}
/* 见文件头：作者样式压过 UA 的 [hidden]，必须显式补一条。 */
.dash-charts[hidden] { display: none; }

.dash-card {
  display: flex;
  flex-direction: column;
  gap: var(--sp-3);
  padding: var(--sp-5);
  align-self: stretch;
  min-width: 0;
  background: var(--cl-bg-card);
  border-radius: var(--rd-md);
  /* ⚠ 这里**不写** `overflow: hidden`（稿子那两张卡写了）：view.js 的 adaptCanvas
     是按「flex:1 + overflow:hidden」认「可滚区」并打 `data-scroll` 的，而这一块
     是控制器在 adaptCanvas 之后才建的。宁可不要这一条，也不想哪天它被误标成
     可滚区（那会连带吃到 app.css 规则⑧的 `flex-shrink: 0`）。 */
}

.dash-card-head {
  display: flex;
  flex-direction: row;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 4px;
  min-width: 0;
}
.dash-card-title {
  color: var(--cl-text-primary);
  font-size: var(--fs-heading);
  font-family: "Noto Sans SC", var(--ff);
  font-weight: var(--fw-semibold);
  line-height: 25px;
}
/* 口径那半句跟标题是**一行里的两半**（跟 ㊸ 的 .rep-note 同一个理由）：
   另起一行会让标题看起来像有两个。 */
.dash-card-note {
  color: var(--cl-text-tertiary);
  font-size: var(--fs-caption);
  font-family: "Noto Sans SC", var(--ff);
  line-height: var(--lh-caption);
  overflow-wrap: break-word;
}

/* ── ① 近 7 天答题量的竖柱 ───────────────────────────────────────
   ⚠ `.dash-bars` **不定高**，定高的是里面那一层 `.dash-bar-track`（136）。
     为什么不在这一层定 160（稿子那个数）：这一列里除了柱子还有「数字」和
     「日期」两行小字（数字必须上屏，见 admin-dashboard.js 那段），三样共用一个
     固定高度，flex 就会去压缩能缩的那一项 —— 能缩的正好是柱子，于是七天里
     最长的那根永远量不到 136，卡片的柱高跟数据脱钩。
     把绘图那片单独切一层、高度写死，柱子就只跟数据有关了。
   ⚠ 136 的来历见 admin-dashboard.js 的 DASH_PLOT_H：
     稿子 .n-9-69 声明 160 高、上下各 12 内边距（padding: var(--sp-3)）⇒ 160−24。 */
.dash-bars {
  display: flex;
  flex-direction: row;
  align-items: flex-end;
  justify-content: space-between;
  gap: var(--sp-2);
  align-self: stretch;
  min-width: 0;
  padding: var(--sp-3);
  background: var(--cl-bg-sunken);
  border-radius: var(--rd-sm);
}
.dash-bar {
  display: flex;
  flex: 1 1 0;
  min-width: 0;
  flex-direction: column;
  align-items: center;
  justify-content: flex-end;
  gap: 2px;
}
.dash-bar-track {
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  align-self: stretch;
  width: 100%;
  height: 136px;              /* = DASH_PLOT_H，见上 */
}
.dash-bar-fill {
  width: 100%;
  max-width: 28px;            /* 手机上一格 30px 不到，条太宽会糊成一片 */
  min-height: 3px;            /* 0 也要看得见那一条底线（见 MIN_BAR_H 那段） */
  align-self: center;
  background: var(--cl-primary-600);
  border-radius: 4px;         /* 稿子 .n-9-92 那个 4px，不是令牌 */
}
.dash-bar-fill--today { background: var(--cl-primary-700); }
.dash-bar-num,
.dash-bar-date {
  font-family: "Noto Sans SC", var(--ff);
  font-size: var(--fs-caption);
  line-height: var(--lh-caption);
  white-space: nowrap;        /* 「09-27」断成两行就完全不是日期了 */
}
.dash-bar-num {
  color: var(--cl-text-primary);
  font-weight: var(--fw-medium);
}
.dash-bar-date { color: var(--cl-text-secondary); }

/* ── ② 全站正确率的环形 ──────────────────────────────────────────
   ⚠ 半径/线宽都在 admin-dashboard.js 里（viewBox 0 0 120 120 + r=52 + 线宽 12），
     这一节只管颜色。**两边要一起改**：比方说把线宽调成 16，弧就会顶出 viewBox 被裁。
   ⚠ 中间那个大数是**叠在 SVG 上的 HTML**（absolute + inset:0 居中），不是 SVG text ——
     SVG 里的字不参与外部字体栈的行高计算，在不同浏览器上会跳。
   ⚠ 环**没有 transition**（跟柱状图同一个理由：这是数据，不是动效）。 */
.dash-ring-wrap {
  position: relative;
  align-self: center;
  width: 140px;
  height: 140px;
  display: flex;
  align-items: center;
  justify-content: center;
  margin: var(--sp-1) 0;
}
.dash-ring {
  display: block;
  width: 100%;
  height: 100%;
}
.dash-ring-track {
  stroke: var(--cl-primary-50);
  stroke-width: 12;
}
.dash-ring-value {
  stroke: var(--cl-primary-700);
  stroke-width: 12;
  /* 不用 round 线帽：`stroke-dashoffset` 正好等于周长（正确率 0）时，
     圆头线帽会在 0 长度上画出一个点 —— 看上去像「有一点点正确率」。 */
  stroke-linecap: butt;
}
.dash-ring-mid {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  pointer-events: none;
}
.dash-ring-num {
  color: var(--cl-text-primary);
  font-size: var(--fs-title);
  font-family: "Noto Sans SC", var(--ff);
  font-weight: var(--fw-semibold);
  line-height: 1.1;
  white-space: nowrap;
}
.dash-ring-foot {
  align-self: stretch;
  color: var(--cl-text-secondary);
  font-size: var(--fs-caption);
  font-family: "Noto Sans SC", var(--ff);
  line-height: var(--lh-caption);
  text-align: center;
  overflow-wrap: break-word;
}

/* ── ③ 题库类型的堆叠占比条 + 图例 ───────────────────────────────
   ⚠ 各段的宽度是**内联的 flex-grow**（= 各档的数量），这一层只给高度和圆角。
     不用 `width: N%`：百分比要前端自己算一遍，三档算下来常常不等于 100%
     （四舍五入），条尾会露出一小截底色。flex-grow 是按比例分，不会。 */
.dash-stack {
  display: flex;
  flex-direction: row;
  align-self: stretch;
  height: 14px;
  min-width: 0;
  background: var(--cl-bg-sunken);
  border-radius: var(--rd-capsule);
  overflow: hidden;           /* 两端的段跟着圆角切齐 */
}
.dash-seg {
  flex: 1 1 0;                /* 实际宽度由内联 flex-grow 定 */
  min-width: 3px;             /* 极小的一档也要留一条看得见的缝，不然它等于不存在 */
}
.dash-tone--a { background: var(--cl-primary-700); }
.dash-tone--b { background: var(--cl-accent-700); }
.dash-tone--c { background: var(--cl-success-700); }
.dash-tone--d { background: var(--cl-text-disabled); }

.dash-legend {
  display: flex;
  flex-direction: row;
  flex-wrap: wrap;
  gap: var(--sp-1) var(--sp-3);
  align-self: stretch;
  min-width: 0;
}
.dash-legend-item {
  display: flex;
  flex-direction: row;
  align-items: center;
  gap: var(--sp-1);
  min-width: 0;
}
.dash-dot {
  flex: 0 0 auto;
  width: 8px;
  height: 8px;
  border-radius: var(--rd-capsule);
}
.dash-legend-name {
  color: var(--cl-text-primary);
  font-size: var(--fs-caption);
  font-family: "Noto Sans SC", var(--ff);
  line-height: var(--lh-caption);
  white-space: nowrap;
}
.dash-legend-val {
  color: var(--cl-text-secondary);
  font-size: var(--fs-caption);
  font-family: "Noto Sans SC", var(--ff);
  line-height: var(--lh-caption);
  white-space: nowrap;
}

/* ── ④⑤ 横条排行 ────────────────────────────────────────────────
   一行三格：名次（定宽）· 名字 + 条（可缩）· 数（定宽、右对齐一列）。
   名字与条**竖着摞在同一格里**（不是并排）：手机上并排只剩不到 100px 写名字，
   一个中文题库名会挤成三四行；摞起来之后名字独占整行宽，条的宽度也能一眼比出来。 */
.dash-rank {
  display: flex;
  flex-direction: column;
  gap: var(--sp-3);
  align-self: stretch;
  min-width: 0;
}
.dash-rank-row {
  display: flex;
  flex-direction: row;
  align-items: center;
  gap: var(--sp-2);
  min-width: 0;
}
.dash-rank-no {
  flex: 0 0 auto;
  width: 16px;
  color: var(--cl-text-tertiary);
  font-size: var(--fs-caption);
  font-family: "Noto Sans SC", var(--ff);
  line-height: var(--lh-caption);
  text-align: center;
}
.dash-rank-mid {
  display: flex;
  flex: 1 1 0;
  min-width: 0;
  flex-direction: column;
  gap: 3px;
}
.dash-rank-name {
  color: var(--cl-text-primary);
  font-size: var(--fs-caption);
  font-family: "Noto Sans SC", var(--ff);
  line-height: var(--lh-caption);
  /* 名字长了**截断**、不折行：折行会让下面那根条跟着往下挪，五行的条对不齐，
     一眼看不出谁比谁多多少。全名挂在行的 title 上，鼠标停上去能看全。 */
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.dash-rank-track {
  align-self: stretch;
  height: 6px;
  background: var(--cl-bg-sunken);
  border-radius: var(--rd-capsule);
  overflow: hidden;
}
.dash-rank-fill {
  height: 100%;
  background: var(--cl-primary-600);
  border-radius: var(--rd-capsule);
}
.dash-rank-num {
  flex: 0 0 auto;
  color: var(--cl-text-primary);
  font-size: var(--fs-caption);
  font-family: "Noto Sans SC", var(--ff);
  font-weight: var(--fw-medium);
  line-height: var(--lh-caption);
  white-space: nowrap;
}

/* ── 电脑档 ──────────────────────────────────────────────────────
   手机上内容区只有 329 宽，五块图竖着排（一列）；电脑上内容区 1188，
   排两列每格 570 左右 —— 柱子、条都读得开。
   ⚠ ① 那张答题量图**横跨两列**（七根柱子挤在半格里太窄）；
     见 admin-dashboard.js 里给那一张加的 `.dash-card--wide`。
   ⚠ 这一层用 `.admin-shell` 而不是媒体查询：本项目的「电脑档」就是壳上这个类
     （见 ㉖ 那几条，同一个写法）。 */
.admin-shell .dash-charts { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.admin-shell .dash-card--wide { grid-column: 1 / -1; }

/* ══════════════════════════════════════════════════════════════════
 * ㊺ 后台的**分栏**（HIG split view / master-detail）
 *
 * 用户 2026-09-27 点名要的：左「列表」、右「选中项的详情」，六个屏都要
 * （题库详情 / 组织详情 / 用户管理 / 资料管理 / 纠错管理 / 激活码）。
 * 骨架在 `app/admin-split.js`（它负责搬节点、拖宽度、记宽度）。
 *
 * ★ 这一套版式**稿子里没有** —— 97 屏设计稿里只有两屏是桌面档（8-402 / 8-759），
 *   而它们的内容区也是单列。所以下面这些数值（320 / 240 / 10）是**这里定的**，
 *   不是从稿子里量的。要改就改这里 + `admin-split.js` 顶部那两个常量，两处一起改。
 *
 * ⚠ 全部收在 `.admin-shell` 里 —— 壳只在电脑上挂（app.js 的 `desktopAdmin`），
 *   所以手机档一个像素都碰不到，分栏的「退化」在手机上是**自然的**
 *   （骨架压根没挂，那一屏还是原来那一条列表 + 弹层）。
 * ══════════════════════════════════════════════════════════════════ */

/* 内容区变成两栏的宿主。
 *
 * ★ 选择器为什么要写**三层**（`.admin-shell-main` + 类名重复一次）：
 *   内容区自己那条规则来自稿子的帧 CSS（`.n-10-4 { padding:24px; gap:16px;
 *   align-items:flex-start; overflow-y:auto; … }`），而**帧 CSS 是运行时后加载的**。
 *   两层特异性跟它打成平手 ⇒ 后加载的它赢，而且是**按属性**赢的（实测）：
 *     实测 1440 下 组织详情那屏 —— 分栏根只有 728px 宽（内容区 1220），
 *     因为 `align-items:flex-start` 是它给的；激活码那屏 —— 内容区还留着
 *     `padding:24px` 且 `overflow-y:auto`，于是整屏多出一条外滚动条。
 *   写成三层就稳定压住它，不用 `!important`（那玩意儿以后没人敢动）。
 *
 * ★ padding / gap **故意不覆盖**：`padding:24px` 是那一列卡片本来要的边距，
 *   `gap:16px` 就是「顶部横跨块」和下面分栏之间那 16px 呼吸 —— 都是稿子的意思，
 *   抹掉它们才是改版式（本项目红线：稿子是唯一的版式来源）。
 *   这里只改**逐屏复用必需的那两条**：横向撑满、宿主自己不滚。 */
.admin-shell-main .admin-split-host.admin-split-host {
  display: flex;
  flex-direction: column;
  flex: 1 1 auto;
  min-height: 0;
  /* ★ 横向必须撑满：帧 CSS 的 `align-items:flex-start` 会让分栏根在交叉轴上
     退成 max-content 宽（实测组织详情那屏只有 728px，右栏被挤成 398、
     右边空一大片灰）。 */
  align-items: stretch;
  /* ★ 滚动交给**每一栏自己**（`.admin-split-left` / `-right` 各自 overflow-y:auto）。
     宿主再滚一次的话，滚左栏会把右边那块详情一起带走 —— 那就不是分栏了。
     而且宿主带 padding 时 `overflow-y:auto` 还会把分栏根的下边裁掉一截。 */
  overflow: hidden;
}

.admin-split {
  display: grid;
  /* 左栏 | 分隔条 | 右栏。--split-left 由 admin-split.js 设（拖出来或记忆里读出来）
     ⚠ 左栏外面这层 `minmax(0, …)` 不能省（`.admin-split--3` 那两栏同理，那里早就有了）：
       写成裸的 `var(--split-left, 320px)` 就是**一点都不能缩**，宿主一窄就先挤右栏，
       右栏到 0 之后控件开始往屏幕外跑（实测 600 宽时「+ 上传资料」出界，
       而文档没有横向滚动条 ⇒ 够不着）。给了下界 0 就变成「左栏先让一让」。
       ⚠ 900 以下整个壳都不挂了（`app.js` 的 SHELL_MIN_W），这一条管的是 900~1000
         那一小段「摆得下但不宽裕」的情形。 */
  grid-template-columns: minmax(0, var(--split-left, 320px)) 10px minmax(0, 1fr);
  /* ★ 高度交给 flex 分，**不能写 `height: 100%`**：
     宿主是列向 flex，里面有「顶部横跨块 + 分栏根」两个孩子。`height:100%` 是
     按**宿主的全高**算，不是按剩余空间算 —— 于是「顶部块 + 全高」超出宿主，
     被宿主的 `overflow:hidden` 裁掉，裁掉的恰好是紧接顶部块下面的那一截
     （实测：题库详情那一屏把中栏的「试题列表」标题整行切掉一半）。
     `flex: 1 1 auto` + `min-height: 0` 才是「把剩下的地方全占了」的正确写法。 */
  flex: 1 1 auto;
  min-height: 0;
  overflow: hidden;
  background: var(--cl-bg-page);
  /* 撑满宿主的宽（配合上面那条 align-items）。写死宽度而不是只靠 stretch：
     分栏根将来若被放进别的容器（比如某个 grid 项里），stretch 不一定还在。 */
  width: 100%;
  min-width: 0;
}

/* 三栏（只有题库详情用：章节卡 / 试题列表 / 题目详情，见 22 号 R4）。
   多一栏就多一对「宽度变量 + 分隔条」，变量名与默认值跟两栏那份一个规矩。 */
/* ★★ 2026-09-30：两栏写死宽度，三栏得**能挤**。
 *
 * 实测（题库详情，1024 宽）：可用 804，左 320 + 条 10 + 中 320 + 条 10 = 660，
 * 留给右边「题目详情」只剩 **144px** —— 题干被逼成一个字一行竖着排
 * （「下 列 各 句 中」），那一栏里的按钮全挤成一条缝，点不着。
 * 1280 下剩 400 勉强能看，所以这个问题**只有窗口窄的时候才露头**。
 *
 * 改法：左、中两栏给个**上限**（还是用户拖出来/记住的那两个变量），
 * 但允许被挤小；右栏给一个**下限 320**（大约是一行题干放得下的宽度）。
 * 装得下时行为跟原来**一模一样**（两栏各自长到 320，右栏拿走剩下的），
 * 装不下时先挤左、中 —— 实测 1024 下变成 232 / 232 / 320，能读了。
 *
 * ⚠ 为什么不在 JS 里判「太窄就退成两栏」：那要么丢掉中栏（章节或题表二选一），
 *   要么得当场搬节点 —— 前者丢功能，后者是这一屏最复杂的一段逻辑。
 *   而「挤小一点还能用」是这个版式本来就能做到的事，交给 CSS 最省。
 * ⚠ `minmax(0, …)` 的下界写 0 不写 240：240 是**拖拽**的下界（MIN_LEFT），
 *   是「用户拖到这儿就别拖了」；这里是「窗口就那么宽，没办法」——
 *   两件事。写 240 的话 1024 下右栏只剩 144，等于没修。
 * ⚠ 最窄到 (0+10+0+10+320)=340：比这还窄（比如把窗口拖到 560 以下）时
 *   分栏根自己会溢出，那一档本来就不是这套后台支持的用法。 */
.admin-split--3 {
  grid-template-columns:
    minmax(0, var(--split-left, 320px)) 10px
    minmax(0, var(--split-mid, 320px)) 10px
    minmax(320px, 1fr);
}

/* ✔ 旧账已修（2026-09-30 第五十一轮）：三栏屏上左栏和中栏的上限原来是
   **各算各的**（左 = 内容区一半 610、中 = 0.35 400），1440 下三栏期望宽
   610+10+400+10+320 = 1350 > 栅格 1188 —— 装不下，栅格把左栏挤到 448，
   于是 `--split-left` 一路涨、`aria-valuenow` 也跟着涨，**左栏的框却纹丝不动**：
   拖过头之后那条分隔条在撒谎。
   现在左栏的上限算的是 `容器宽 − 中栏宽 − 两条分隔条 − 右栏下限` = 448，
   跟栅格实际给它的宽**是同一个数**。算式与理由见 `admin-split.js` 的 panes。 */

/* 顶部横跨块：稿子里属于「整页的头」那些节点（题库头卡 / 组织头部卡 / 激活码工具栏）。
   它不进任何一栏 —— 进了就会被拖窄（比如「题库名」跟着列表一起缩）。 */
.admin-split-top {
  flex: 0 0 auto;
  min-width: 0;
  /* ★ `flow-root` 不是装饰，是**必须**的：它建一个 BFC，把里面最后那张卡的
     `margin-bottom` 关在顶块**内部**。不建的话那个 margin 会塌陷穿透出去，
     顶块在文档流里实际占了 16px，而它的 `getBoundingClientRect().bottom`
     却少这 16px —— 于是「顶块底边」和「分栏根顶边」差 16px。
     视觉上看不出来（那 16px 空白本来就在），但任何按边界对齐的判据都会红，
     而且将来给顶块加边框/底色，缺的那一块就露出来了。 */
  display: flow-root;
}

/* 中间栏（三栏时才有）：结构与左栏一样，只是位置在中间。 */
.admin-split-mid {
  display: flex;
  flex-direction: column;
  min-width: 0;
  min-height: 0;
  overflow-y: auto;
  overflow-x: hidden;
  overscroll-behavior: contain;
}

/* 左栏：筛选行 + 列表 + 页脚（都是稿子原本的节点）。自己滚，不带着右栏一起滚。 */
.admin-split-left {
  display: flex;
  flex-direction: column;
  min-width: 0;
  min-height: 0;
  overflow-y: auto;
  overflow-x: hidden;
  overscroll-behavior: contain;
}

/* ★ 栏里**不许压扁任何一个稿子块** —— 要滚，不要挤。
 *
 * 2026-09-30 实测（资料管理，1280 宽）：左栏里那枚「+ 上传资料」算出来 **高 0px** ——
 * 元素在、字在、`display:flex`、`visibility:visible`，就是占 0 高，屏幕上什么都没有。
 * 机器主人的原话是「有的组织有上传资料的按钮，有的没有……被遮挡、被隐藏」。
 *
 * 根因不是它自己的样式写错了，而是**它是这一栏里唯一一个没写 `flex-shrink:0` 的块**：
 * 帧 CSS 给筛选行 / 资料列表 / 页脚都写了，唯独这枚按钮没写（稿子里它是单独一行，
 * 没人想到它会被塞进一个定高的列里）。于是列表一长、这一栏装不下时，
 * **全部压缩量由它一个人吸收** —— 直接压成 0。
 * 这也正是「有的组织有按钮、有的没有」：文件少的组织（或「还没建目录」那种）
 * 这一栏装得下，按钮还在（实测 44px）；文件多的装不下，就被压没了（实测 0px）。
 *
 * ⚠ `min-height:auto` 救不了它：自动最小尺寸对 `overflow != visible` 的 flex 项按 0 算，
 *   而帧 CSS 给了它 `overflow:hidden`。
 *
 * 写成「这一栏的每个直接子节点」而不是点名那枚按钮：**这是这一栏的规矩**
 * （它是 `overflow-y:auto` 的滚动列 —— 滚动列的语义就是子项保持原尺寸、装不下就滚），
 * 而且别的屏只要有同样「稿子里漏写 `flex-shrink:0`」的块，一样会被压扁。
 * 只写直接子节点：稿子里的块本来就是直接子节点，加 `>` 只压这一层、不牵连卡内部。 */
.admin-split-left > *,
.admin-split-mid > * {
  flex-shrink: 0;
}

/* 右栏：详情。也自己滚 —— 这才是分栏的意义：滚左边不动右边。 */
.admin-split-right {
  display: flex;
  flex-direction: column;
  min-width: 0;
  min-height: 0;
  overflow-y: auto;
  overflow-x: hidden;
  overscroll-behavior: contain;
  padding: var(--sp-5);
  background: var(--cl-bg-page);
}

/* ── 分栏三栏的滚动条：**细、淡、没有箭头** ──────────────────────────
 * 2026-10-02。机器主人那张 1455×750 的截图里最扎眼的东西就是它们：
 * 三栏各挂一条灰柱子，并排三条，把一屏劈成好几块 ——
 * 他原来的话是「两个滚动条、三个滚动条、表格嵌表格似的，很丑」。
 *
 * ★ 尺寸和颜色是**从他那张 2546×1311 截图里量出来的**（不是估的）：
 *     左栏条 x=812..827  → 16 物理 px = **9.1 CSS px**，滑块高 730 物理 px
 *     中栏条 x=1573..1586 → 14 物理 px = **8.0 CSS px**
 *     右栏条 x=2498..2513 → 16 物理 px = **9.1 CSS px**
 *   三条滑块取色都是 **(139,139,139) = #8B8B8B**，两端**没有**三角箭头
 *   （Chrome 早就不画箭头了，别按「Windows 原生条」去想象它）。
 *   ⇒ 丑的构成是：**灰得深（#8B8B8B）+ 并排三条**，不是箭头。
 *
 * ★ 先说清楚：**没有把滚动条藏掉，也没有把三栏变成不滚**。
 *   量过（1455×750，`.admin-split` 只有 515 高）：
 *     左栏内容 **636**（12 个章节行 × 44 的可点区 + 卡片留白）、
 *     右栏内容 **626**（一整道题：题干 + 四个选项 + 正确答案 + 解析）。
 *   44 是 `--sz-tap-min`（手指的最小命中区），**不能砍** ——
 *   就算砍到 36，12 行也还是 540 > 515。
 *   所以「这一屏在矮窗口上分栏会滚」是**版式上的必然**，不是 bug；
 *   这一节治的是它**滚起来有多丑**。
 *
 * 改法就三样：变细、轨道透明、滑块半透明圆角，指到那一栏才变深。
 * 轨道透明是为了让滑块像浮在内容边上，而不是再画一条浅灰的柱子；
 * 滑块 rgba(15,23,42,0.28) 压到页面底色上 ≈ #BCBEC3，比原来的 #8B8B8B
 * 亮了约 50 级 —— **这是这一节真正起作用的那个数字**。
 *
 * ⚠ **Chrome ≥121 上真正生效的是 `scrollbar-width`/`scrollbar-color` 这两条
 *   标准属性；`::-webkit-scrollbar` 那几块会被忽略**（新版规范优先）。
 *   留着头几块 webkit 只是为了喂 121 以前的旧 Chrome/Edge —— 别以为它是主力，
 *   也别在它上面调参数然后去截图找效果（找不到的）。
 * ⚠ Firefox 只认标准属性，不认 `::-webkit-scrollbar`，所以两条都得写。
 * ⚠ 只挂 `.admin-split-*` 这三栏：不碰全站、不碰手机档
 *   （手机档根本没有分栏，它那些列表用的是浏览器默认滚动条，一个字不动）。
 * ⚠ **改完量不出「占几像素」是正常的**：这台机器上 Chrome 用悬浮滚动条，
 *   `offsetWidth - clientWidth` 改前改后恒为 0。要验它请读计算样式
 *   （`getComputedStyle(el).scrollbarWidth` 必须是 `thin`），
 *   `tools/build/probe-admin-split.py` 的「一·补九」就是干这个的。
 * ⚠ 滑块颜色写死 rgba 而不是引令牌：令牌里没有「半透明中性色」这一档，
 *   硬套 `--cl-border-base`（不透明 #E8E8EF）在深色主题下会变成一条亮线。
 *   rgba(15,23,42) 是通用的深色中性，靠 alpha 在深浅两套主题里都成立。 */
.admin-split-left,
.admin-split-mid,
.admin-split-right {
  scrollbar-width: thin;                                  /* Firefox */
  scrollbar-color: rgba(15, 23, 42, 0.28) transparent;    /* Firefox */
}
.admin-split-left::-webkit-scrollbar,
.admin-split-mid::-webkit-scrollbar,
.admin-split-right::-webkit-scrollbar {
  width: 6px;
  height: 6px;
}
.admin-split-left::-webkit-scrollbar-track,
.admin-split-mid::-webkit-scrollbar-track,
.admin-split-right::-webkit-scrollbar-track {
  background: transparent;
}
.admin-split-left::-webkit-scrollbar-thumb,
.admin-split-mid::-webkit-scrollbar-thumb,
.admin-split-right::-webkit-scrollbar-thumb {
  background: rgba(15, 23, 42, 0.16);
  border-radius: 999px;
}
.admin-split-left:hover::-webkit-scrollbar-thumb,
.admin-split-mid:hover::-webkit-scrollbar-thumb,
.admin-split-right:hover::-webkit-scrollbar-thumb {
  background: rgba(15, 23, 42, 0.34);
}
/* 拖滑块的时候颜色不能再退回去（`:hover` 在拖动全程成立，但显式写一条更稳） */
.admin-split-left::-webkit-scrollbar-thumb:active,
.admin-split-mid::-webkit-scrollbar-thumb:active,
.admin-split-right::-webkit-scrollbar-thumb:active {
  background: rgba(15, 23, 42, 0.46);
}

/* ── 全后台的滚动条：**同一个尺寸、同一个颜色** ────────────────────────
 * 用户 2026-10-02：「其他页面呢，我说的是其他页面也要……滚动条丑大」。
 *
 * ★ 他说的「其他页面」是哪几个 —— 量过（1455×750，15 屏普查）：
 *   上面那一节只把**分栏那三栏**变细了，剩下**四条全宽的滚动页**当时
 *   还是浏览器默认：数据概览的「内容区」、IP 黑名单 / 卡片管理 / 文章管理
 *   的「正文」—— 实测 `getComputedStyle(el).scrollbarWidth === "auto"`。
 *   Windows 上那是 17px 的灰柱子，而同一屏旁边分栏那条是 8px 淡条：
 *   **一屏里两种滚动条**，正是他说的「丑大」。
 *
 * ★ 为什么是「整个 `.admin-shell` 一刀切」而不是再列一遍名单：
 *   · `.admin-shell` **只在电脑档挂**（手机档根本没有这个壳，见
 *     `app/admin-shell.js`），所以这一刀天然不碰手机 ——
 *     这正是上面那一节反复强调的那条边界，不用再写一遍 `:not()`。
 *   · 再列名单就是接着漏：今天漏了「正文」，明天新加一屏又会漏。
 *   · 「只有能滚的盒子才画条」是浏览器自己保证的，所以 `*` 不会误伤。
 *
 * ⚠ 唯一的例外是**故意藏条**的那种（帧里那条 Markdown 工具条：横排一条、
 *   本来就不该出现横杠）。覆盖写在下面，靠**多一个类**赢，不靠文件顺序 ——
 *   顺序会被后来的人挪。
 * ⚠ `::-webkit-scrollbar` 那几块在 Chrome ≥121 上不生效（见上面 ㊸ 那段），
 *   留着只为旧 Chrome/Edge，参数跟标准属性对齐、别单拎出来调。 */
.admin-shell,
.admin-shell * {
  scrollbar-width: thin;
  scrollbar-color: rgba(15, 23, 42, 0.28) transparent;
}
.admin-shell ::-webkit-scrollbar {
  width: 8px;
  height: 8px;
}
.admin-shell ::-webkit-scrollbar-track {
  background: transparent;
}
.admin-shell ::-webkit-scrollbar-thumb {
  background: rgba(15, 23, 42, 0.16);
  border-radius: 999px;
}
.admin-shell ::-webkit-scrollbar-thumb:hover {
  background: rgba(15, 23, 42, 0.34);
}
/* 帧里那条 Markdown 工具条是**故意不画滚动条**的（手机档靠滑一下，
   电脑档工具条窄的时候也能滚，但一条横杠挂在工具条下面只是噪声）。
   `.admin-shell .ae-mdtools` 比 `.admin-shell *` 多一个类 ⇒ 与顺序无关。 */
.admin-shell .ae-mdtools {
  scrollbar-width: none;
}
.admin-shell .ae-mdtools::-webkit-scrollbar {
  display: none;
}

/* ── **桌面档**（`data-ds="xq"`）的滚动条：跟上面那一刀**同一把尺** ────────
 *
 * ★ 为什么必须再写一遍（同一个坑踩了第二次）：
 *   上面那一刀是 `.admin-shell`，而 **`.admin-shell` 在桌面档根本不挂** ——
 *   `app.js:1448` 的 `desktopAdmin = … && !useDesk`，15 条 admin 路由
 *   **全都有 `deskSlug`** ⇒ `useDesk` 恒真 ⇒ 电脑上那个壳一次都不出现。
 *   所以上面整段在桌面档**一条都收不到**（同 46 号那条：
 *   「用 `.admin-shell` 当下限写的电脑适配规则，桌面档一条都收不到」）。
 *
 * · 特异性 (0,2,0)：压得住页 CSS 的 `.n-3-6924{…}` (0,1,0) ——
 *   稿子里那些 `overflow:auto` 的盒子才能拿到 thin（稿子 CSS 不写 scrollbar）。
 * · **不会误伤那条「故意藏条」的 MD 工具条**：它在**手机档手写帧**
 *   （`web/pages/admin-article-edit.html`），而手机档屏**没有 `data-ds`**
 *   （`admin-desk.js:1022` 明写）⇒ 选不到它。下面仍补一条同名例外，
 *   是为了**万一**哪天桌面档帧也长出 `.ae-mdtools` 时不至于被这条盖掉。
 * · 「只有真能滚的盒子才画条」是浏览器自己保证的，所以 `*` 不误伤。 */
.screen[data-ds="xq"],
.screen[data-ds="xq"] * {
  scrollbar-width: thin;
  scrollbar-color: rgba(15, 23, 42, 0.28) transparent;
}
.screen[data-ds="xq"] .ae-mdtools {
  scrollbar-width: none;
}
.screen[data-ds="xq"] .ae-mdtools::-webkit-scrollbar {
  display: none;
}

/* ── 列表视口：列表卡片盒**里面**那张会滚的 div（`admin-bank-desk.js` 的
 *    `listVp`，完整坐标推导写在那边的注释里）─────────────────────────────
 * · `left`/`top` 由 JS 写成卡片盒**稿子基线** left/top 的相反数 —— 于是纵向原点
 *   恒为 0（引擎只动 left/width、从不写 top）⇒ 行的 `top` 一个字节都不用改；
 *   横向原点 = 引擎位移 Δ ⇒ 稿子 `left` 自动变成「相对卡片盒」⇒ 行跟着卡片盒。
 * · `right/bottom:0` ⇒ 右下缘**贴住卡片盒当前的右下缘**（跟着它一起被拉宽/挪）。
 * · `overflow-x:hidden`：行底靠 `right` 撑宽，横向绝不能冒出滚动条。
 * · 裁剪靠**卡片盒自己的** `overflow:hidden`（页 CSS 给的，app.css:469 那条
 *   `overflow:visible` 只松到 `.screen > *` = 稿子根那一层，松不到孙子）。
 *   ⇒ 视口伸到卡片盒外面的那两条死带被裁掉，**滚动条正好落在卡片盒右缘**。 */
.screen[data-ds="xq"] .xq-listvp {
  position: absolute;
  right: 0;
  bottom: 0;
  overflow-x: hidden;
  overflow-y: auto;
  overscroll-behavior: contain;
}

/* 详情落点（`admin-split.js` 造的容器，每屏的详情渲染往这里放） */
.admin-split-detail {
  display: flex;
  flex-direction: column;
  gap: var(--sp-4);
  min-width: 0;
}

/* ── 分隔条 ────────────────────────────────────────────────────────
 * 命中区是整条 10px（能抓住），视觉上只画中间那 1px 的线（好看）。
 *
 * ⚠ **2026-10-02 改：那条线从「只有指过去才画」改成「一直画着」。**
 *   用户这一轮的原话里有「分栏不清晰」，量的结果就是这一句：
 *   1455×750 实测（用户管理 / 资料管理 / 纠错管理三屏）——
 *     `.admin-split-right` 底色 = `rgb(250,250,255)`，跟页面底色**一模一样**；
 *     边框 0px、阴影 none；里面那张详情卡是纯白 255，跟底色**只差 5/255**。
 *     加上这条分隔线平时是 `transparent` —— 于是整条缝上**没有任何一处**
 *     是看得见的，两个栏的分界全靠「白卡比底色亮一点点」去猜。
 *   而**题库详情（`9:187`）那屏早就不是这样**：它的抽屉
 *   `border-left: 1px solid var(--cl-border-base)` + 一道阴影（见 6525 那段）。
 *   同一个后台，四屏分栏三种做法 —— 「分栏不清晰」和「风格各异」是同一件事。
 *
 *   所以线改成常驻，颜色跟抽屉那条**是同一个 token**（`--cl-border-base`），
 *   跟列表里的行分隔线也是同一个 —— 这样「分栏的缝」和「行的缝」
 *   在同一个视觉系统里。
 *   「可以拖」那个暗示不能丢：原来的 hover 色正好就是这条线现在的颜色，
 *   再画一遍等于没反应，所以 hover / 聚焦 / 拖动**三级都用主色**
 *   （`--cl-primary-500`），比原来更明显。 */
.admin-split-bar {
  position: relative;
  cursor: col-resize;
  background: transparent;
  /* 触摸设备上别让浏览器把拖动当成滚动 */
  touch-action: none;
}
.admin-split-bar::before {
  content: "";
  position: absolute;
  left: 50%;
  top: 0;
  bottom: 0;
  width: 1px;
  transform: translateX(-50%);
  background: var(--cl-border-base);
  transition: background 120ms ease;
}
.admin-split-bar:hover::before,
.admin-split-bar:focus-visible::before {
  background: var(--cl-primary-500);
}
.admin-split-bar:focus-visible {
  outline: 2px solid var(--cl-primary-500);
  outline-offset: -2px;
}
/* 拖的时候整条线变成主色（告诉用户「你正在拖它」） */
body.admin-split-dragging .admin-split-bar::before {
  background: var(--cl-primary-500);
}
/* 拖的过程中别让横向拖动把列表里的字刷成蓝色选中 */
body.admin-split-dragging {
  user-select: none;
  cursor: col-resize;
}
/* 焦点不要落在那些「一按就跑」的东西上，拖起来才不跳 */
body.admin-split-dragging * { cursor: col-resize !important; }

/* ── 选中的那一行 ──────────────────────────────────────────────────
 * HIG：列表里当前选中项要有明显的底色，用户才知道右边那条是「哪一条」。
 * ⚠ 判据是 `[data-row-id]`（分栏的契约属性），不是 `.admin-row`：
 *   六屏里五屏的行是脊柱造的 div 卡片（有 `.admin-row`），而**激活码那一屏的行是
 *   真 `<tr>`**（手写帧的表格，不走脊柱）—— 它们两边都挂了 `data-row-id`。
 * ⚠ 左侧那条竖条对卡片用绝对定位伪元素；对 `<tr>` 改成「首格内阴影」——
 *   `<tr>` 上的绝对定位伪元素各浏览器表现不一致（表格行有它自己的盒模型），
 *   不值得赌一个只在那一屏才看得出来的差异。 */
.admin-split-left [data-row-id],
.admin-split-mid [data-row-id] {
  position: relative;
}
.admin-split-left [data-row-id].is-selected,
.admin-split-mid [data-row-id].is-selected {
  background: var(--cl-primary-50);
}
.admin-split-left [data-row-id].is-selected::after,
.admin-split-mid [data-row-id].is-selected::after {
  content: "";
  position: absolute;
  left: 0;
  top: 0;
  bottom: 0;
  width: 3px;
  background: var(--cl-primary-500);
}
.admin-split-left tr[data-row-id].is-selected::after,
.admin-split-mid tr[data-row-id].is-selected::after {
  content: none;
}
.admin-split-left tr[data-row-id].is-selected > td:first-child,
.admin-split-mid tr[data-row-id].is-selected > td:first-child {
  box-shadow: inset 3px 0 0 var(--cl-primary-500);
}

/* ── 宿主化的表单（右栏里那一块，openSheet 的 opts.host）───────────
 * 它是**同一块面板**（稿子 15-111 的「弹窗/纠错」），只是不再当弹层。
 * 所以要把 `.dialog-gaozi` 那一套「钉在视口底部」的属性**全部撤掉** ——
 * 这里一条都不写位置，位置由 `.admin-split-right` 的流决定。 */
.admin-split-detail .dialog-inline {
  position: static;
  max-height: none;
  overflow: visible;
  z-index: auto;
  width: 100%;
  /* 弹层那种「浮在内容之上」的阴影在右栏里是错的（它不是浮层） */
  box-shadow: none;
  /* 稿子的面板自带圆角与背景；在右栏里保持它自己的样子，只把外边距归零 */
  margin: 0;
}

/* 危险键的 hover/active 原本挂在 `.dialog-gaozi` 下（app.css 2859/2860），
   宿主模式不叫那个名字了，这几条得跟着走一份 —— 否则右栏里的「删除」按下去没有反馈。 */
.admin-split-detail [data-name="Component/Button/Danger/Default"]:hover {
  background: var(--cl-danger-600);
}
/* ⚠ 2026-09-29（第七报）：这条底下原本还有一句 `scale: 0.98`，摘掉了。
   它是「几何只有一个作者」那条规则漏掉的一处 —— 我上一轮按**行**扫 `:active`，
   而它跟 `scale` 不在同一行，于是**静默漏掉**；漏掉之后它还在真机上演同一个 bug：
   它（0,3,0）比 `[data-press="down"]`（0,1,0）更具体，按下去停在 .98 而不是 .97，
   而且是**抬手之后**才动。现在全仓 `:active` 一律不碰几何（有闸门盯着，见
   `tools/build/check-active-geometry.mjs`）。
   ★ 第八报：底色**仍然留着**，但这次是**量过之后**才留的（不是"没顾上"）：
   `tools/build/probe-press-bg.py` 在三条桌面分栏屏（banks/b7、codes、users）× 5 种
   交互（初始 / 勾一行 / 点行 / 行上删除 / 组设置→删除该卡片组）下，**一次都没构造出**
   匹配这枚选择器的元素；同时读代码也说不通：`admin-dialog.js:739` 在 `dangerOk` 时
   是把危险色**贴到那枚 Primary 键上**（`data-name` 是 `Component/Button/Primary/Default`），
   而克隆 9-33 那枚 `Danger/Default` 的两处（`cards.js:1262` 清空本组进度、
   `bank.js:732` 重刷确认）都不在分栏右栏里。
   ⇒ 这一条**疑似死规则**。没删是守「没量到就不动它」：删错了是「右栏里那枚按下去没反馈」，
   而留着的代价只是多一条不生效的 CSS。哪一轮真构造出它了，就照同一把尺子换成
   `[data-press="down"]`；真确认不存在了，再连上面那条 `:hover` 一起删。 */
.admin-split-detail [data-name="Component/Button/Danger/Default"]:active {
  background: var(--cl-danger-700);
}

/* ── 只读详情那一排动作（admin-dialog.js 的 detailSheet）────────────
 * 里面装的是后台那套文字按钮（`.gaozi-act`）—— 跟列表卡上的「查看 / 管理 / 删除」
 * 是同一枚东西，不另造一种按钮。
 *
 * **摆哪一行看宿主**（2026-09-30 改）：手机浮层里它自成一行，落在字段下面；
 * 电脑右栏里它进按钮行、跟「关闭」并排（见下面那条 `margin-right: auto`）。 */
.admin-detail-actions {
  display: flex;
  flex-wrap: wrap;
  gap: var(--sp-2);
  margin-top: var(--sp-2);
}
/* 右栏那一档：动作行现在跟「关闭」同在一行里。`justify-content: flex-end`
   会把这两撮都挤到右边去 —— `margin-right: auto` 把左边这撮顶回最左，
   于是同一条动作条上「左一撮动作、右一枚收尾」。 */
.admin-split-detail .admin-form-actions > .admin-detail-actions {
  margin: 0 auto 0 0;
  min-width: 0;
}

/* ── 公式：`app/math.js` 画出来的那两个类名（2026-09-30，设计 29 号）──────
 *
 * 公式块/行内的**结构**由 KaTeX 自己那套类名负责（`.katex` / `.katex-display`），
 * 它们来自 index.html 那枚 <link>（vendor/katex/katex.min.css）。这一节只管
 * **我们自己的那两枚**类名 —— 也就是「这个公式摆在本页版式里该占多大地方」。
 *
 * 为什么不写 `.md-math`（行内那个）的规则：行内公式就该是**行内**的元素，
 * KaTeX 自带那套已经把基线、字距、上下标的排布管完了；我们再插一条
 * `vertical-align` 之类只会跟它打架。**没有规则就是正确的规则。**
 *
 * 块级那个必须能自己横向滚：一道题里的长公式（比如一串连等式）在手机上
 * 一定比屏宽长，不滚的话它会**把整页撑破**（正文横向溢出，用户左右晃屏）。
 */
.md-math-block {
  display: block;
  overflow-x: auto;
  overflow-y: hidden;
}
/* KaTeX 给 display 公式的上下外边距是 `1em`，在题干/卡面这种紧排的块里太松 ——
   收到令牌表里的一档（--sp-3 = 12px）。⚠ 首位那一个的内边距去掉，
   否则它会和容器自己的间距叠在一起（卡片里表现为「公式离上文特别远」）。 */
.md-math-block .katex-display {
  margin: var(--sp-3) 0;
}
.md-math-block .katex-display:first-child {
  margin-top: 0;
}
.md-math-block .katex-display:last-child {
  margin-bottom: 0;
}

/* ═══════════════════════════════════════════════════════════════════════════
 * ㊻ 题目编辑器（`quiz` 那一类型）—— 2026-09-30 第三十轮
 *
 * 设计见 `项目记录/30-后台题库管理-功能设计与题目编辑器-2026-09-30.md`。
 * 这一屏（9-187 题库详情）**稿子上没有「新建题目」这个入口** —— 我数过，
 * 那张画布上能点的只有「编辑题库 / 删除 / + 新增章节 / 下载模板 / 开始导入」。
 * 所以编辑器里的控件只能是**借的**：从别的帧克隆稿子里已有的元件
 * （借法与「跨帧克隆样式为什么能带上」见 `app/routes/admin-dialog.js` 开头那段）。
 *
 * 借来的节点带的是 `n-*` 类名，而那份 CSS 里**只有版面、没有状态** ——
 * 悬停 / 按下 / 键盘焦点 / 禁用四种，一条都没有（那把尺子只量版面）。
 * 这一节：给借来的按钮补状态，给新加的几个零件补版面。
 *
 * ⚠ 为什么下面有些选择器要用 `.screen` 或 `.admin-field` 打头、不能光写类名：
 *   帧的 CSS 是 `app/view.js` 的 loadViewOnce **后加载**的，同样 (0,1,0) 的规则
 *   它赢。**要压过稿子那几条，特异性必须抬到 (0,2,0) 往上** ——
 *   这条规矩的第一个出处写在 2139 行那一段（`.screen` 打头）。
 * ⚠ 认节点一律认**图层名**（`data-name`），不认 `n-*` 类名：稿子一重导出，
 *   那批类名全换一遍，认类名的规则会**静默失效**（不报错、就是没样式）。
 */

/* ── ① 借来的按钮：四态（H5）────────────────────────────────────────────
 * `admin-dialog.js` 的 `wireTap()` 给每一枚借来的按钮挂这个类。为什么挂**我们的**
 * 类名而不是往帧的白名单里加类名：`n-*` 每次重导出就换一批，加不完；而且漏一个
 * 是静默退化（不报错、就是没反馈）。一个稳定类名、四态只写这一个地方。
 *
 * 底色一律**换令牌一档**，不用 `filter: brightness()` —— 理由跟 `.ac-btn`（3241）那段
 * 一模一样：令牌换色是**可量的**，filter 改的是渲染结果、`backgroundColor` 一动不动。
 * 方向：悬停深一档、按下深两档。danger 那两档跟 `.ac-btn--danger` / 面板里那枚
 * 危险键**取同一个值**（"同一个应用里两枚红按钮按下时不能变成两种颜色"）。
 *
 * ⚠ 几何**不在这儿写**：按下缩到 `.97` 是全站唯一那一条 `[data-press="down"]`
 *   （75 行）管的。`wireTap` 挂的 click 监听会被 `view.js:589` 自动打上记号，
 *   所以这四态里白捡一个按下态，不用自己接事件。
 * ⚠ 禁用那一条不用 `:disabled`：借来的节点是 `div`，没有 `disabled` 属性，
 *   标起来走 `aria-disabled`（跟 `.ac-btn` 同一条路子，见 3268 行）。 */
.admin-tap[data-name="Component/Button/Secondary/Default"]:hover { background-color: var(--cl-primary-100); }
.admin-tap[data-name="Component/Button/Secondary/Default"][data-press="down"] { background-color: var(--cl-primary-200); }
.admin-tap[data-name="Component/Button/Primary/Default"]:hover { background-color: var(--cl-primary-800); }
.admin-tap[data-name="Component/Button/Primary/Default"][data-press="down"] { background-color: var(--cl-primary-900); }
.admin-tap[data-name="Component/Button/Danger/Default"]:hover { background-color: var(--cl-danger-600); }
.admin-tap[data-name="Component/Button/Danger/Default"][data-press="down"] { background-color: var(--cl-danger-600); }
/* Ghost 那一档（「添加选项」用的就是从 10-116 借的那枚）：它静息**没有底色**，
   所以悬停给个浅底是"多出来的"，不是"深一档"。取值照 `.ac-btn--ghost`。 */
.admin-tap[data-name="Component/Button/Ghost/Default"]:hover { background-color: var(--cl-primary-100); }
.admin-tap[data-name="Component/Button/Ghost/Default"][data-press="down"] { background-color: var(--cl-primary-200); }
.admin-tap[aria-disabled="true"] {
  pointer-events: none;   /* 可点与不可点必须一眼分得开（同 3268 行那条） */
  opacity: .5;
}

/* ── ② 「添加选项」那枚幽灵按钮够不着 44（H1）────────────────────────
 * 稿子 10-116 上它画的是 `height: 32px`（`.n-10-124__10-99`）。32 < 44，
 * 手指点它是"瞄着点"。只改**这一枚**的高度，不动稿子 ——
 * 它是这一屏里唯一一枚这么矮的可点件。宽度不用管：它是按钮，自己撑开。 */
.admin-optadd { height: var(--sz-tap-min); }

/* ── ②bis 新加的「新建题目」那一枚（克隆帧按钮）也够不着 44（H1）────────
 * 它跟这一行另外两枚一样是稿子画的（`height: 40px`）。40 是**全站帧按钮的既有
 * 版式**（稿子那一整套按钮都是 40），要动得整站一起动 —— 所以**视觉一概不动**，
 * 连它自己也不抬：抬了它这一行就高低不齐，而只抬这一行又跟别的屏不一样。
 * 但 H1 是硬线，新引进后台的控件达标是我的事 —— 于是走答案段那一套：
 * 纯伪元素往上下各够 2px，40 + 2×2 = 44，**一个像素的绘制都不变**。
 * ⚠ 只挂**这一枚**，不挂 `.admin-tap` 整族：那族里有**竖着叠**的场合（一列按钮
 *   挨着排），整族一起往外够会把邻居的命中区吃掉。既有欠账记在项目记录的遗留
 *   清单里，不在这儿顺手改（探针里那格只量"三枚一样高"，不判 44，判 44 会假红）。
 * ⚠ `overflow` 必须放开：这枚节点自带 `overflow: hidden`（稿子导出的每个节点都带），
 *   不关掉的话伸出去那 2px 会被**自己**裁掉 —— 看着加了、其实没加（4748 行同一个坑）。
 *   放开是安全的：探针在 393 / 360 / 320 三个宽度都判过 `clippedX == 0`，
 *   也就是说这里从来不靠裁切兜底。
 * ⚠ 选择器要**两格**（`.screen` ＋ 属性）：帧那边的 `.n-9-212 { overflow: hidden }`
 *   也是一格，而**帧 CSS 加载在 app.css 之后**（`view.js` 的 `loadViewOnce`）——
 *   一格对一格是它赢，写了等于没写（第一次就是这么白写了一遍，探针量出来上下只
 *   各够到 0.5/0，直接红给我看）。两格 0,2,0 才压得住。 */
.screen [data-role="new-question"] { position: relative; overflow: visible; }
.screen [data-role="new-question"]::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  top: -2px;      /* 40 + 2×2 = 44 */
  bottom: -2px;
}

/* ── ③ 分段选择（判断题的「正确 / 错误」用的那个零件）───────────────
 * 整排从稿子 2-1677 借来（`.n-14-5`，`height: var(--sz-tap-min)`，天生够 44）。
 * 这儿补三件稿子没有的：
 *   · 整排**撑满**：`.admin-field` 是竖排，`align-self: stretch` 才会拉满宽度；
 *     不写的话三个字宽的排会缩成一小坨贴在左边。
 *   · 没选中那几段的**悬停/按下**。选中那段**不给** —— 它已经是"卡片浮起来"
 *     那个样子了，再染一层浅紫会被读成"取消了选中"，正好是反的。
 *   · 每一段的**命中区**（H1，见下）。
 */
.admin-field--seg > .admin-seg { align-self: stretch; }
.admin-seg [role="radio"][aria-checked="false"]:hover { background-color: var(--cl-primary-50); }
.admin-seg [role="radio"][aria-checked="false"][data-press="down"] { background-color: var(--cl-primary-100); }

/* 每一段的命中区撑到 44 —— 稿子画的那一段是 `height: 36px`（`.n-14-5__2-350`），
 * 36 < 44，手指点它是"瞄着点"。⚠ 这跟上面那排 40px 的帧按钮**不是一回事**，
 * 别拿同一个理由一起放过：
 *   · 帧按钮那 40 是**稿子画在整块画布上的既有版式**，全站每一屏都一样，
 *     要动就得整站一起动（另立一轮）；
 *   · 这一段是**这一轮新引进后台的控件**，新控件要达标是我的事。
 *   · 而且这里有**零代价**的做法：整排是 44 高、上下各留了 `sp-1`（4px）内边距
 *     —— 往上下各撑 4px 正好补满 44，**一个像素的绘制都不变**。
 *
 * 做法还是那套（跟 ⑤ Icon/Close、跟 1161 行同一套）：纯伪元素。
 * ⚠ 两个前提缺一不可，缺了就是**看着加了、其实没加**：
 *   ① 那一段自带 `overflow: hidden`（稿子导出的每个节点都带）—— 不关掉的话
 *      伪元素伸出去的那 4px 会被**自己**裁掉；
 *   ② 父级那一排（`.n-14-5`）也有 `overflow: hidden` —— 但它裁的是**内边距盒**
 *      （44 高），所以 36 + 4 + 4 = 44 正好齐平，不会挨它裁。 */
.admin-field--seg [role="radio"] { position: relative; overflow: visible; }
.admin-field--seg [role="radio"]::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  top: -4px;      /* 36 + 4×2 = 44，正好补满那一排的内边距 */
  bottom: -4px;
}

/* ── ④ 「保存后接着录下一题」那行（控制器造的节点，H7 的连录路径）──────
 * 它不是稿子画的 —— 连录这件事稿子上根本没这个场景。所以照 HMI 的规格自己摆：
 * 复选框和字排一行、点击区整行可点（用 `<label>` 包，点字也能勾上）、
 * 高度给到 44（H1）。字用 `--fs-caption`：它是这一屏的**次要说明**，
 * 不该比字段名还响。 */
.admin-keepon {
  display: flex;
  flex-direction: row;
  align-items: center;
  gap: var(--sp-2);
  min-height: var(--sz-tap-min);
  align-self: stretch;
  color: var(--cl-text-secondary);
  font-size: var(--fs-caption);
  line-height: var(--lh-caption);
  cursor: pointer;
}
.admin-keepon input { width: 18px; height: 18px; flex-shrink: 0; }

/* ── ⑤ 字段级报错（H7）：红描边框 + 框下方一行红字 ────────────────────
 * 描边的**取值**照抄稿子 11-25 的 `Component/Input/Error`
 * （`web/assets/pages/11-25.css:18`：`inset 0 0 0 2px var(--cl-danger-600)`）——
 * 不自己挑一个红。这一条挂在**字段那一行**（`.admin-field.is-bad`）上，
 * 因为框本身是稿子的节点、类名会变；行是我们自己的。
 * ⚠ 三种控件都要盖到：单行/多行（`Component/Input/Default`）、下拉（`Select`）、
 *   分段（在 `.admin-seg` 里那排），少一个就是"这一格报错没标出来"。 */
.admin-field.is-bad > [data-name^="Component/Input/Default"],
.admin-field.is-bad > [data-name^="Component/Select/Default"],
.admin-field.is-bad > .admin-seg > [data-name="Component/Segmented/Default"] {
  box-shadow: inset 0 0 0 2px var(--cl-danger-600);
}

/* 错误行自己那行字。★ 正常情况下这枚节点的样式**是稿子给的**（从 11-25 借的
   「错误提示」元件，`--cl-danger-600` + `--fs-caption`，字号行高全对）。
   这一条是**兜底**：借件那一步失败时（元件被改名/那一帧没加载出来），
   `admin-dialog.js:926` 会 warn 一声、然后退回这儿的样式 ——
   报错**仍然看得见**，只是没那么"像稿子"。**不给它加 `display:none` 之类的
   隐藏**：报错是必须看见的东西，借件失败不该把它一起弄没。 */
.admin-field-err {
  align-self: stretch;
  color: var(--cl-danger-600);
  font-size: var(--fs-caption);
  line-height: var(--lh-caption);
}

/* ── ⑥ 借来的关闭/删除图标，在**分栏宿主**里也要有 44 的命中区 ─────────
 * 1161 行那条只写了 `.dialog-gaozi`（底部面板那个形态），而分栏右栏给的宿主
 * 类名是 `.dialog-inline`（`admin-dialog.js:853` 二选一），于是右栏里的关闭图标
 * 只剩稿子画的 24×24 —— 手指点它是"瞄着点"。做法跟 1161 那条**一字不差**
 * （纯伪元素、不占空间、不改绘制位置），只是把宿主补上，不另写一套。 */
.dialog-inline [data-name="Icon/Close"] { position: relative; cursor: pointer; }
.dialog-inline [data-name="Icon/Close"]::after {
  content: "";
  position: absolute;
  inset: -10px;   /* 24 + 10×2 = 44 */
}

/* ── ⑦ 导入卡那行按钮：加了第三枚就得能让它换行（并放开裁切）──────────
 * 「+ 新建题目」是这一轮新加的（从这行左起第一枚「下载模板」克隆，插在**最后**）。
 * 稿子那行是 `flex-direction: row` **不换行**、三枚各 100 宽 + 两个 sp-3 间距
 * = **324**；实测可用宽度：393 屏 → 329（放得下，一行三枚），360 → 296，320 → 256。
 * ⚠ 第一版这里写的是 336、「三个宽度全塞不下」—— **算错了**：`--sp-5` 是 16+16、
 *   内容盒 68 不是 60（`9-187.css:57`）。393 上其实一行放得下，**真出问题的是
 *   360 / 320**。算错的那版写进了设计文档，也一并改过。
 * 不换行的话第三枚被 `overflow:hidden` 直接切掉 —— 切出来是半个按钮，
 * 看着像画错了，点还点得着，最难受。
 * ⇒ 让这行换行。卡本身是竖排、`flex-shrink: 0`、没有钉死高度，所以换行之后
 *   卡片自己会长高，不会被裁。
 * ⚠ `overflow: visible` 是给上面 ②bis 那条命中区让路的：这行**天生只有 40 高、
 *   内边距 0**，按钮的 `::after` 往上下伸的 2px 正好落在行盒子外面 —— 行的
 *   `overflow:hidden` 会把它裁掉，量出来还是 41（真红过一次）。行盒子放开之后
 *   那 2px 落在卡片里（卡片四周有内边距），不挨裁。
 *   ※ 放开之后横着也不会漏：换行已经开了，探针在 393/360/320 都判
 *     `rowScrollOverflow` 与 `outOfCard`，漏一个就红。
 * ⚠ 只认**这一张卡**里的按钮行：`按钮行` 这个图层名在题库头卡
 *   （`.n-9-194`）上还有一个，不 SCOPE 的话会把「编辑题库 / 删除」那行一起改。 */
.screen [data-name="导入试题卡"] > [data-name="按钮行"] { flex-wrap: wrap; overflow: visible; }


/* ═══════════════════════════════════════════════════════════════════════════
 * ㊼ 「点一下选项，答案就改成它」 —— 2026-09-30（机器主人当场提的那件事）
 *
 * 原话：「正确答案现在是手输的状态，我希望是直接选……我直接编辑，然后直接点
 * 一下选项 C，那答案就改成 C 了」。同一件事还管着**查看模式**：那屏的选项
 * 也要把正确答案标出来（不然「哪一项是对的」得拿字母去对）。
 *
 * 两处用的都是 2-1534 上那两枚现成的件 —— 圆的 = 选一个、方的 = 选几个，
 * 跟设计系统里「选择」这件事本来就长的一样，没有新发明一种选中态。
 * 尺寸是稿子画的 22×22（`.n-2-1543` 等），**视觉一个像素不动**。
 *
 * ── H1（命中区 ≥44）在这儿是怎么达标的 ──────────────────────────────
 * ⚠ **没给那枚 22×22 的控件加伪元素**，故意的：
 *   ① 可点的**不是那枚控件，是整行**——控件在这一行里的身份是**记号**
 *      （「这一项是答案」），不是按钮；点哪儿都算。
 *   ② 那枚控件在帧里的用法本来就是「一行一行的行内控件」（章节项那排），
 *      给它套一个 44 的伪元素会往上下各伸 11px —— 而选项行是**竖着挨着排**的，
 *      伸出去就把上下两行的命中区吃掉了（4730 行记的正是这个坑）。
 *   ⇒ 于是把**行**撑到 44（`.admin-optrow--pick` 那条，量到 24 时真红过一次）。
 *   （验证见 tools/build/probe-admin-answer-pick.py 的 ④ 那组：量的是**行**。）
 *
 * ── 四态（H5）────────────────────────────────────────────────────────
 * 悬停/按下的底色**照抄本仓既有的「可点行」口径**（2665 行那条：
 * 悬停用品牌浅紫 `--cl-primary-50`，灰 `--cl-bg-sunken` 在这个系统里是
 * 「不可用」的语义，拿它当悬停会读成「这一行点不动」）。
 * 键盘焦点那一条**不在这儿写**：`.admin-tap:focus-visible`（3283 行）
 * 已经统一给了，控件过的是 `wireTap`，白捡。 */

/* 整行可点（`admin-dialog.js` 的 `optRow` 挂的 `.admin-optrow--pick`）。
 *
 * ⚠ 这里**必须**把行撑到 44，不能靠里面那个输入框：选项行里那枚输入是
 *   `swapPlaceholder` 换出来的**裸 input**（它把字写在「占位」那枚节点上，
 *   而占位节点在稿子上只有一行文字那么高），实测整行只有 **24** ——
 *   24 < 44，手指点它是「瞄着点」（探针里那一格就是量这个，量到 24 时真红过）。
 *   注意这跟别的字段不一样：`type: "text"` 那些格子外面还套着稿子那枚 48 高的
 *   输入框，所以它们本来就有 48。选项行没套（它自己就是一行）。
 * ⚠ 撑的是**整行**，不是那枚 22 的记号：记号在这一行里的身份是「哪几项是答案」，
 *   可点的是行（见文件头那条）。而且选项行是**竖着挨着排**的，给记号加伪元素会
 *   往上下各伸 11px、把上下两行的命中区吃掉。
 * ⚠ 上下内边距 8 是给悬停底色留出「这一行」的样子（不然底色紧贴着字）。
 *   24 + 8 + 8 = 40 还差 4，由 `min-height` 补到 44（多出来的 4px 被
 *   `align-items: center` 居中吸收，字的行盒一点不动）。 */
.admin-optrow--pick {
  cursor: pointer;
  min-height: var(--sz-tap-min);
  padding: var(--sp-2) 0;
}
.admin-optrow--pick:hover { background-color: var(--cl-primary-50); }
.admin-optrow--pick[data-press="down"] { background-color: var(--cl-primary-100); }

/* 控件本身：只保证它不被输入框挤瘦（跟徽标、删除图标同一个待遇）。 */
.admin-pick { flex-shrink: 0; display: flex; align-items: center; }

/* 查看模式的那一列（`.admin-pick--ro`）：同一样子，但**不可点**。
   ⚠ 在这儿明说一句 `cursor: default`：它长得跟编辑模式那枚一模一样
     （那是故意的，两种模式看到的得是同一件东西），不点破的话用户会去点它。 */
.admin-pick--ro { cursor: default; }

/* ── 查看模式的选项行 ─────────────────────────────────────────────
 * 跟编辑模式那张纸是同一副骨架（`.admin-optrow`），只是把输入框换成了
 * 一行只读文字 —— 所以这里只补「那行字」要的两条。 */
.admin-optview { align-self: stretch; }
/* ⚠ `flex-shrink: 1` 必须 !important（简写那行也要留）——本仓库踩过三次的老坑
   （见 ⑮ `.quiz-bankname` / ⑲ `.slot-1line`）：稿子给这枚「占位」写的是
   `flex-shrink: 0`，而稿子 CSS 是运行时后加载的，跟这里**同特异性**（都是单个类），
   于是它在收缩这一项上赢。后果不是「缩一点」，是**一点都不缩**：
   实测 1280 宽的分栏右栏里这一格 240px 死活不动，22(点) + 8 + 23(徽标) + 8 + 240 = 301，
   而给它的只有 256 —— 多出来的 45px 把整栏顶到 scrollWidth 416 / clientWidth 336，
   选项文字被右边缘切掉（下面那句 `white-space: normal` 因此根本没机会折行）。
   ⚠ 简写那行留着有用：grow / basis 稿子没写，是这里给的。 */
.admin-optview-text {
  flex: 1 1 auto;
  flex-shrink: 1 !important;
  min-width: 0;
  color: var(--cl-text-primary);
  /* 长选项要能折行（稿子那枚「占位」是单行输入框的占位，自带 nowrap 之类） */
  white-space: normal;
}

/* ── 「原来的答案对不上选项」那一句（`.admin-pick-note`）───────────────
 * 什么时候出现：库里那条 answer 的字母在选项里找不到（比如答案写着 E、
 * 而这道题只有三个选项；或者老数据写的是「对」「正确」这种非字母值）。
 * 这时**一个控件都不该被点亮**，但也不能就这么摆着 —— 用户看到的是
 * 「正确答案：E」配着四个都没选中的控件，他会以为界面坏了、或者以为这题没答案。
 * 所以说清楚：原来写的是什么 + 为什么对不上 + 现在该怎么办。
 *
 * ⚠ 它不是**报错**（数据是那样存的，不是用户刚犯的错），所以不穿那身红
 *   （红是 `.admin-field.is-bad` 的语义，混用会让「真错」和「本来就是这么存的」
 *   看起来一样）。走「提示」那一路：灰底 + 左侧一条警示色竖线。
 * ⚠ 字色用 `--cl-text-secondary` 而**不是** `--cl-warning-600`：
 *   亮色主题下预警色只有 600 那一档（#F5A524），它当**底色/线**可以，
 *   当**字**对白底只有 1.9:1 —— H2 要求 4.5:1，那样写等于写了看不见的字。
 *   （竖线不承载信息，信息全在文字里，所以 H3「不许只靠颜色表意」也成立。） */
.admin-pick-note {
  align-self: stretch;
  padding: var(--sp-2) var(--sp-3);
  background: var(--cl-bg-sunken);
  border-left: 3px solid var(--cl-warning-600);
  border-radius: var(--rd-sm);
  color: var(--cl-text-secondary);
  font-size: var(--fs-caption);
  line-height: var(--lh-caption);
}

/* ── 答案那一格在选择题下的样子（`.admin-ro-input`）────────────────────
 * 选择题的答案**不许手打**（手打正是这一轮要治的那件事）：字母由上面点选项
 * 推出来，这一格是**只读**的。
 *
 * ⚠ 用的是 `readOnly`，**不是 `disabled`**，两件事必须分清：
 *   · `disabled` 在真机上点不动、读屏也念不出来（用户根本不知道有这一格），
 *     而且浏览器会给它套一层灰字 —— 看着像「坏了」。HIG 里只读和禁用是两个状态。
 *   · `readOnly` 能聚焦、能被读屏念出来、值照样提交，只是打不进字。
 * ⚠ 底色给 `--cl-bg-sunken`（灰）：灰在这个系统里是「不可用」的语义 ——
 *   用在这儿**正是要说的那句话**「这一格不用你填」。字色不动（正文色），
 *   所以对比度不比普通输入框差。
 * ⚠ 光标的形状也得跟上：能打字的框给 `text`、只读的给 `default`，
 *   不然鼠标一进去就变成 I 型光标，那是在承诺一件做不到的事。 */
.admin-ro-input {
  background-color: var(--cl-bg-sunken);
  cursor: default;
}

/* ══════════════════════════════════════════════════════════════════════
 * ㊽ 题库详情：章节卡那一列**点得动**了（2026-09-30，机器主人当场提的那件事）
 *    「我如果是单独去点……一个章节去查看的话，现在我没有办法查看」
 *
 * 一行 = 一个筛选按钮（JS 挂 `.admin-chaprow` + `wireTap`，见
 * `admin-bank-detail.js` 的 wireChapRow）。稿子那八行原来是**只读**的
 * 「名称 · 值」两格（`.n-9-201`：36 高、没有底色、没有光标），
 * 所以这一节补的是「它现在能点」这件事该有的三样东西：
 * 命中区、四态、选中态。
 *
 * ⚠ 命中区（H1）：稿子那行只有 **36** 高（`.n-9-201{height:36px}`），
 *   不到 44。手指点它是在瞄着点 —— 所以给整行 `min-height: var(--sz-tap-min)`。
 *   行**本身**就是可点区域（不是里面那两格字），量的是这行的盒子。
 *   `height:36px` 是帧 CSS 写的固定高，这里必须**显式改成 auto**：
 *   只写 min-height 的话，帧那条 `height:36px` 与新写的一条特异性相同、
 *   谁在后面谁赢 —— 而且固定高配 min-height 在内容变高时会裁字。
 * ⚠ 四态（H5）：静息没有底色（帧里就没有）、悬停浅一层、按下再深一层、
 *   键盘焦点走 `.admin-tap:focus-visible`（3273 行那一份，不在这儿再写一条）。
 * ⚠ 选中态（H3）：**不许只靠颜色**。选中有两样东西同时变 ——
 *   底色 + 左边一条 3px 竖条（形状也变了），跟分栏里选中的题目行同一个做法。
 *   颜色语义：`--cl-primary-50` 浅紫 = 「这就是当前范围」（品牌色在这里
 *   不是「可点」，是「选中」）—— 跟 `.admin-split-*` 那两条完全一致。
 * ⚠ 不给这行加 `::after` 之外的伪元素做「手型」以外的暗示：它长得还是
 *   稿子那一行（名称左、值右），只在指上去和选中时才变 ——
 *   静态时不能变成按钮的样子，否则八个章节看起来就是八个按钮，很吵。
 * ══════════════════════════════════════════════════════════════════════ */
.admin-chaprow {
  height: auto;
  min-height: var(--sz-tap-min);
  padding: var(--sp-1) 0;
  position: relative;
  cursor: pointer;
  border-radius: var(--rd-sm);
}
.admin-chaprow:hover {
  background-color: var(--cl-primary-50);
}
.admin-chaprow[data-press="down"] {
  background-color: var(--cl-primary-100);
}
.admin-chaprow.is-selected {
  background-color: var(--cl-primary-50);
}
.admin-chaprow.is-selected::after {
  content: "";
  position: absolute;
  left: 0;
  top: 0;
  bottom: 0;
  width: 3px;
  background: var(--cl-primary-500);
}

/* ══════════════════════════════════════════════════════════════════
 * ㊾ 后台版式统一层（2026-09-30 第三十二轮）
 *
 * 机器主人这一轮的原话：
 *   「后台UI换新设计。遵照设计规范，因为现在后台管理确实太冗杂了。要避免那种
 *     内嵌滚动条，两个滚动条、三个滚动条、表格嵌表格似的，很丑。要保证标题、
 *     信息层级、列表的层级关系，不重要的东西就把它要么最小化，要么图标化。」
 *
 * 这一节只放**跨屏通用**的那几件（分栏右栏的只读详情、列表行的动作收口、
 * 按钮主次）。某一屏自己的列宽 / 门槛归那一屏自己的小节（激活码是 ㊵）。
 *
 * ── 先说清楚「内嵌滚动条」那件事量出来是什么（别照感觉改）────────
 *   13 张后台屏 × 1280 逐个查「在滚的框，外面还套着几个在滚的祖先」：
 *   **嵌套 0 个**（`$CLAUDE_JOB_DIR/tmp/nest.py` 的读数）。
 *   所以机器主人看到的「两个滚动条」不是套娃，是**分栏**：左栏在滚、右栏在滚，
 *   两条滚动条并排站着，而且左栏那张表自己还要横向挤 —— 看着就像表格嵌表格。
 *   真正要治的是**左栏为什么要挤**，不是把滚动条藏起来。
 * ══════════════════════════════════════════════════════════════════ */

/* ── ㊾-1 分栏右栏的只读详情：从「一排灰盒子」改成「标签 · 值」────────
 *
 * 改之前右栏长这样（`$CLAUDE_JOB_DIR/tmp/probe-detail.py` 量出来的真实 DOM，
 * 1440 的激活码详情）：
 *
 *   .admin-field.admin-field--readonly   472×76   flex column / gap 8
 *     ├─ [data-name="类型提示"]          472×20   13px / #5B5A66   ← 标签
 *     └─ [data-name="Component/Input/Default"]  472×48  **bg #F3F3F7**  ← 值
 *
 * 十一条事实 = 十一个「标签一行 + 灰底盒子一行」往下摞。灰底盒子是**输入框的
 * 长相**（`admin-dialog.js` 的只读分支就是 clone 了稿子那枚单行输入框、只是不放
 * input）—— 它在一枚弹窗里当「填好的表单」是对的，搬到一栏几百像素宽的详情栏里
 * 就成了十来个一模一样的灰条：**每一格的视觉重量一样重，谁也压不住谁**，
 * 分不出哪条是这条记录的主语。这正是机器主人说的「信息层级」那件事。
 *
 * 改成**定义列表**（用户管理那屏的详情一直是这个长相，机器主人认它）：
 *   标签在左固定 88 宽、值在右占满，行与行之间一条发丝线。
 *   灰底盒子整个拿掉 —— 值本来就是字，不需要框。
 *
 * ⚠ 只作用在 `.admin-split-detail` 里（分栏的右栏）。**弹窗里那套一个字不动**：
 *   弹窗是两列网格、每格 ~250 宽，那时「框」是有用的（它划出每一格的边界）。
 *   同一份 DOM 在两种宿主里长得不一样，是有意的。
 * ⚠ 只读那一行现在多了一个 `data-k`（字段的 key，`admin-dialog.js` 的 readonly
 *   分支挂的）。这一节没用到它，但下面「激活码」那一格想要等宽字体的将来要用，
 *   顺手记在这儿免得有人再去找。 */
/* 详情栏里「标签列」的宽度：**一处定义、两处用**。
 *
 * 两个消费者：只读行（㊾-1，`admin-field--readonly`）和网格行（㊾-3，`admin-grid__k`）。
 * 它们本来各写各的（88 / 112），于是同一个右栏里上下两节的**值的左边缘差 8px**：
 * 「状态」「用户反馈原文」这些行的值起于 918，而紧跟着的「题干」「解析」起于 910 ——
 * 一栏里两个起点，读的人会以为这是两栏东西（纠错管理那一屏两节挨着，最明显）。
 *
 * 宽度取 112 而不是 88：88 ≈ 6.7 个字，而稿子里最长的标签是资料管理的
 * 「用户在 App 里下载」（8 个字）—— 88 下正好折成两行，标签一折，
 * 右边那列值的左边缘就跟着参差。112 ≈ 8 个字 × 13px ＋ 一点余量。
 *
 * ⚠ 这是**详情栏**的宽度，不是全站的：弹窗里那套（三列网格、每格标签在上）
 *   一个字不动（同 ㊾-1／㊾-3 的宿主限定）。
 * ⚠ 别再写死数字：下一个要定宽的地方引这个变量，否则又会长出第三个起点。 */
.admin-split-detail {
  --detail-kw: 112px;
}
.admin-split-detail .admin-field--readonly {
  /* 竖排 → 横排：值挪到标签右边 */
  flex-direction: row;
  align-items: baseline;
  gap: var(--sp-4);
  padding: var(--sp-3) 0;
}
/* 分隔线画在「两个只读行之间」：给后一个加 `border-top`，于是
   第一行头上没有线、最后一行底下也没有线 —— 两头都不封口。
   ⚠ 别用 `:last-of-type` / `:first-of-type`：这一排字段全是 `div`，而它们的
     兄弟里还有标题行和底部按钮行 —— `:last-of-type` 命中的是**那一行的最后一个
     div**（多半是页脚），不是最后一条只读行。实测过：写了 `:last-of-type`
     之后最后一行底下照样有条线（因为它压根没匹配上，规则静默不生效）。 */
.admin-split-detail .admin-field--readonly + .admin-field--readonly {
  border-top: 1px solid var(--cl-border-light);
}
/* 标签那一格：定宽（`--detail-kw`，在上面定义，跟网格行同一根尺子），
   **不许缩** —— 缩了就变成「有的行标签两行」。
   ⚠ 用 `flex: 0 0 …` 而不是 width：它是个 flex 项，写 width 仍会被压缩。 */
.admin-split-detail .admin-field--readonly > [data-name="类型提示"] {
  flex: 0 0 var(--detail-kw);
  min-width: 0;
}
/* 值那一格：把「灰底输入框」的壳脱掉。
   ⚠ 这一条跟 app.css ㉑ 那条（`.admin-field--readonly > [data-name=…] {height:auto;
     min-height:48px}`）**打架**，靠特异性赢（这里是三层：宿主 + 类 + 属性）。
     不写 `!important` 是因为不需要 —— 稿子那条 `.n-8-1095` 只有一层。
   ⚠ `display: block` 而不是继续 flex：里面那枚 `.admin-ro` 写的是 `flex: 1 1 auto`
     （㉑ 给折行用的），父级一旦不是 flex 它就不参与 flex，值直接按块级排版折行，
     正是想要的。 */
.admin-split-detail .admin-field--readonly > [data-name="Component/Input/Default"] {
  display: block;
  flex: 1 1 auto;
  min-width: 0;
  height: auto;
  min-height: 0;
  padding: 0;
  /* 稿子那枚「单行输入框」的壳一共三件：底色、描边（有的是 border、有的是
     inset 阴影 —— 稿子里两种写法都有）、圆角。三件都要脱干净，
     漏一件屏幕上就剩一圈极淡的方框（截图上看得很清楚：灰底没了，框还在）。 */
  background: none;
  border: 0;
  box-shadow: none;
  border-radius: 0;
}
/* 值折行时不要吊在标签底下 —— `align-items: baseline` 已经对齐了第一行基线，
   这里只保证长值（设备 ID、备注）换行之后不越到标签下面去。 */
.admin-split-detail .admin-field--readonly .admin-ro {
  flex: none;
  white-space: normal;
  word-break: break-all;
}

/* 底部那枚「关闭」：弹窗里它撑满整行是对的（弹窗的两种结局等宽并列），
   右栏里就成了**一整条淡紫色的长条**压在详情下面 —— 一个常驻的面板，
   它的收尾动作不该比内容还显眼。改成右对齐的一枚小按钮。 */
.admin-split-detail .admin-form-actions {
  justify-content: flex-end;
}
.admin-split-detail .admin-form-actions > * {
  flex: 0 0 auto;
  min-width: 96px;
}

/* ── ㊾-2 行内「更多」菜单（`app/admin-menu.js`）──────────────────
 *
 * 为什么要有：见那个文件头的算式 —— 五个动作要 255px 列宽，而分栏左栏给不出，
 * 于是每一行的按钮都换行成两行、行高 121（表头才 44）。
 *
 * ⚠ 菜单是 `position: fixed` 挂 body 的（不进这一节的任何布局），
 *   所以这里的定位只写「长什么样」，位置一律由 JS 按触发按钮的矩形算。
 * ⚠ `z-index` 取 9990：比调试条（`.devbar`，9998）低 —— 调试条是给人看日志的，
 *   压住它没好处；比普通内容高就行。 */
.admin-menu {
  position: fixed;
  z-index: 9990;
  display: flex;
  flex-direction: column;
  gap: 2px;
  min-width: 148px;
  padding: var(--sp-1);
  background: var(--cl-bg-card);
  border-radius: var(--rd-md);
  /* 一层描边 + 一层投影：只给投影的话在浅色背景上边界糊；只给描边又不像浮层。 */
  box-shadow: 0 0 0 1px var(--cl-border-base), 0 8px 24px rgba(28, 27, 34, .14);
}
.admin-menu__item {
  display: flex;
  align-items: center;
  width: 100%;
  /* 桌面行高下限：HIG 那张表里「列表行 ≥32」——菜单项 36 比它宽裕一点，
     因为这里的目标是**单手在小屏上也能点**（手机档的菜单就是这个尺寸）。 */
  min-height: 36px;
  padding: 0 var(--sp-3);
  border: 0;
  border-radius: var(--rd-sm);
  background: none;
  color: var(--cl-text-primary);
  font-family: inherit;
  font-size: var(--fs-body);
  line-height: var(--lh-body);
  text-align: left;
  white-space: nowrap;
  cursor: pointer;
}
.admin-menu__item:hover {
  background: var(--cl-bg-sunken);
}
/* 键盘走到哪一格，眼睛要跟得上 —— 全站其它地方用的是同一对手感（app.css ㊴）。 */
.admin-menu__item:focus-visible {
  outline: 2px solid var(--cl-primary-500);
  outline-offset: -2px;
  background: var(--cl-bg-sunken);
}
.admin-menu__item--danger {
  color: var(--cl-danger-700);
}
.admin-menu__item--danger:hover {
  background: var(--cl-danger-50);
}
.admin-menu__sep {
  height: 1px;
  margin: var(--sp-1) 0;
  background: var(--cl-border-light);
}

/* 「更多 ⋯」这枚按钮：文字 + 一枚省略号记号。
   ⚠ 省略号用 `aria-hidden` 的装饰节点（`app/admin-menu.js` 里挂的）——
     读屏念到的是「更多操作」（aria-label），不是「点点点」。 */
.admin-more {
  gap: 2px;
}
.admin-more-dot {
  color: var(--cl-text-tertiary);
  letter-spacing: 1px;
}

/* ── ㊾-3 分栏右栏里的「网格」（`type: "grid"`）也改成标签 · 值 ──────────
 *
 * ㊾-1 治的是 `admin-field--readonly` 那条路（用户管理/激活码的详情走它）；
 * 另一条路是 `admin-dialog.js` 的 `buildGrid`（`{type:"grid"}`），它渲染的是
 * `.admin-grid` —— 而后台壳里那条规则是
 *     `.admin-shell .admin-grid { grid-template-columns: repeat(3, 1fr) }`
 * 也就是**三列**：四个事实排成「文件名/大小/所属组织」一行、第四个换行。
 * 在弹窗里那是对的（弹窗宽、三列一眼扫完）；搬到分栏右栏里就成了机器主人说的
 * 「表格嵌表格似的」—— 三列并排的键值对，读起来要来回跳，而且每一格的标签和值
 * 上下叠着、看不出这行属于哪个标签。
 *
 * 改成**一行一条**：标签左定宽 88、值右占满，行间一条发丝线（跟 ㊾-1 同一套）。
 * ⚠ 只作用在 `.admin-split-detail` 里 —— **弹窗里的三列一个字不动**。
 *   同一份 DOM 在两种宿主里长得不一样，跟 ㊾-1 是同一条道理。 */
.admin-split-detail .admin-grid {
  grid-template-columns: 1fr;
  gap: 0;
}
.admin-split-detail .admin-grid__cell {
  display: flex;
  flex-direction: row;
  align-items: baseline;
  gap: var(--sp-4);
  padding: var(--sp-3) 0;
}
/* 分隔线同 ㊾-1：给后一个加，两头都不封口。`+` 相邻选择器而不是 `last-of-type`
   —— 这一排 cell 的兄弟里还有段标题（`.admin-sec-row`），`last-of-type` 会数错。 */
.admin-split-detail .admin-grid__cell + .admin-grid__cell {
  border-top: 1px solid var(--cl-border-light);
}
/* 标签列：跟只读行（㊾-1）共用 `--detail-kw`，理由与那个宽度怎么来的都写在
   上面那一节的定义处 —— 一句话是「同一栏里的值必须起于同一个地方」。 */
.admin-split-detail .admin-grid__k {
  flex: 0 0 var(--detail-kw);
  min-width: 0;
}
.admin-split-detail .admin-grid__v {
  flex: 1 1 auto;
  min-width: 0;
}

/* ── ㊾-4 资料管理（`10-127`）：左栏的工具栏与行 ──────────────────────
 *
 * 这一屏的量出来的毛病（1440，`$CLAUDE_JOB_DIR/tmp/dump-res.py` 的节点树）：
 *   · 左栏 320 宽（骨架兜底值），而这一行要塞「完整文件名 + 徽标 + 下载 + 更多」
 *     ⇒ 标题只剩 80px，文件名一律「内部讲义（未…」；
 *   · 一行卡片 171 高，里头写着**同一句话两遍**：标题「内部讲义（未审核）」
 *     + 次行「内部讲义（未审核）.pdf 1.0 MB」；
 *   · 筛选行（44）+ 整栏宽的「+ 上传资料」（44）上下两行占掉 88px，
 *     而左栏总共 823px；
 *   · 「隐藏」那枚徽标底色是 `--cl-bg-sunken`（#F3F3F7）—— 在白卡上跟没有一样，
 *     而「可见」是 success-50。同一种东西两种长相，还是被压住的那种。
 *
 * 左边宽 / 行里的 DOM 归 `app/routes/admin-resources.js`（搬节点、收字），
 * 这里管**长相**。 */
body[data-ardot="10:127"] .admin-shell-main .admin-split-left [data-name="筛选行"] {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
}
body[data-ardot="10:127"] .admin-shell-main .admin-split-left
  [data-name="筛选行"] > [data-name="Component/Select/Default"] {
  flex: 1 1 auto;
  min-width: 0;
}
/* 上传按钮不再撑满整栏：原来那一枚整栏宽的实心紫块，比列表里任何一样都抢眼，
   而它是这一屏最不常用的动作。搬到筛选行里之后按内容宽。
   ⚠ `width: auto` + `align-self: stretch` 不够 —— 稿子给它的是一条 `width: 100%`
   （或 flex 撑开），得显式覆盖。这里靠特异性赢，不用 `!important`。 */
body[data-ardot="10:127"] .admin-shell-main .admin-split-left
  [data-name="筛选行"] > [data-name="Component/Button/Primary/Default"] {
  flex: 0 0 auto;
  width: auto;
  align-self: center;
}
/* 标题那一行现在装着四样：标题（占满剩下的）· 徽标 · 下载 · 更多。
   ⚠ 这一行原来是给「标题 + 徽标」两样排的，标题写死了宽度（242/288），
     直接塞按钮会把徽标挤出格子外 —— 所以标题必须改成「按剩余空间走」。 */
body[data-ardot="10:127"] .admin-shell-main .admin-split-left .admin-row [data-name="顶行"] {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
}
body[data-ardot="10:127"] .admin-shell-main .admin-split-left .admin-row [data-name="顶行"] > [data-name="标题"] {
  flex: 1 1 auto;
  min-width: 0;
  width: auto;
}
/* 徽标：**别往它身上写尺寸和颜色**。量出来 38×22 不是谁钉了死宽，是算出来的：
   里层「文」那个节点自己写着 `font-size: var(--fs-micro)`（=11px），父节点改
   `font-size` 压不进去，于是 2 个字 22px ＋ 左右各 8px 内边距 = 38；
   两个状态稿子也早画好了（可见 = `--cl-success-50` 绿胶囊、
   隐藏 = `--cl-bg-sunken` 灰胶囊，都带 `--rd-pill`）。
   这里只补稿子没管的一件事：灰胶囊压在白卡片上几乎看不见，描一道 1px 内边线。
   ⚠ 状态靠卡号类认（`.n-10-138` 是「隐藏」那张），跟 `variants` 那张表同一个口径。 */
body[data-ardot="10:127"] .admin-shell-main .admin-split-left
  .admin-row.n-10-138 [data-name="Component/Badge/Success"] {
  flex: 0 0 auto;
  box-shadow: inset 0 0 0 1px var(--cl-border-base);
}
/* 动作那一格：搬进「顶行」之后跟徽标并排，**不许换行**（换行了行高又参差）。 */
body[data-ardot="10:127"] .admin-shell-main .admin-split-left .admin-row [data-name="顶行"] > [data-name="操作"] {
  flex: 0 0 auto;
  flex-wrap: nowrap;
  width: auto;
}
/* 底下那句元信息：一行说完（大小 · 组织 · 上传于 X）。长了收省略号 ——
   行高一致比把话说完重要（全文在右栏的详情里）。 */
body[data-ardot="10:127"] .admin-shell-main .admin-split-left .admin-row [data-name="描述"] {
  flex: 0 0 auto;
  width: 100%;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* ── ㊾-5 纠错管理（`10:116`）：左栏只留一行反馈，题面归右栏 ────────────
 *
 * 这一屏的左栏原来把**整道题**画在卡上：题干 + 四个选项 + 正确答案 + 解析。
 * 那是**当年故意**的 —— 那时候这一屏根本没有「详情」这回事，题面只能在卡上，
 * 运营才判断得出该不该改（见 admin-feedbacks.js 里 fillFeedbackText 上面那段）。
 *
 * 但 2026-09-27 这一屏接上了分栏、右栏就是一份「看得清的详情」，那三段
 * **右栏里一模一样地又写了一遍**（818 宽，题面在那儿才读得动）。
 * 于是同一道题在一屏里出现两遍 —— 正是机器主人这一轮说的「冗杂」。
 *
 * 量出来的账：卡高 447 / 485 / 419 / 724（解析长的那张 724），一列四种高度，
 * 4 张就 2111px，在 844 高的栏里要滚两屏半；其中三段题面占了 300～450。
 *
 * 桌面这一档：题面归右栏，左栏只留
 *   状态 · 题型 ······ 时间 ／ 反馈原文（一行）／ 题库 · 章节（一行）／ 那一排动作。
 *
 * ⚠ **只在 `.admin-split-left` 里藏**：手机档没有右栏，题面在那儿的卡上是
 *   唯一去处，那段 `enrichRows` 预取也不能白做（手机上照样填、照样显示）。
 * ⚠ 这三段是**连节点一起藏**（不是清空内容）：`enrichRows` 是**先渲染后回填**的，
 *   清空的话会先白闪一下再填回来，藏起来才是「这一屏不用它」。
 */
body[data-ardot="10:116"] .admin-shell-main .admin-split-left [data-name="题干"],
body[data-ardot="10:116"] .admin-shell-main .admin-split-left [data-name="选项组"],
body[data-ardot="10:116"] .admin-shell-main .admin-split-left [data-name="答案解析区"] {
  display: none;
}
/* 反馈原文那一块：一行说完。全文在右栏「用户反馈原文」那一格里，这里只是引子 ——
   长了收省略号，**行高一致**比把话说完重要（同资料管理那条「描述」）。 */
body[data-ardot="10:116"] .admin-shell-main .admin-split-left [data-name="反馈原文区"] {
  display: block;
}
body[data-ardot="10:116"] .admin-shell-main .admin-split-left [data-name="反馈原文"] {
  display: block;
  width: 100%;
  min-width: 0;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
/* 「题库名 · 章节」同理：一行 */
body[data-ardot="10:116"] .admin-shell-main .admin-split-left [data-name="章节"] {
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
/* 头部行：状态徽标 · 题型徽标 ······ 时间。徽标和「时间」都不许换行 ——
   这一行折了，行高就又参差了（这一屏的老毛病就是「一列里四种高度」）。 */
body[data-ardot="10:116"] .admin-shell-main .admin-split-left [data-name="头部行"] {
  display: flex;
  flex-wrap: nowrap;
  align-items: center;
  gap: var(--sp-2);
}
body[data-ardot="10:116"] .admin-shell-main .admin-split-left
  [data-name="头部行"] > [data-name="Component/Badge/Success"],
body[data-ardot="10:116"] .admin-shell-main .admin-split-left
  [data-name="头部行"] > [data-name="Component/Badge/Primary"],
body[data-ardot="10:116"] .admin-shell-main .admin-split-left
  [data-name="头部行"] > [data-name="时间"] {
  flex: 0 0 auto;
}
/* ── ㊾-6 纠错管理：五枚动作并回一行（480 的卡里那 72px 的行）─────────
 *
 * 稿子把动作摆成了两行：`操作行 > 操作行1`（已解决 · 忽略 · 编辑题目）
 * ＋ `操作行2`（删除反馈 · 删除题目）。手机档这是对的 —— 那是一列 360 宽的卡片，
 * 五枚摆一行摆不下，分两行是**稿子专门这么画的**。
 *
 * 但桌面左栏是 520（卡片内容 488），五枚并排只要
 *   77 ＋ 62 ＋ 92 ＋ 92 ＋ 92 ＋ 4×8 = 447 —— **摆得下**。
 * 摆得下却分两行，代价是每张卡多 40px：卡高 218，4 张 872 ＋ 工具行 44 ＝ 916，
 * 而左栏只有 822 —— 于是左栏自己冒出一条**只能滚 130px** 的滚动条，
 * 滚到底第 4 张还是看不全。正是机器主人说的那种「莫名其妙的内嵌滚动条」。
 *
 * 并回一行之后卡高 178，4 张 712 ＋ 44 ＝ 756 < 822 —— **那条滚动条自己就没了**
 * （跟用户管理那一屏同一个道理：那屏是把栏放宽让它一行摆得下，这屏是样式把
 *  稿子的两行并成一行 —— **摆得下才并，摆不下让它自己换行**，别硬挤）。
 *
 * ⚠ 只改 `.admin-split-left`：手机档没有分栏，卡片还是稿子画的两行。
 * ⚠ `flex-wrap: wrap` 不能省：左栏最小能拖到 240（内容 208），那时 447 摆不下，
 *   得让它**体面地换回两行**，而不是把按钮压扁或者挤出横向滚动条。
 * ⚠ 稿子给 `操作行1` / `操作行2` 都写了 `flex-shrink: 0`，所以并排时它们
 *   保持各自的宽度、不会被压变形 —— 这一条不用我补。
 */
body[data-ardot="10:116"] .admin-shell-main .admin-split-left [data-name="操作行"] {
  flex-direction: row;
  flex-wrap: wrap;
  align-items: center;
}

/* ── ㊿-1 三张「列表屏」的宽屏行：一屏一行，横向排满 ──────────────────
 *
 * 对象：题库管理（`9:115`）· 组织管理（`9:156`）· 公告管理（`10:156`）。
 *
 * 这三屏没有分栏（没有详情要摆），一直是**手机那套卡片被拉到 1188 宽**：
 * 量到卡片 1188、顶行「标题 + 徽标」一行、次行「元数据 …… 操作」一行，
 * 行高 88～108，中间 870 宽全是「弹性」那格撑出来的空气。
 * 机器主人的原话是「整个后台的页面都要重新排」—— 这屏的钱花在
 * **把一屏一行的东西摆成一行**，而不是硬塞一栏详情进去：
 * 这三屏的一行就是「名字 + 两个事实 + 三枚动作」，够简单，
 * 给它们半屏宽的详情栏是浪费（列表要的是**一屏看得见多少条**）。
 *
 * 改法（三屏同一条，因为量过：**它们是同一副骨架**）：
 *   · 卡片从「竖排两行」改成「横排一行」——
 *     `[标题 + 徽标]  [事实 事实 …… 动作]`
 *   · 顶行 `flex: 0 1 auto`：它按内容宽坐稳在左端，太长才缩
 *     （`max-width` 兜一道，免得一个长题库名把右边的事实挤没）
 *   · 次行 `flex: 1 1 auto`：吃掉剩下的宽度，里面的「弹性」那格
 *     照旧把三枚动作顶到最右 —— 位置跟稿子里一样，只是整行矮了一截
 *
 * ⚠ **2026-10-02 第二次改：两块横排 → 一列一列。**（用户这一轮的
 *   「分栏不清晰」说的就是这件事，量的结果也对得上。）
 *
 *   旧写法的病在「顶行 `flex: 0 1 auto`」这一条上：它**按内容宽坐稳**，
 *   于是顶行的宽 = 名字的自然宽，**后面每一格的 x 都跟着名字长度滑**。
 *   1455×750 实测，一屏五条：
 *     题库管理  徽标 320/350/365/380/390 · 字段1 385/404/430/434/444
 *     组织管理  徽标 335/350/359/395/402 · 字段2 393/423/447/454/502
 *     公告管理  状态 395/410/425 · 类型 460/475/490 · 时间 510/535/550
 *   —— 每一列都排成一条**斜线**，从上往下扫的时候对不上。
 *   上面那条 42% 的封顶从头到尾**一次都没触发过**：三屏最长的名字
 *   「关于题库数据维护的通知」量到 165，42% 是 505 —— 永远够用，
 *   所以列的宽窄全由名字定，封顶形同虚设。
 *
 *   改法是照抄 ㊿-12（试题检索）—— **同一个骨架、同一副药**：
 *   · 行 `display: grid`，顶行/次行 `display: contents` 摊平，
 *     孙节点直接落进栅格，**每个格子写死列号**。（自动放置会在
 *     `setTextOrHide` 藏掉某格时把后面的格子往左挪，见 ㊿-12 那段。）
 *   · 名字那列 `minmax(200px, 1fr)`、中间留一格**空的** `minmax(0, 1fr)`
 *     当弹簧 —— 「弹性」那格摊平后会变成真的栅格项、抢走一整列，所以藏掉它，
 *     留白交给这格空轨道。
 *     ⚠ 这里**不能**用 `fit-content(320px)` 之类的**按内容算**的轨道：
 *       每一行是**各自一个**栅格（`display: grid` 挂在 `.admin-row` 上），
 *       按内容算的列每行宽度都不一样，等于把「斜线」原样搬进栅格。
 *       栅格能吃掉的自由空间 = 容器宽 − 定值列，每行一样，所以
 *       **只有 `1fr` 这种按剩余空间算的轨道才跨行对齐**。
 *   · 操作那格 `auto`（= 那三枚按钮的宽，每行都一样），仍旧贴最右 ——
 *     量过：按钮的 x 跟改之前**一模一样**，还是 1249。所以这一改
 *     动的只是「事实那几列」，右边那排按钮纹丝不动。
 *   · 三屏的列各写各的（题库 5 列 / 组织 4 列 / 公告 5 列）：
 *     组织的字段1 是稿子画的、没有数据源、永久藏（见 `admin-orgs.js` 文件头 ①），
 *     给它留一格会在「人数」和「创建」之间空出一条 100px 的缝。
 *
 * 行高：题库管理 88 → 68，组织/公告 108 → 68（三屏终于一样了）。
 * ⚠ **2026-10-02 又改了一次：68 → 61。** 68 只在「这三屏」成立，
 *   而卡片管理 / 文章管理 / IP 黑名单 那一族是 61 —— 两档并存这件事
 *   一直记在上线记录的「还没做的」里。用户这一轮点名「表格样式、风格各异」，
 *   就把它一起收了：三屏现在跟那一族**同一个算式**（8 + 44 + 8 + 1）。
 * ⚠ **同日第二次改：行配方从这里搬去了 ㊿-14。** 原来「长什么样」和
 *   「怎么摆」挤在同一条规则里、还按屏号挂了三个 slug；用户管理 / 资料管理 /
 *   纠错管理三屏不在名单里，就一直是白底圆角卡 —— 同一个后台两套列表。
 *   现在**配方只留一份**（㊿-14，按容器名挂），这条只管摆法。
 *
 * ⚠ 题库管理那屏还捎带修一个真毛病：稿子给它的次行钉了
 *   `height: var(--sz-icon-lg)`（= 24），而里面那排动作是 44 高 ——
 *   多出来的 10px 上下**冒到卡片外面**（卡内容 92 塞进 88 的盒子，
 *   溢出 4px 到卡底以下）。所以这里的 `height: auto` 不是排版偏好，
 *   是「别让按钮长出卡片」。另两屏的次行本来就是 44，写了也一样。
 * ⚠ 这条只管**摆法**（一列一列），而且是**带屏号**的：
 *   手机档、以及用户管理/资料管理这些别的屏，摆法一行都不碰。
 *   外观（内边距/发丝线/圆角/底色）**不在这里**，见 ㊿-14 —— 那一份是全局的。
 *   （`.admin-row` 是共用的类，不带屏号就会波及别的屏。）
 * ⚠ 手机档一个字不动：`body[data-ardot]` + `.admin-shell-main` 双重限定，
 *   手机壳整屏换、**根本不挂 `.admin-shell`**（`admin-shell.js`）。
 * ⚠ 窄窗口不另加横向滚动条：名字那列的 `min-width` 是 **200** 而不是 `auto`，
 *   定值列合起来 200+84+100+120+按钮+80 的缝 ≈ 758 是这一行的下限，
 *   再窄名字列自己缩（带省略号）。**不学 ㊿-12 给列表挂 `overflow-x`** ——
 *   用户这一轮点名「滚动条丑大」，能在布局里收掉的就别再添一条。
 */
body[data-ardot="9:115"] .admin-shell-main [data-name="列表"] > .admin-row,
body[data-ardot="9:156"] .admin-shell-main [data-name="列表"] > .admin-row,
body[data-ardot="10:156"] .admin-shell-main [data-name="列表"] > .admin-row {
  /* 行高 61 是 ㊿-14 那条通用配方给的（8 + 44 + 8 + 1），这里只管**怎么摆**。 */
  display: grid;
  align-items: center;
  gap: var(--sp-4);
}
/* 三屏各自的列（名字那列两屏一样，事实那几屏不一样，所以分开写）：
     题库管理  名字 | 组类型 | 章节/分组 | 题目/文章/卡片 | ⟨弹簧⟩ | 操作
     组织管理  名字 | 人数   | 创建日期                    | ⟨弹簧⟩ | 操作
     公告管理  标题 | 状态   | 类型      | 时间          | ⟨弹簧⟩ | 操作 */
body[data-ardot="9:115"] .admin-shell-main .admin-row {
  grid-template-columns: minmax(200px, 1fr) 84px 100px 120px minmax(0, 1fr) auto;
}
body[data-ardot="9:156"] .admin-shell-main .admin-row {
  grid-template-columns: minmax(200px, 1fr) 84px 120px minmax(0, 1fr) auto;
}
body[data-ardot="10:156"] .admin-shell-main .admin-row {
  grid-template-columns: minmax(200px, 1fr) 84px 100px 100px minmax(0, 1fr) auto;
}
/* 顶行 / 次行摊平：不生成盒子，孙节点直接落进上面那套栅格。
   （㊿-1 老版给次行写的 `height: auto` 是治「题库管理那屏的次行被稿子钉成
     24、里面的 44 按钮冒到卡片外」的 —— 摊平之后次行没有盒子了，那个病
     从结构上就不存在。） */
body[data-ardot="9:115"] .admin-shell-main .admin-row > [data-name="顶行"],
body[data-ardot="9:115"] .admin-shell-main .admin-row > [data-name="次行"],
body[data-ardot="9:156"] .admin-shell-main .admin-row > [data-name="顶行"],
body[data-ardot="9:156"] .admin-shell-main .admin-row > [data-name="次行"],
body[data-ardot="10:156"] .admin-shell-main .admin-row > [data-name="顶行"],
body[data-ardot="10:156"] .admin-shell-main .admin-row > [data-name="次行"] {
  display: contents;
}
/* 第 1 列 · 名字。`min-width: 0` 不能省 —— 不写的话栅格项的自动最小值
   会把这一列顶到全文那么宽，窄窗口下这一行就横向溢出了；
   加上省略号，名字再长也只是截断，不会把行撑高。 */
body[data-ardot="9:115"] .admin-shell-main .admin-row [data-name="主标题"],
body[data-ardot="9:115"] .admin-shell-main .admin-row [data-name="标题"],
body[data-ardot="9:156"] .admin-shell-main .admin-row [data-name="主标题"],
body[data-ardot="9:156"] .admin-shell-main .admin-row [data-name="标题"],
body[data-ardot="10:156"] .admin-shell-main .admin-row [data-name="主标题"],
body[data-ardot="10:156"] .admin-shell-main .admin-row [data-name="标题"] {
  grid-column: 1;
  min-width: 0;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
/* 第 2 列 · 徽标（题库=组类型 / 组织=人数 / 公告=启停状态）。
   ⚠ 用 `^=` 不是 `=`：控制器按优先级挑徽标，写死某一种，另一种出现时就落到
   自动放置上，整行的列会跟着错。公告那屏次行里还有第二枚（类型），下面单独钉。 */
body[data-ardot="9:115"] .admin-shell-main .admin-row [data-name^="Component/Badge/"],
body[data-ardot="9:156"] .admin-shell-main .admin-row [data-name^="Component/Badge/"],
body[data-ardot="10:156"] .admin-shell-main .admin-row [data-name^="Component/Badge/"] {
  grid-column: 2;
  justify-self: start;
}
/* 「弹性」那格摊平之后会变成一个真的栅格项、抢走一整列 —— 藏掉，
   中间的留白交给那格空的 `minmax(0, 1fr)`。 */
body[data-ardot="9:115"] .admin-shell-main .admin-row [data-name="弹性"],
body[data-ardot="9:156"] .admin-shell-main .admin-row [data-name="弹性"],
body[data-ardot="10:156"] .admin-shell-main .admin-row [data-name="弹性"] {
  display: none;
}
/* 事实那几列 + 操作那列。操作贴右：`justify-content: flex-end` 跟 ㊿-12 同一条。 */
body[data-ardot="9:115"] .admin-shell-main .admin-row [data-name="字段1"] { grid-column: 3; }
body[data-ardot="9:115"] .admin-shell-main .admin-row [data-name="字段2"] { grid-column: 4; }
body[data-ardot="9:115"] .admin-shell-main .admin-row [data-name="操作"] { grid-column: 6; justify-content: flex-end; }

body[data-ardot="9:156"] .admin-shell-main .admin-row [data-name="字段2"] { grid-column: 3; }
body[data-ardot="9:156"] .admin-shell-main .admin-row [data-name="操作"] { grid-column: 5; justify-content: flex-end; }

body[data-ardot="10:156"] .admin-shell-main .admin-row [data-name="Component/Badge/Primary"] { grid-column: 3; }
body[data-ardot="10:156"] .admin-shell-main .admin-row [data-name="时间"] { grid-column: 4; }
body[data-ardot="10:156"] .admin-shell-main .admin-row [data-name="操作"] { grid-column: 6; justify-content: flex-end; }

/* ── ㊿-14 后台所有列表：行配方只有一份（2026-10-02）──────────────────
 *
 * 用户这一轮的原话：「其他页面也要，主要清理**表格样式**、层级、按钮乱放、
 * 没层级、**风格各异**、分栏不清晰、滚动条丑大」。
 *
 * 当时的实情（1455×750 逐屏实测）：后台的列表**不是一套，是三套**——
 *   · 扁平行 + 发丝线（61 高）：卡片管理 / 文章管理 / IP 黑名单 / 试题检索
 *     / 激活码（65：多出来的 4px 是操作格里那枚 `--sz-control`（48）的控件，
 *     别人那格是 `--sz-tap-min`（44）—— 差在按钮档位，不在行配方，归按钮那一轮）
 *   · 白底 + 圆角 16 + 12px 行间隙（108 高）：**用户管理**
 *   · 同上的卡片，只是塞在分栏左栏里（103 / 178 高）：**资料管理 / 纠错管理**
 *   并排看就是两个产品。
 *
 * 为什么**按容器名挂**、不按屏号挂
 * ──────────────────────────────
 * ㊿-1 那套是 `body[data-ardot="9:115"] …`，一屏一条。它的问题是
 * **名单会过时**：新加一屏、或者像用户管理这样当初没列进去的屏，
 * 一个 slug 写不到就掉队，而屏幕上不会报任何错 —— 它就静静长成另一副样子。
 * 而「列表」是后台自己的契约名：`admin-list.js` 建容器时写死 `listName`，
 * 九屏分别叫「列表」/「资料列表」/「反馈列表」（这一屏两个是一屏一名，
 * 见 `admin-resources.js:154` 与 `admin-feedbacks.js`）。
 * 按名字挂之后，**新加一屏只要沿用这个名字就自动落在同一套里**。
 *
 * 数字全部照抄那一族，不另发明（那一族是对的，错的是没跟上的三屏）：
 *   `padding: var(--sp-2) var(--sp-4)`（8 / 16）、`min-height: 48`、
 *   透明底、`border-radius: 0`、`border-bottom: 1px solid var(--cl-border-base)`。
 *   一行 = 8 + 44（行里那排控件的高）+ 8 + 1 = **61**。
 *
 * ⚠ 行间隙那 10/12px 是**父容器**给的，得单独关掉（下面第二条）——
 *   只改行不改容器，卡片是变平了，但行与行之间还空着 12px，
 *   看着像「表格散架了」，比不改还难看。
 * ⚠ `border-radius: 0` 必须显式写：稿子那条是 `.n-9-124` 之类的**帧类**，
 *   在帧 CSS 里，跟这里不同层 —— 不覆盖的话圆角会留着，
 *   而「白底没了但角还是圆的」在浅色页面上几乎看不出来（假绿）。
 * ⚠ 容器里**只放行**：空态/骨架屏走的是旁边那个「态位」槽
 *   （`articles.js` 的 `fillState(box=态位)`、`admin-codes.js:1249`），
 *   不进列表 —— 所以 `> *` 不会误伤空态。这条是查过才敢写的，不是假设。
 * ⚠ 只落在 `.admin-shell-main` 里：手机档没有这个壳（`admin-shell.js`），
 *   而 `data-ardot` 两档都会镜像到 `<body>` —— 少限定这一层，手机档就跟着坏
 *   （试题检索那轮踩过）。量过：手机档六屏的行仍是 pad 16 / 圆角 16 / 无发丝线。
 */
.admin-shell-main [data-name="列表"] > :not([data-name="列表"]),
.admin-shell-main [data-name="资料列表"] > :not([data-name="列表"]),
.admin-shell-main [data-name="反馈列表"] > :not([data-name="列表"]) {
  min-height: 48px;
  padding: var(--sp-2) var(--sp-4);
  background: transparent;
  border-radius: 0;
  border-bottom: 1px solid var(--cl-border-base);
}
.admin-shell-main [data-name="列表"],
.admin-shell-main [data-name="资料列表"],
.admin-shell-main [data-name="反馈列表"] {
  gap: 0;
}

/* ── ㊿-14 附注：用户管理（`9:138`）这一行**就让它两截**，别再试了 ──────
 *
 * 试过、量过、退回来了，把数字记在这儿，免得下一轮又想到「把它也摆成一行」。
 *
 * 这屏在电脑上是**分栏**：左栏 520 放列表、右栏看详情。看着跟题库管理那三屏
 * 很像（也是一屏一行的东西），但**左栏放不下**：
 *   顶行（手机号 + 徽标）143
 * + 间隙 16
 * + 次行内容（注册 96 + 活跃 55 + 管理员 55 + 三枚操作 174 + 之间 3×8）
 *   = 511
 * ────────────────────────────
 *   合计 **670**，而左栏内容宽只有 520 − 32（内边距）= **488**，差 75px。
 * 硬摆成一行，`[data-name="次行"]` 那排就折成两行，行高**一点没降**（还是 93），
 * 画面却从「上下两截、清清楚楚」变成「左窄右挤、右边自己折」——**比不改难看**。
 * （试过给顶行封 46% 的顶：224 比顶行自身的 143 还宽，白抢次行 78px，一样折。）
 *
 * 所以这屏的行高就是 **93**，它比题库管理那三屏高，是**内容多**，不是风格不统一：
 * 外观（㊿-14 那条：内边距 8/16、透明底、圆角 0、发丝线、父容器 gap 0）已经
 * 跟其它八屏一字不差。**外观全局一套，行数一屏一议** —— 行数本来就该由内容定。
 * 真要让它变成一行，得动的是 `admin-users.js` 里 `defaultLeft: 520` 那个数
 * （放到 700 才够），那是「左栏多宽」的产品决定，不是排版规则能兜的。
 */

/* ══════════════════════════════════════════════════════════════════════
 * ㊿-2 题库详情：导入卡收进题库头卡之后，那条把两组按钮分开的竖线
 *      （2026-09-30 第五十一轮）
 *
 * 「导入试题卡」整块（**137px**）在桌面档被收掉了 —— 见
 * `admin-bank-detail.js` 里那段注释：三栏只有 526，而左栏内容 636
 * （冒出只能滚 110 的滚动条）、右栏内容 648（滚 122），**两条内嵌滚动条
 * 都是这块吃出来的高度**。卡上那行三枚按钮并进了题库头卡的按钮行。
 *
 * 并进去之后那一行有**六枚**按钮，读的人分不清哪几枚是一件事 ——
 * 补一条 1px 竖线，把「往这个题库里加题」（下载模板 / 开始导入 /
 * 新建题目）和题库本身的动作（编辑题库 / 数据报告 / 删除）分开。
 *
 * ⚠ 竖线是**装饰**（JS 那边挂了 `aria-hidden`），不参与读屏。
 * ⚠ `align-self: stretch` 必须有：按钮行是 `align-items: center` 的，
 *   不写这条竖线只有 1px 高，看着像个点。
 * ══════════════════════════════════════════════════════════════════════ */
.admin-bar-sep {
  width: 1px;
  align-self: stretch;
  margin: 0 var(--sp-2);
  background-color: var(--cl-border-base);
}

/* ══════════════════════════════════════════════════════════════════════
 * ㊿-3 题库详情：中栏试题列表的题干**最多两行**（2026-09-30 第五十一轮）
 *
 * 中栏是**列表**，可它每一行是一张完整的卡：题干多长、卡就多高。
 * 实测同一列里 **108 和 328 两种高度** —— 328 那张是材料题，一整段材料
 * 全摊在行里，把一列撑得参差。这跟纠错管理那屏「一列里四种卡高」是同一类病：
 * **列表行的高度不该由内容长度决定**。
 *
 * 截**两行**不是一行：材料题截一行认不出是哪道题，两行够认出、
 * 又不至于让中栏变成一列长短不齐的卡。全文在右栏（496 宽，还带解析）。
 *
 * ⚠ 只截**中栏**：`.admin-split-mid` 只有三栏的屏才有（目前只有这一屏）。
 *   手机档没有分栏、这条不命中 —— 手机上题干照旧全摊着：那边是一列卡片
 *   逐张翻，截了反而读不到题面。
 * ⚠ 用 `line-clamp` 不用 `max-height` + `overflow: hidden`：后者会把行
 *   **切在半截字上**（这个坑在上一屏量过），前者自己带省略号。
 * ══════════════════════════════════════════════════════════════════════ */
.admin-split-mid .admin-row [data-name="主标题"] {
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

/* ══════════════════════════════════════════════════════════════════════
 * ㊿-4 组织详情（`10:1`）：左栏那一摞两坨列表，改成「成员 / 关联题库」两个标签页
 *      （2026-09-30 第五十二轮）
 *
 * 病（量出来的）：左栏把**两坨列表竖着摞在同一个框里** ——
 *   成员那一块 538（头 32 + 4 行 ×108 + 页脚 38）
 * ＋ 题库那一块 740（头 32 + 6 行 ×108）＝ **1278**，而这一栏只有 **630**。
 *   于是左栏冒出一条要滚 **648** 的内嵌滚动条 —— "滚一整屏还多"，
 *   跟题库详情那两条、用户管理那条「只能滚 24px」是同一族病。
 *
 * 根子不是"内容太多"，是**两件事被摆成了上下楼**：成员和关联题库是同一个
 * 组织的两类关系，看的人在任一刻只关心其中一类。这一栏是 master 栏，
 * master 栏里两组互斥的东西该做**切换**，不是摞起来一起滚。
 *
 * 于是：两个「头」里现成的标题节点搬出来并排当标签页（JS 那边做的），
 * 两个头卡连同动作按钮并进同一行 —— 一行干原来两行头的活。
 *
 * ⚠ 选中态**沿用后台已有的写法**（`admin-dashboard.js` 那排排序分段：
 *   选中换主色、未选中用次级字色），不自己描一条线、不造新控件 ——
 *   自己描的那条迟早跟设计语言分叉，而且分叉了看不出来。
 * ⚠ 只认 `body[data-ardot="10:1"]` 且只认 `.admin-split-left`：
 *   手机档没有分栏，那边两组照旧竖着排在一条页面上，一个字节不改。
 * ══════════════════════════════════════════════════════════════════════ */

/* ── 那一行本身：两枚标签页在左，当前那一组自己的动作在右 ──────────────
 * `flex: 0 0 auto` 是**必须**的：左栏是列向 flex、`overflow` 又不是 visible，
 * 不写这条它会被当成可压缩项，行高在窄屏上会被压扁。 */
body[data-ardot="10:1"] .admin-shell-main .admin-split-left > .admin-org-tabs {
  flex: 0 0 auto;
  display: flex;
  flex-direction: row;
  align-items: center;
  gap: var(--sp-4);
  padding-bottom: var(--sp-2);
  border-bottom: 1px solid var(--cl-border-base);
}

/* 一枚标签页 = 稿子那个「标题」节点被 JS 搬进来。按钮自己只负责命中区和手感，
 * **字的一切照旧由那个节点说了算**（字号/字重/行高都是稿子给的），
 * 所以这里只把 UA 给按钮的那套皮褪掉。 */
body[data-ardot="10:1"] .admin-shell-main .admin-org-tab {
  appearance: none;
  -webkit-appearance: none;
  margin: 0;
  padding: 0;
  border: 0;
  background: none;
  font: inherit;
  cursor: pointer;
  display: flex;
  align-items: center;
  color: var(--cl-text-secondary);
  /* 命中区撑到 44：稿子那个标题节点行高 22，手指点它是「瞄着点」。
     这里用零代价的做法 —— 上下各留 11px 内边距把整枚撑到 44，
     字的位置一个像素不动（跟 `.admin-seg` 那段同一个理由、同一个数）。 */
  padding: 11px 0;
}
body[data-ardot="10:1"] .admin-shell-main .admin-org-tab[aria-selected="true"] {
  color: var(--cl-primary-700);
}
body[data-ardot="10:1"] .admin-shell-main .admin-org-tab:not([aria-selected="true"]):hover > * {
  color: var(--cl-text-primary);
}
/* 标题节点在稿子里是 `flex: 1 1 0`（它原来跟按钮并排、要吃掉整行），
   搬进按钮里就该缩回它自己的宽度，不然两枚标签页会各占半屏。 */
body[data-ardot="10:1"] .admin-shell-main .admin-org-tab > [data-name="标题"] {
  flex: 0 1 auto;
}
body[data-ardot="10:1"] .admin-shell-main .admin-org-tabs__act {
  margin-left: auto;
  display: flex;
  align-items: center;
}

/* ── 左栏的行：108 的手机卡压成一行 ─────────────────────────────────
 * 这一栏里一行装的东西少得可怜 —— 成员行是「号码 + 徽标 + 昵称 + 移出」，
 * 题库行是「题库名 + N 题 + 解除关联」，108 纯是手机卡的身板（顶行 + 次行竖着摞）。
 * 压成一行之后：成员 108→44、题库 108→44，两组的自然高度都落进 630 里。
 *
 * 手法跟 ㊿-1（三张列表屏）是同一套：顶行 `flex: 0 1 auto` 坐稳在左端、
 * 次行 `flex: 1 1 auto` 吃掉剩下的宽度、里面的「弹性」那格照旧把动作顶到最右。
 * ⚠ 这一栏只有 320～610 宽（不是列表屏那 1188），顶行的上限因此**给到 75%**。
 *   踩过：第一版照列表屏的思路给了 55%（320 宽时 = 163），而
 *   「13910000001」加一枚「2027-07-27」徽标本身就要 190 —— 超出的那部分
 *   被这一格 `overflow:hidden` 吃掉，屏幕上只剩 **「1391000000」**。
 *   ★ 号码是这一行**最要紧的东西**（昵称可以为空、徽标可以没有，号码不能少一位），
 *     所以上限要按「号码 + 最长的那枚徽标」来留，不是按一个好看的比例。
 * ⚠ `height: auto` 同 ㊿-1：次行在稿子里钉过一个高度，不覆盖就会长出卡片。
 * ⚠ **选择器按「列表根的直系孩子」认行，不按 `.admin-row`** —— 这一屏的卡片
 *   身上**没有**那个类（实测：`n-10-16 is-selected`，类是稿子生成的每张卡一个），
 *   照 ㊿-1 抄 `.admin-row` 会一条都不命中、而屏幕上一个错都不报（踩过）。
 * ⚠ 只认 `.admin-split-left`：手机档、以及别的屏（用户管理/资料管理/纠错管理
 *   都在用同一个 `.admin-split-left`）一行都不碰。 */
body[data-ardot="10:1"] .admin-shell-main .admin-split-left
  :is([data-name="成员列表"], [data-name="题库列表"]) > * {
  flex-direction: row;
  align-items: center;
  gap: var(--sp-3);
  padding: var(--sp-2) var(--sp-3);
}
body[data-ardot="10:1"] .admin-shell-main .admin-split-left
  :is([data-name="成员列表"], [data-name="题库列表"]) > * > [data-name="顶行"] {
  flex: 0 1 auto;
  min-width: 0;
  align-self: center;
  max-width: 75%;
}
body[data-ardot="10:1"] .admin-shell-main .admin-split-left
  :is([data-name="成员列表"], [data-name="题库列表"]) > * > [data-name="次行"] {
  flex: 1 1 auto;
  min-width: 0;
  align-self: center;
  height: auto;
}

/* ── 头部那三张统计卡的 2px 切字 ─────────────────────────────────────
 * 稿子把卡片钉成 `height: 90px` + `overflow: hidden`，而里面的自然高度是
 * **92**（数值 35 + 间距 6 + 标签 19 + 上下内边距 32）—— 底部那 2px 被切掉，
 * 切在「成员 / 关联题库 / 激活码」那几个标签的下沿上。量出来的：
 * `clientHeight 90 / scrollHeight 92`。
 * 改成跟着内容长：90 → 92，整块头顶高 2px，切字就没了。 */
body[data-ardot="10:1"] .admin-shell-main [data-name="统计行"] > [data-name="后台统计卡"] {
  height: auto;
}

/* ── 右栏那份详情：行留白收紧（同一屏，跟上面那一段是一件事的两半）──────────
 * 两栏的高度是同一个数（`.admin-split` 那一行给多少，两边就是多少）。
 * 左栏收干净之后，右栏这份「成员详情」就露出来了 —— 量出来的：
 * `.admin-split-right` 可见 607（减掉它自己的 20px 上下内边距 = **567**），
 * 而里面那份面板 **621**，**差 54 冒出一条只能滚一点点的滚动条**。
 *
 * 那 621 里**七个「标签 · 值」行就占 329** —— 稿子给每行 `padding: 12px 0`，
 * 那是弹窗里的尺度（弹窗又宽又高、行离得开才好看）。缩到 8px 是这一栏的尺度：
 * 一行 = 22 的行高 + 上下各 8 = 38，仍是一条读得开的行，
 * 七行一共省 56（54 那条缺口就补上了）。
 *
 * ⚠ 只认 `body[data-ardot="10:1"] .admin-split-right`：䈾一层的
 *   `.admin-split-detail` 那份（㊾-1／㊾-3）是**弹窗与分栏共用**的规则，
 *   动它就把用户管理 / 资料管理 / 纠错管理 / 激活码那几屏一起改了。
 * ⚠ 行距也顺手从 16 收到 12（面板的 6 个孩子之间 5 条缝，省 20）——
 *   留出余量是因为**值那一列会换行**（比如一个较长的昵称），
 *   留 14px 的话换个行就又冒出来滚动条了。 */
body[data-ardot="10:1"] .admin-shell-main .admin-split-right .admin-grid__cell {
  padding: var(--sp-2) 0;
}
body[data-ardot="10:1"] .admin-shell-main .admin-split-right .dialog-inline {
  gap: var(--sp-3);
}

/* ══════════════════════════════════════════════════════════════════════
 * ㊿-5 题库详情（`9:187`）：头卡那排按钮 —— 主次与分组（2026-10-02）
 *
 * 量出来的毛病（1440，`$CLAUDE_JOB_DIR/tmp/bk2.py` 的节点树）：
 *   六枚按钮 + 一条分隔线从 x=252 一路排到 929，**全是等宽 100 的方块**，
 *   而整行宽 1188 —— 左边挤成一片、右边空掉半个屏。
 *   机器主人原话：「你看按钮挤在一块」。
 *   比挤更要紧的是**两枚实心并排**：「开始导入」（Primary 紫）和「删除」
 *   （Danger 红）站在同一行里，两个都想当主角。
 *
 * 按设计规范（notes/14 第 27 条）：「一行里只有一枚实心主按钮；次要动作改描边
 * 或 ghost；破坏性动作是危险色文字」。所以这里做两件事：
 *
 * ① 「删除」褪掉实心底，只留危险色文字
 *    —— 取色跟 `.gaozi-act--danger` 一致（`--cl-danger-700`）。
 *    ⚠ **零件名一个字不动**（还是 `Component/Button/Danger/Default`）。
 *      改 `data-name` 会把四态一起丢掉（那条教训完整地写在
 *      admin-bank-detail.js「不许改 data-name」那一段，实测是「鼠标指上去
 *      看不出能点」）。这里只是**本屏这个位置**不用它的实心档。
 *    ⚠ 悬停/按下态也要一起改：`.admin-tap[data-name="…Danger…"]:hover` 那条
 *      会把底色刷回实心红 —— 静息是红字、一悬停变成一整块红块，
 *      反而像在招呼人点它。改成浅红底（`--cl-danger-50`）、红字不动。
 *
 * ② 「删除」推到最右（`margin-left: auto`）
 *    —— 它原来跟「新建题目」只隔 12px，手一滑就把整个题库删了。
 *    拉开之后误点要横跨整个屏幕；而且左边那两组按钮自然靠左成组，
 *    一行读起来是「改题库 │ 加题 ……… 删题库」。
 *
 * ⚠ 手机档一个字不改（`body[data-ardot]` + `.admin-shell-main` 双重限定，
 *   跟 ㊿-1 到 ㊿-4 同一个写法）。 */
body[data-ardot="9:187"] .admin-shell-main [data-name="题库头卡"] [data-name="Component/Button/Danger/Default"] {
  background: transparent;
  margin-left: auto;
}
/* 字色：那枚「标签」自己带着颜色（`--cl-text-inverse` 白）。
   光把底褪掉会剩白字白底 —— 得连着它一起改。 */
body[data-ardot="9:187"] .admin-shell-main [data-name="题库头卡"] [data-name="Component/Button/Danger/Default"] [data-name="标签"] {
  color: var(--cl-danger-700);
}
body[data-ardot="9:187"] .admin-shell-main [data-name="题库头卡"] .admin-tap[data-name="Component/Button/Danger/Default"]:hover,
body[data-ardot="9:187"] .admin-shell-main [data-name="题库头卡"] .admin-tap[data-name="Component/Button/Danger/Default"][data-press="down"] {
  background-color: var(--cl-danger-50);
}

/* ── ㊿-5 续：中栏顶上那枚搜索框的排布 ──────────────────────────────────
 * 借来的搜索框（`9-223` 的 `Component/SearchBar/Default`）自带 `flex: 1 1 0`，
 * 而「试题列表」标题**也是** `flex: 1 1 0`（稿子给的）—— 两个平分剩余宽度。
 * 量出来：标题占 198，可它只要 60；搜索框被挤到 226（480 的中栏里）。
 *
 * 改成：标题**只要自己那么多**（`flex: 0 0 auto`），剩下的全给搜索框。
 * 一行读起来是「试题列表 [搜索题干关键词………………] 共 2 题」。
 *
 * ⚠ 不限桌面档（不写 `.admin-split-mid` 前缀）：手机档那一行同样是
 *   393 宽装「标题 · 搜索框 · 计数」，算得下（60+39+两个缝 16 = 115，
 *   留 278 给搜索框），而且**手机上的管理员同样需要搜题**。
 *   加了之后手机档的几何会变，探针里那几条手机档判据要跟着重量。
 * ⚠ 搜索框那 44 高不动（`--sz-tap-min`，HIG 的最小点按区，稿子给的）。
 *   它是这一行里唯一会长高的东西，代价是中栏少 22px —— 换来一只手点得到。 */
body[data-ardot="9:187"] .admin-shell-main [data-name="试题列表头"] > [data-name="标题"] {
  flex: 0 0 auto;
}

/* ══════════════════════════════════════════════════════════════════════
 * ㊿-6 题库详情：右栏里**值只有一条起点**（2026-10-02）
 *
 * 量出来的（1440，`$CLAUDE_JOB_DIR/tmp/bk2.py` 的右栏树）：同一张面板里
 * 并存着**两套栅格** ——
 *     「题型 / 章节 / 题干 / 正确答案 / 解析」的值起于 x=1176；
 *     而夹在「题干」和「正确答案」中间那一坨选项，起于 x=1048，
 *     它的**文字**起于 1109。
 * 一栏里两条竖线，读的人会以为那是两栏东西（正好差着一整个标签列的宽度）。
 * 机器主人原话：「不对齐」。
 *
 * 根子：那一坨选项是个 `optionsView` 块（`admin-dialog.js`），它里面每一行
 * 本来是「[圆点][字母][文字]」摊成一串 flex —— 串里没有「列」的概念，
 * 于是它的文字起点由圆点+字母的宽度凑出来（凑到 1109），跟标签列无关。
 *
 * 治法是把它也摆成**面板同一套两列栅格**：记号占左列、文字占右列。
 * 左边那列取 `--detail-kw`、缝取 `--sp-4`，`grid-template-columns` 一写，
 * 选项文字就跟所有值落在同一条竖线上了。
 * 为此 `admin-dialog.js` 那边把「圆点+字母」裹进了 `.admin-optmark` 一个盒子
 * （不裹的话栅格会把三个孩子当成三列）。
 *
 * ── 为什么这一屏的 `--detail-kw` 敢收到 88（全站是 112）─────────────
 * 112 是给**别的屏**留的：㊾ 那一节写明了资料管理有 8 个字的标签
 * （「用户在 App 里下载」）。这一屏最长的标签是「正确答案」4 个字，
 * 13px 下 52px 就够，88 还余着一截。
 * ⚠ 收它换来的是**值列从 288 涨回 296**（面板 400 − 88 − 16），
 *   这一栏比改动前还宽 8px —— 右栏的值全是一整句话，值列一窄就折行、
 *   一折行就冒出一条滚动条（第一版栏宽给 416 时真冒过：内容 736 / 可见 661）。
 * ⚠ **只限这一屏**（`body[data-ardot="9:187"]` 前缀）。改全站的 112 会把
 *   别的详情栏标签挤成两行 —— 那是㊾ 那一节花了力气才收掉的毛病。
 * ⚠ 手机档不写前缀就不受影响：那儿的选项在从底下升的浮层里，
 *   浮层不是 `.admin-split-detail`，一个字不改。
 * ══════════════════════════════════════════════════════════════════════ */
body[data-ardot="9:187"] .admin-split-detail {
  --detail-kw: 88px;
}
body[data-ardot="9:187"] .admin-split-detail .admin-optview .admin-optrow {
  display: grid;
  grid-template-columns: var(--detail-kw) 1fr;
  row-gap: 0;
  column-gap: var(--sp-4);
  align-items: center;
}
/* ⚠ `.admin-optmark` 自己那几条（横排、间距、徽标不许被挤）是**全局**的，
   写在上面 `.admin-optrow` 那一段底下 —— 因为手机档和弹窗里也得有，
   不能只挂在这一屏的栅格规则里。这里只管「摆成两列」。 */

/* ── ㊿-6 续：中栏试题行**一屏一个行高** ────────────────────────────────
 * ㊿-3 给题干截了两行，可它只封了上限、没封下限：题干一行的行高 108、
 * 两行的 130，一列卡参着排。规范第 22 条「一屏里只有一个行高」，
 * 而且㊿-3 自己那句「**列表行的高度不该由内容长度决定**」也是这个意思 ——
 * 这次是把下限也钉住：题干块**始终占两行**（短题干下面留白），
 * 整列于是全是 130，扫起来是一条直边。
 * ⚠ 只钉**中栏**（`.admin-split-mid`，只有三栏的屏才有）。手机档那列卡片
 *   是逐张翻着读的，行高跟着内容走反而省地方，一个字不改。
 * ⚠ 用 `min-height` 而不是写死 `height`：题干真超两行时有 `line-clamp`
 *   兜着，但万一字号/行高令牌将来变了，写死的高度会把字切掉半截。 */
.admin-split-mid .admin-row [data-name="主标题"] {
  min-height: calc(2 * var(--lh-body));
}

/* ══════════════════════════════════════════════════════════════════════
 * ㊿-7 题库详情 × HMI 设计规范逐条体检（2026-10-02，第五十三轮）
 *
 * 尺子：`tools/build/probe-admin-hmi.py`（**只读**，逐条报「违反第几条、
 *   实测多少」）。规范：`~/.workbuddy-ai/skills/hmi-design-principles/`
 *     §1 红线 H1（点按区 44×44；数值表 §2 另给桌面档下限 24×24）
 *     §1 红线 H5（交互件必须有 hover/active/focus/disabled 四态）
 *     §1 红线 H4（桌面字号下限 10px）
 *     `references/specs.md` §1：允许的间距只有 4/8/12/16/24/32/48/64
 *     `references/specs.md` §5.2：破坏性操作要远离主操作
 *
 * 1440×900 上量到、这一轮动手的三条：
 *   ① 面包屑可点项 60×20  → 撑到 44（H1）
 *   ② 章节卡「章节管理」标题 100×22、**四态一个都没有**（H5）
 *   ③ 右栏页脚「删除」与「编辑」只隔 8px（§5.2）
 *
 * 同一把尺子量到、**决定不动的**（记在这儿免得下轮又想起来）：
 *   · 顶部那排按钮 40 高、「+ 新增章节」32 高：不到 H1 的 44，但 H1 的 44 是
 *     **手指**档，数值表给桌面档的下限是 24×24 —— 它们都过了。硬撑到 44 会让
 *     头卡长高 4px，而「+ 新增章节」在 208 宽的卡头里会把「章节管理」四个字
 *     挤没（那格只剩 100 宽）。拿版面换一条没违反的线，不划算；真要动该动稿子。
 *   · 对比度 0 处不达标（尺子自检那两格 2.85 / 4.54 都对得上，可信）。
 *   · 一屏实心主按钮 1 枚 ✓（㊿-5 已经收过）。
 *   · 圆角 7 种、间距若干越界值：全在**共用**的帧类和侧边栏上，牵动六屏，
 *     留到 #40 逐屏做，不混在这一轮。
 * ══════════════════════════════════════════════════════════════════════ */

/* ── ① 面包屑：可点的那三节只有 60×20，够不着桌面档 24 的底线 ──────────
 * 20 → 44（既然撑就撑满 H1 的整档）。
 *
 * 三件事一起做，**视觉一个字不动**：
 *   · 间距归一到 §1 的集合：`gap` 6 → 8、分隔符 `padding: 0 2px` → 0。
 *     6 和 2 都不在允许集里；而且这两刀**像素等价** ——
 *     6+2+5+2+6 = 21，8+5+8 = 21，四节的 x 改前改后一个没动
 *     （实测 272 / 353 / 434 / 516）。
 *   · 热区靠 `padding-block` 撑、再用等量负 `margin` 把行高补回去 ——
 *     这样**条子自己的盒子正好 44**（y 6..50），下面那张题库头卡一动没动
 *     （实测仍在 y=72、高 140）。文字也不动：内容盒还是 20 高、中心还是 y=28。
 *
 * ⚠ 为什么不用「`height: 44px`」和「`::after` 覆盖层」——两条都试过，都不行：
 *   · `height: 44px` 会把 flex 行也撑到 44，条子变成 **68** 高（多两圈白 padding）；
 *   · `::after` 覆盖层被 `.admin-crumb-item` 自己的 `overflow:hidden` 裁掉
 *     （那一条是给长标签做省略号用的，不能摘）。量出来命中率**一点没涨** ——
 *     这就是为什么要先拿探针打命中点，光看盒子尺寸会以为改好了。
 *   靠 padding 撑、负 margin 补，是唯一既撑得起热区、又不改盒子尺寸的写法。
 * ⚠ 这是**全站**的（每张后台屏的顶栏都有面包屑，`admin-shell.js` 造的）。
 *   改完跑了 `probe-admin-split.py` 全量（六屏 × 桌面/手机两档）确认没连带。 */
.admin-crumb {
  gap: var(--sp-2);
  padding-block: var(--sp-3);
  margin-block: calc(-1 * var(--sp-3));
}
.admin-crumb-sep { padding: 0; }
.admin-crumb-item.is-link {
  display: flex;
  align-items: center;
  padding-block: var(--sp-3);
  margin-block: calc(-1 * var(--sp-3));
}

/* ── ② 章节卡那格「章节管理」：它点得开面板，却长得跟纯标题一样 ────────
 * 它**本来就是入口**：`admin-bank-detail.js` 把 `openChapters` 接在这一格上
 * （稿子上没给这个功能画按钮，那是唯一写着「章节管理」四个字的地方）。
 * 所以问题不在「它凭什么能点」，在**看不出能点** —— 红线 H5 要的四态里
 * 它 hover / focus 一个都没有，鼠标划过去和一段普通标题一模一样。
 *
 * 补三样：hover（字变主色 + 下划线，全站「可点的字」就是这个长相）、
 * 热区 22 → 44、键盘焦点（`:focus-visible` 那条统一规则里加了它）。
 * ⚠ 键盘那一条要 JS 那边给 `tabIndex`，写在 `admin-bank-detail.js`
 *   同一个位置 —— 两处是同一件事，改一处不算改完。
 *
 * ⚠ 卡头那格（`n-9-198`）自带 `overflow:hidden`，会把标题的伪元素**裁掉**，
 *   所以先把卡头放开。放开是安全的：卡头里就两件东西（标题 + 按钮，
 *   100 + 12 + 96 = 208），谁也不溢出。
 *   实测：不放开时命中 4 处漏、放开后 **0 漏**。
 * ⚠ 手机档一个字不改（`.admin-shell-main` 限定）。 */
body[data-ardot="9:187"] .admin-shell-main [data-name="章节卡"] [data-name="卡头"] {
  overflow: visible;
}
body[data-ardot="9:187"] .admin-shell-main [data-name="章节卡"] [data-name="卡头"] > [data-name="标题"] {
  position: relative;
}
body[data-ardot="9:187"] .admin-shell-main [data-name="章节卡"] [data-name="卡头"] > [data-name="标题"]::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  top: 50%;
  transform: translateY(-50%);
  height: var(--sz-tap-min);
}
body[data-ardot="9:187"] .admin-shell-main [data-name="章节卡"] [data-name="卡头"] > [data-name="标题"]:hover {
  color: var(--cl-primary-700);
  text-decoration: underline;
}

/* ── ③ 右栏页脚：那枚「删除」跟「编辑」只隔 8px ────────────────────────
 * 8px 在同一排按钮里是「组内」的距离，而规范 §5.2 要**破坏性操作远离主操作**。
 * 上一轮在头卡里是把「删除」推到最右（`margin-left: auto`）；右栏页脚这里
 * 右边紧挨着「关闭」—— 推到最右等于把它挪到收尾按钮边上，**更糟**。
 * 所以改成把它**从这一组里摘出来**：`margin-left: var(--sp-6)`，
 * 与「编辑」隔开 32px（组内 8 + 24），落在 §4「组间 24~48」那一档。
 * 一行读起来是「编辑 │ 删除 ……… 关闭」。 */
.admin-split-detail .admin-detail-actions > .gaozi-act--danger {
  margin-left: var(--sp-6);
}

/* ══════════════════════════════════════════════════════════════════════════
 * ㊿-8 · 题库详情（9:187）**改版式**：三栏并排 → 一条竖流 + 右侧抽屉
 *                                                    （2026-10-02 第五十四轮）
 *
 * 机器主人看完 ㊿-7 那一版（合规都过了）之后原话是：
 *   「你不觉得目前的题库详情页面设计无可救药了吗？」「重新思考并设计这个页面！」
 * ⇒ 他说的不是**像素合规**，是**版式**本身。这一节就是在推翻版式，
 *   不是加几条 padding。**我推翻了 2026-09-27 自己定的「三栏」**，理由在下面。
 *
 * ── 三栏到底坏在哪（1455×750 实测，`probe-bank-layout.py` 量的）─────────
 *   可用宽 1203、高 515（头卡 140 吃掉之后只剩这些）。三栏分下来：
 *     左 240（章节卡，内容 636 ⇒ **滚 121**）
 *     中 424（**试题列表，干活的那一栏，最窄**）
 *     右 519（只读详情，**最宽**，内容 626 ⇒ 滚 111）
 *   四个病，一个比一个重：
 *     ① **宽窄跟用途反了** —— 要一条条扫题干的列表只有 424，只读的详情反而 519；
 *     ② **两条内嵌滚动条并排** —— 左滚 121、右滚 111，都是「再给几像素就没」
 *        的那种，最丑；机器主人从 v1.20.11 起念叨的就是它们；
 *     ③ **章节名被竖排挤没了** —— 8 行 36 高往后排，第 9 章开始就得滚；
 *     ④ **三块一样重** —— 卡片同色同宽，眼睛进去没有落点。
 *
 * ── 改成什么 ─────────────────────────────────────────────────────────
 *   一条竖流，从上到下只有一个方向：
 *     头卡（56）  →  章节筛选条（44，横着滚）  →  列表工具条（44）
 *                 →  **试题列表（占满剩下的高，全屏唯一一条滚动条）**
 *   点一道题 → 右栏变成**从右边滑出来的抽屉**（480 宽），关掉列表回全宽。
 *   于是：滚动条 2 条 → 1 条；列表 424 → 1155；章节名一个字不截；
 *        头卡 140 → 56；没点题的时候右栏**不存在**（不再占 519 的宽度放空气）。
 *
 * ── 为什么敢推翻「三栏」──────────────────────────────────────────────
 *   三栏是 2026-09-27 照 22 号 R4 的参照图落的（左树 / 中列表 / 右题号索引），
 *   当时写进控制器的那三条理由里，有两条今天仍然成立
 *   （中栏不放编辑表单、不做题号索引），**只有「三栏」这个形状本身被推翻了** ——
 *   因为参照图是个 Win32 桌面程序，它的窗口能拉到 1920×1200；
 *   而这台机器是 **1455×750**，高度只有人家的一半。三栏在 750 高上必然
 *   每栏都装不下、每栏都得自己滚 —— 这不是没调好，是版式选错了。
 *
 * ── ⚠ 只挂 `.admin-shell-main`（电脑档），手机档一个字不动 ─────────────
 *   手机档没有 `.admin-shell`，这一整节对它无效：那边还是原来那条长卷
 *   （章节卡在最上面、题列表在下面），一行没变。
 *
 * ── ⚠ 稿子里的节点一个没删、一个没搬 ─────────────────────────────────
 *   头卡 / 章节卡 / 试题列表 / 详情落点全是 `admin-split.js` 认领的那几个，
 *   这里只改它们的**排法**。`data-name` 一个没动。
 * ══════════════════════════════════════════════════════════════════════════ */

/* ── ① 分栏根：三栏 → 一栏两行（章节条 / 列表）───────────────────────
 * `grid-template-columns` 从 5 列压成 1 列，`grid-template-rows` 显式写两行：
 * 第 1 行给章节卡（auto，它自己 44 高），第 2 行给列表（吃满剩下的）。
 * `position: relative` 是给抽屉当定位基准的 —— 抽屉要贴在这个盒子的右边。 */
body[data-ardot="9:187"] .admin-shell-main .admin-split--3 {
  grid-template-columns: minmax(0, 1fr);
  grid-template-rows: auto minmax(0, 1fr);
  position: relative;
}
/* 两条拖宽分隔条**藏掉**：一栏了，没有可调的宽度。
   `display:none` 顺带把它从焦点顺序里摘掉（它们本来是 tabIndex=0 的滑块）。 */
body[data-ardot="9:187"] .admin-shell-main .admin-split--3 > .admin-split-bar {
  display: none;
}
/* ★ 左栏这一层**自己消失**（`display: contents`），里面的章节卡直接变成
   分栏根的栅格项 —— 这样章节卡能横跨整行，而不用把它从 DOM 里搬走。
   ⚠ 用 `display:contents` 而不是「在 JS 里把章节卡 append 到根上」：
     搬节点会让 `admin-split.js` 认领的那份引用失效（它拿着 leftEl），
     将来一重挂就错位。而且搬节点是**改结构**，这条只是改排法。 */
body[data-ardot="9:187"] .admin-shell-main .admin-split--3 > .admin-split-left {
  display: contents;
}

/* ── ② 头卡：三行摞着（140）→ 一行横排（56）──────────────────────────
 * 改前：padding 16 + 题库名 25 + 12 + 副标题 19 + 12 + 按钮行 40 + 16 = 140。
 * 改后：padding-block 8 + 按钮行 40 + 8 = **56**（题库名/副标题并排、都在这一行里）。
 * 省下的 84px 全给了列表 —— 那是这一屏真正的活儿。
 * ⚠ 高度由**按钮行**兜底（40 是帧里定的、够 24 的桌面下限），不是写死 56：
 *   将来稿子把按钮改高，这里跟着长，不会把按钮压扁。 */
body[data-ardot="9:187"] .admin-shell-main .admin-split-top [data-name="题库头卡"] {
  flex-direction: row;
  align-items: center;
  gap: var(--sp-3);
  padding-block: var(--sp-2);
}
/* 题库名与副标题：帧里是 `align-self:stretch`（各占一整行），横排里改成
   按内容宽、垂直居中；长名字**截断**而不是把按钮挤出屏幕。
   `flex: 0 1 auto` 是「不主动长、装不下可以让」—— 让出来的宽度归按钮行左边那个空隙。 */
body[data-ardot="9:187"] .admin-shell-main .admin-split-top [data-name="题库头卡"] > [data-name="题库名"],
body[data-ardot="9:187"] .admin-shell-main .admin-split-top [data-name="题库头卡"] > [data-name="副标题"] {
  align-self: center;
  flex: 0 1 auto;
  min-width: 0;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
/* 按钮行推到最右边。原来它靠 `align-self:stretch` 占满一行，
   横排里必须显式给一个「吃掉剩余空间」的推力，否则它会紧贴着副标题。 */
body[data-ardot="9:187"] .admin-shell-main .admin-split-top [data-name="题库头卡"] > [data-name="按钮行"] {
  align-self: center;
  flex: 0 0 auto;
  margin-left: auto;
}

/* ── ③ 章节卡：一排横着滚的筛选块 → 一行「章节 ▾ + 章节管理」（㊿-9）───
 * 2026-10-02 第五十五轮。机器主人原话：「章节直接下拉选择更好，
 * 章节管理单独弹窗管理。」
 *
 * 上一版（㊿-8 那一节）是**一排横着滚的筛选块**，11 枚挤一行、横滚 313px。
 * 三个病，两个是量出来的：
 *   · **章节名被截** —— 1455 宽上「毛泽东思想和中国特色社会主义理论体系概论」
 *     只露出「毛泽」两个字（`.admin-chaprow` 那一排），
 *     而「章节名一个都不截」正是上一轮刚立的规矩；
 *   · **章节越多越糟** —— 这个库 10 章，线上那个 24 章，横排永远在滚；
 *   · 两枚**入口**混在筛选块里 —— 卡头那格标题「章节管理」和按钮
 *     「+ 新增章节」被 ㊿-8 一并画成了药丸，三个长得一模一样。
 *
 * 现在这一格 = 一个下拉 + 一枚按钮。**下拉的样式一条都不在这儿**：
 * 药丸是稿子自己的 `Component/Select/Default`（控制器从 9-223 跨帧借来的），
 * 它的底/圆角/44 高/ChevronDown 全跟着设计系统走。这一节只管**这一行怎么排**。
 * 章节名再长也不会被截 —— 点开是系统自己的选择器，一行放不下它自己会折行。
 *
 * ⚠ 不加 `.admin-shell-main`：**手机档一起改**。两档共用一份控制器，
 *   而手机档原来那一列竖排八行同样该换成下拉（还能省下 8 行的竖向空间）。 */

/* 帧里那八行「列表行·名称值」**不再用了** —— 控制器把下拉插进了卡头。
   这里显式收起来，跟控制器里那句 `hideChapterRows()` 是**两道**：
   帧是 `gen.mjs` 生成的，万一哪天控制器那条路走不到，
   屏幕上就会多出八行空壳（有名字没内容的那种）。
   ⚠ 上一版那几条把行画成药丸的规则（底色/圆角/is-selected 描边/悬停）
     在这里**删掉**了，不是留着不管：它们的目标节点已经 `display:none`，
     留着就是一段永远量不到的死代码，下一个人会以为那一排还在。 */
body[data-ardot="9:187"] [data-name="章节卡"] > [data-name="列表行·名称值"] {
  display: none;
}

/* 卡片自己：竖排摞行 → 一行横排。
   `padding-block: 4` 是**沿用上一版那个 44+8=52 的总高** —— 这一屏的竖向空间
   全归试题列表，章节条多长一像素，列表就少一像素。 */
body[data-ardot="9:187"] [data-name="章节卡"] {
  flex-direction: row;
  align-items: center;
  padding: var(--sp-1) var(--sp-6);
}
/* 卡头（帧里是「章节管理」+「+ 新增章节」那一行）现在装着三样：
   标签「章节」 · 下拉药丸 · 按钮「章节管理」。
   帧里它本来就是 `flex-direction:row; gap:sp-3; align-items:center`，够用。
   ⚠ 但它那格 `align-self: stretch` **在这儿不管用**：卡片从竖排改横排之后，
     主轴变横的，`align-self` 管的就是**竖**轴（拉高），**宽度**是收着走的 ——
     实测卡头只有 319 宽，「章节管理」被卡在 x=836 上，离右缘差了 836。
     要它铺满这一行得用 `flex: 1 1 auto`（主轴 = 横），两回事。 */
body[data-ardot="9:187"] [data-name="章节卡"] [data-name="卡头"] {
  flex: 1 1 auto;
  min-width: 0;
}
/* 标签「章节」：帧里是 `flex:1 1 0`（吃掉整行剩余宽度），
   横排里它会撑出一大块空白，把下拉推到中间去 —— 改成按内容宽。 */
body[data-ardot="9:187"] [data-name="章节卡"] [data-name="卡头"] > [data-name="标题"] {
  flex: 0 0 auto;
  white-space: nowrap;
}
/* 下拉药丸：**吃掉剩余宽度、封顶 360**。
   ⚠ 用 `flex: 1 1 auto` 而不是「按内容宽」，是为了**宽度别跳**：
     按内容宽的话，换一章药丸就跟着缩一截，每选一次控件都动一下。
     吃掉剩余宽度之后它是个定值（桌面 360 / 手机占满），换选项时一个字都不动。
   ⚠ 360 是照着**最长那个章节名**定的：19 个字（「毛泽东思想和中国特色社会主义
     理论体系概论」）× 14px ≈ 266，加左右内边距 32、ChevronDown 24、间隔 8 = 330，
     360 装得下还有余。再长就收省略号 —— 名字本身在下拉里是完整的。
   ⚠ `min-width: 0` 不能省：药丸里那格「值」要能收省略号，
     而 flex 项的默认 `min-width:auto` 会把它顶住不让缩。 */
body[data-ardot="9:187"] [data-name="章节卡"] [data-name="卡头"] > .admin-chapselect {
  flex: 1 1 auto;
  min-width: 0;
  max-width: 360px;
}
/* 药丸里那格「值」：章节名比药丸长时收省略号（名字本身在系统选择器里是完整的，
   这里截的是**回显**，不是一个选不到的名字）。 */
body[data-ardot="9:187"] .admin-chapselect > [data-name="值"] {
  flex: 0 1 auto;
  min-width: 0;
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
}
/* 「章节管理」那枚按钮推到最右 —— 它是这一屏章节的**唯一入口**
   （「+ 新增章节」并进了面板里面）。 */
body[data-ardot="9:187"] [data-name="章节卡"] [data-name="卡头"] > [data-name^="Component/Button"] {
  flex: 0 0 auto;
  margin-left: auto;
}
/* 题库没取到的时候下拉是停用的（控制器把 `disabled` 打在原生 select 上）。
   显式画出来：能点但点下去什么也不发生，比看着就是灰的坏得多。 */
body[data-ardot="9:187"] .admin-chapselect[aria-disabled="true"] {
  opacity: 0.5;
  cursor: default;
}
/* 键盘焦点：原生 `<select>` 是**透明**盖在药丸上的，焦点环得画在药丸这一层
   （否则键盘用户 Tab 过来只看见一个没变化的药丸）。`focus-within` 认的就是
   里面那枚 select 拿到焦点。 */
body[data-ardot="9:187"] .admin-chapselect:focus-within {
  box-shadow: inset 0 0 0 1px var(--cl-primary-700), 0 0 0 2px var(--cl-primary-100);
}

/* ── ④ 中栏：全屏唯一的滚动区 ───────────────────────────────────
 * ⚠ 滚的是**里面那张列表**（`试题列表`），不是中栏自己 —— 这样上面的
 *   「试题列表 / 搜索 / 共 N 题」那一行**钉住不动**，只有题目在滚。
 *   改前是整栏一起滚，搜索框会跟着题目滚出屏幕。 */
body[data-ardot="9:187"] .admin-shell-main .admin-split--3 > .admin-split-mid {
  grid-column: 1;
  grid-row: 2;
  overflow: hidden;
  padding-inline: var(--sp-6);
}
body[data-ardot="9:187"] .admin-shell-main .admin-split-mid > [data-name="试题列表头"] {
  padding-block: var(--sp-3);
}
/* 搜索框**封顶**。
 * ㊿-5 那条给它的是 `flex: 1 1 0`（吃掉剩余宽度）—— 那是按 480 的中栏调的，
 * 在那里它刚好 278 宽。现在中栏 1155，同一个 `flex: 1 1 0` 会把它拉成 1039 宽：
 * 一个搜索框占满一整行，看着像「这一屏只有搜索」，而且输入位长得荒唐。
 * 420 是「够敲一句题干关键词、又不喧宾夺主」的宽度；封顶之后
 * 「共 N 题」紧跟在它右边（原来被挤到最右端，离搜索框一千多像素远）。
 * ⚠ 只封桌面档：手机档那一行 393 宽，本来就到不了 420，一个字不用改。 */
body[data-ardot="9:187"] .admin-shell-main .admin-split-mid > [data-name="试题列表头"] > .admin-qsearch {
  max-width: 420px;
}
body[data-ardot="9:187"] .admin-shell-main .admin-split-mid > [data-name="试题列表"] {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  padding-bottom: var(--sp-4);
  scrollbar-width: thin;
  scrollbar-color: rgba(15, 23, 42, 0.28) transparent;
}

/* ── ⑤ 右栏：常驻一栏 → 从右边滑出来的抽屉 ────────────────────────
 * 关着的时候 `translateX(100%)` 整个推到分栏根外面（分栏根 `overflow:hidden`，
 * 所以它连一像素都不占），`visibility:hidden` 是为了把里面所有控件
 * **从键盘 tab 顺序里摘出去** —— 光靠 transform 移出屏幕，读屏和 Tab 还是够得着，
 * 那不是「关掉了」，是「看不见」。
 * ⚠ `visibility` 的过渡要**延迟到 transform 走完**（`0s linear 220ms`），
 *   不延迟的话关的一瞬间就 hidden，滑出动画根本看不见；
 *   开的时候反过来要**立即**可见（`0s`，无延迟）。
 * ⚠ 抽屉不铺遮罩（scrim）：列表右半边还露着 723px，点另一道题直接换内容，
 *   比「先关再点」顺手。关是抽屉自己右上角那枚 ✕。 */
body[data-ardot="9:187"] .admin-shell-main .admin-split--3 > .admin-split-right {
  position: absolute;
  inset: 0 0 0 auto;
  width: min(480px, 62%);
  z-index: 3;
  border-left: 1px solid var(--cl-border-base);
  box-shadow: -18px 0 44px -28px rgba(15, 23, 42, 0.45);
  transform: translateX(100%);
  visibility: hidden;
  transition: transform 220ms cubic-bezier(0.2, 0, 0, 1), visibility 0s linear 220ms;
}
body[data-ardot="9:187"] .admin-shell-main .admin-split--3.is-drawer-open > .admin-split-right {
  transform: none;
  visibility: visible;
  transition: transform 220ms cubic-bezier(0.2, 0, 0, 1), visibility 0s;
}
/* 抽屉右上角那枚关闭（控制器造的，只此一处用）。
   32×32 是**桌面**下限 24 之上、44 之下取的中 —— 这是鼠标档的次要动作，
   硬撑 44 会在详情标题旁边糊一大块。 */
body[data-ardot="9:187"] .admin-shell-main .admin-split-right > .admin-drawer-close {
  position: absolute;
  top: var(--sp-3);
  right: var(--sp-3);
  width: 32px;
  height: 32px;
  display: grid;
  place-items: center;
  padding: 0;
  border: 0;
  border-radius: var(--rd-md);
  background: transparent;
  color: var(--cl-text-secondary);
  font-size: 16px;
  line-height: 1;
  cursor: pointer;
}
body[data-ardot="9:187"] .admin-shell-main .admin-split-right > .admin-drawer-close:hover {
  background: var(--cl-primary-50);
  color: var(--cl-text-primary);
}
body[data-ardot="9:187"] .admin-shell-main .admin-split-right > .admin-drawer-close:focus-visible {
  outline: 2px solid var(--cl-primary-700);
  outline-offset: -2px;
}
/* 系统开了「减少动态效果」就不滑，直接出现 —— 这一条是无障碍红线，不是客套。 */
@media (prefers-reduced-motion: reduce) {
  body[data-ardot="9:187"] .admin-shell-main .admin-split--3 > .admin-split-right,
  body[data-ardot="9:187"] .admin-shell-main .admin-split--3.is-drawer-open > .admin-split-right {
    transition: none;
  }
}

/* ══════════════════════════════════════════════════════════════════════════
 * ㊿-10 · 后台表单的字号、行距、间距收一圈（2026-10-02 第五十六轮）
 *
 * 机器主人原话：「**字号小一点，尤其是题目编辑间距，行间距、设计美观，好用**」。
 * 主语是**题目编辑**那张表（题库详情抽屉里点一行出来的那张）。
 *
 * ── 先量，再改（1455×750，题目编辑那张表）────────────────────────────
 *   表单**总高 960**，视口只有 750 ⇒ 它自己滚 210，「保存」在折线以下。
 *   960 花在哪（`probe-h.py` 逐块量的，不是我估的）：
 *     选项块 268 · 字段间距 11×16 = 176 · 题干 116 · 解析 116
 *     · 章节 76 · 答案 76 · 题型 72 · 外壳 padding 52 · 按钮行 48 · 标题 24
 *   两处最不值：**题干/解析各 116**（多行框下限=稿子画的 88）、
 *   **字段间距 176**（11 道缝 × 16）。
 *
 * ── 两件事分开做，别混成一件 ─────────────────────────────────────────
 *   ① **「字号 16px」不是口味问题，是被压住了** —— 帧要给 15px/22px，
 *      被 ㉒ 那条 reset 的更高特异性换成了 16px/normal。
 *      已在 ㉒ 就地修掉 ⇒ **全后台生效（含手机）**，这里不重复。
 *   ② 桌面抽屉里那张表**再收一圈**，才是这一节。
 *
 * ── ⚠ 只挂 `.admin-split-detail`（电脑档），手机档一个字不动 ───────────
 *   手机档没有分栏、表单是底部弹层（`.dialog-gaozi`），走不到这儿。
 *   那边的 44px 触控下限、48px 控件高**原样保留** —— 那档是真的用手指点。
 *
 * ── ⚠ 一个数一个出处，别在这里散着写 ─────────────────────────────────
 *   下面这 6 个变量是这张表**唯一的起点**：要调就改这儿，别再往规则里塞数字。
 *   ⚠ 选项行高**不在这个列表里，也不许加进来**：它是 44（`--sz-tap-min`），
 *     按答案那一列是**主操作**（「点选项直接选」就是这一屏的核心动作），
 *     44 是 HIG 的触控底线。它占的那 8px 我宁可留着，不拿命中率换高度。
 * ══════════════════════════════════════════════════════════════════════════ */
body[data-ardot="9:187"] .admin-shell-main .admin-split-detail .dialog-inline {
  --adm-fs: 14px;        /* 控件里的字（原 16px，见上面 ①）*/
  --adm-fs-label: 12px;  /* 字段标签（原 13px，稿子的 --fs-caption）*/
  --adm-lh: 20px;        /* 控件里一行的行高（原来是 normal，等于没管）*/
  --adm-h: 40px;         /* 单行控件高（原 48px = --sz-control）*/
  --adm-area: 56px;      /* 多行框下限（原 88px = 稿子给刷题页「详细描述」画的）*/
  --adm-seam: 10px;      /* 字段与字段之间的缝（原 16px = --sp-4）*/

  /* 外壳：稿子是 20/20/32，抽屉里四周各收一圈，底下多留一点（按钮行要呼吸）*/
  padding: var(--sp-4) var(--sp-4) var(--sp-6);
  /* 缝：11 道缝省下 66px，是全表最大的一笔 */
  gap: var(--adm-seam);
}

/* 标题：「编辑题目」（稿子 17/24）。它比正文大一档就够了，不占 24 的高。 */
body[data-ardot="9:187"] .admin-shell-main .admin-split-detail
  .dialog-inline [data-name="纠错管理"] {
  font-size: 15px;
  line-height: 20px;
}

/* 字段与字段之间那条缝也走同一个数：
   ⚠ 两处都写，是因为 `.admin-field` 自己的 `gap`（`--sp-2`）管的是
     **标签与控件之间**那一缝，跟这条不是同一个 —— 别合并成一条。
   ⚠⚠ 必须 `:not(.admin-field--readonly)`。**这一条我第一版漏了，当场量出来**：
     `.admin-field` 是**竖排**（label 在上、控件在下），收 gap 收的是竖缝；
     可**只读**那几行被另一条改成**横排**（label 在左、值在右），同一个 `gap`
     在横排里管的就是**横缝** —— 于是值的左缘从 1089 挪到 1075，
     跟网格行（1085）、选项文字（1085）**差开 10px**（正好是 16−6）。
     `probe-admin-split.py` 那条「右栏里值只有一条起点」当场红，
     报的是 `值 [1075, 1085]` —— 尺子抓到的是真伤，不是尺子坏了。 */
body[data-ardot="9:187"] .admin-shell-main .admin-split-detail
  .dialog-inline .admin-field:not(.admin-field--readonly) {
  gap: 6px;
}

/* 字段标签：「题型 / 章节 / 题干（必填）…」（稿子 13/20 → 12/16）*/
body[data-ardot="9:187"] .admin-shell-main .admin-split-detail
  .dialog-inline .admin-field > [data-name="类型提示"] {
  font-size: var(--adm-fs-label);
  line-height: 16px;
}

/* 单行控件 + 下拉：把壳压矮。
   ⚠ 靶子必须是**壳**（`Component/Input/Default` / `Component/Select/Default`），
     不是里面那个 `<input>` —— 稿子把高钉在壳上（`.n-8-1095{height:var(--sz-control)}`
     / `.n-9-231{height:var(--sz-tap-min)}`），只压 `<input>` 那个壳照样 48 高。 */
   ⚠ 必须 `:not(.admin-field--readonly)`：只读那几行的壳有它自己的一条
     （`.admin-split-detail .admin-field--readonly > [data-name=…] {height:auto;
     min-height:0}`，见上面「宿主化的表单」那一段），而**这条特异性比它高**
     （(0,6,1) 对 (0,3,0)）—— 不排除的话只读行会被一起压成 40 高，
     本该随内容长长的东西变成一个死高的框，且不报错。 */
body[data-ardot="9:187"] .admin-shell-main .admin-split-detail
  .dialog-inline .admin-field:not(.admin-field--readonly) > [data-name="Component/Input/Default"],
body[data-ardot="9:187"] .admin-shell-main .admin-split-detail
  .dialog-inline .admin-field:not(.admin-field--readonly) > [data-name^="Component/Select/Default"] {
  height: var(--adm-h);
  min-height: var(--adm-h);
}

/* 多行框（题干 / 解析）：稿子画死 88 高 + 隐藏溢出，那是给刷题页「详细描述」
   画的两行框；㉓ 已经把它放开成 auto（内容长了就往下长），这里只把**下限**
   从 88 收到 56 —— 短内容时不再白占 88 的坑，长的照样长。
   ⚠ 只动 `min-height`：`height:auto` 那条（㉓）必须留着，不然就是回到
     「自己打的字看不见」。 */
body[data-ardot="9:187"] .admin-shell-main .admin-split-detail
  .dialog-inline .admin-field--area:not(.admin-field--readonly) > [data-name="Component/Input/Default"] {
  min-height: var(--adm-area);
}

/* 控件里的字：14px / 行高 20。这是这一屏「字变小了」真正落地的一条 ——
   ①②两处都只是把这个值准备好，落到控件身上的是这条。 */
body[data-ardot="9:187"] .admin-shell-main .admin-split-detail
  .dialog-inline .admin-field [data-name="表单输入"],
body[data-ardot="9:187"] .admin-shell-main .admin-split-detail
  .dialog-inline .admin-field [data-name="表单文本域"] {
  font-size: var(--adm-fs);
  line-height: var(--adm-lh);
}

/* 下拉里显示当前值那格（稿子 15/22）。原生 `<select>` 是透明浮层，那一格才是看得见的。 */
body[data-ardot="9:187"] .admin-shell-main .admin-split-detail
  .dialog-inline .admin-field > [data-name^="Component/Select/Default"] > [data-name="值"] {
  font-size: var(--adm-fs);
  line-height: var(--adm-lh);
}

/* 选项行的「添加选项」和徽标里的字母：跟着正文收一档，
   不然一屏里 14px 的题干配 15px 的「添加选项」，两个都显眼。
   ⚠ 只改字，**高度一概不动**（见文件头那条：44 是触控底线）。 */
body[data-ardot="9:187"] .admin-shell-main .admin-split-detail
  .dialog-inline .admin-optadd > * {
  font-size: 13px;
  line-height: 18px;
}

/* 底部按钮行：「取消 / 保存」。
   ⚠ 高度从 48 收到 40，但**不改 `--sz-tap-min`**（那是全局令牌，手机档要用）。
   40 是鼠标档的尺寸：这一屏是桌面抽屉，HIG 桌面下限是 24。 */
body[data-ardot="9:187"] .admin-shell-main .admin-split-detail
  .dialog-inline .admin-form-actions > * {
  height: 40px;
}
body[data-ardot="9:187"] .admin-shell-main .admin-split-detail
  .dialog-inline .admin-form-actions [data-name^="Component/Button"] > * {
  font-size: var(--adm-fs);
  line-height: var(--adm-lh);
}

/* ── 底部那行按钮**钉在抽屉底**（「好用」的那一半账）────────────────────
 * 上面把表从 960 收到 766，抽屉只有 750 —— 还是差 156：
 * 「取消 / 保存」在折线以下，改完一道题要先滚到底才按得着。
 * 收型号只能收到 766（再收就要动选项行的 44 —— 那是答案那一列的触控底线，
 * 拿命中率换高度不划算），所以这一截不再从**字**上省，改成**让它跟着走**。
 *
 * ⚠ 这一条能成立，靠的是两件事，动其中一个它就静默失效：
 *   · 滚动必须发生在 `.admin-split-right`（抽屉自己滚，见 ㊿-8 ⑤）——
 *     sticky 是相对**最近的那个滚动祖先**粘的，滚动方换到别处它就不跟了；
 *   · `.dialog-inline` 的 `overflow` 必须是 `visible`（上面「宿主化的表单」
 *     那条给的）。改成 `hidden` 的话，**它自己就成了粘附容器**，
 *     而它并不滚动 ⇒ 表现为「这条规则写了但一点用没有」，不报任何错。
 * ⚠ 底色必须给：不给的话滚动时底下的字会从按钮缝里透出来（试过）。
 * ⚠ 上边留 8 的底衬，免得内容紧贴着按钮上沿被切一半。
 *
 * ── 下面这一条是上面那条的**前提**，少一条它就少钉 20px ──────────────
 * 第一版钉出来是「按钮行离抽屉底还差 20px，缝里露出下一截字段」。
 * 量出来的原因：`.admin-split-right` 自己带 `padding: var(--sp-5)`（20），
 * 而 sticky 的 `bottom: 0` 认的是**滚动容器的内容盒**，不是它看得见的边
 * ⇒ 钉在 714，容器底沿在 734，714~734 那 20px 是**内边距条**；
 * ⚠ 内边距条**不裁滚动内容**（`overflow` 只裁到 padding box 的外沿），
 *   所以滚上来的字照样在缝里露半行 —— 看着像按钮行没贴底。
 * ⇒ 抽屉里**开着表单**时，把这条 20 收成 0；底下那点呼吸由表单自己的
 *   `padding-bottom`（`--sp-6` = 24）出，比原来两条叠加的 44 更紧凑。
 * ⚠ 只认 `:has(.dialog-inline)`：只读详情那一态照样留着 20，一像素不动。
 * ⚠ `:has()` 的特异性取它参数里最具体的那个（(0,1,0)），加前面那串
 *   (0,6,1) ⇒ 总 (0,7,1)，压得住基础那条 (0,1,0)。 */
body[data-ardot="9:187"] .admin-shell-main .admin-split--3.is-drawer-open
  > .admin-split-right:has(.dialog-inline) {
  padding-bottom: 0;
}

body[data-ardot="9:187"] .admin-shell-main .admin-split-detail
  .dialog-inline .admin-form-actions {
  position: sticky;
  bottom: 0;
  z-index: 1;
  padding-top: var(--sp-2);
  background: var(--cl-bg-card);
  /* 一条发丝线把「按钮那一栏」和「正在滚的内容」分开 —— 不给的话按钮行
     和上面的字段底色一样，滚起来看着像字段上浮着两个按钮。
     用 `box-shadow` 不用 `border`：这行没有写死高度（高由里面两枚 40 的按钮
     撑出来），`border-top` 会**往上长 1px**、把整条栏和它底下的间距一起顶一格；
     `box-shadow` 不占布局，画在盒子外面。 */
  box-shadow: 0 -1px 0 var(--cl-border-light);
}

/* ══════════════════════════════════════════════════════════════════════════
 * ㊿-11 · IP 黑名单：桌面档从「一张张卡」翻回「一行一条」（2026-10-02 第五十六轮）
 *
 * 这一屏的帧 CSS（`web/assets/pages/admin-ip-blacklist.css`）写的是**手机档**：
 * 每条记录是一张卡，竖着四行（IP / 原因 / 元信息 / 操作），行距 8。
 * 手机上那是对的 —— 1203 宽铺到桌面上就不对了：
 *
 *   实测（1455×750，21 条记录）：**一张卡高 172**，加 12 的缝 ⇒ 一屏只看得到
 *   **3 条**，滚完 21 条要 4.8 屏。对照同一台机器上的另外两屏（也实测）：
 *   卡片管理 / 文章管理那一行 **61**、一屏 9~10 条。改完这一屏是 **61 / 9 条**。
 *   这不是「好不好看」，是**后台是拿来扫的，密度就是效率**。
 *
 * ⚠ 做法**照抄 ㊲ 给卡片管理 / 文章管理做的那一套**，不另发明：
 *     行改成 grid、`min-height: 48`、去圆角去阴影、底下一条分隔线不要卡边，
 *     悬停给品牌浅紫、`::before` 那些列名前缀在桌面档关掉。
 *   连列宽都照同一个算法：**除文字列外全是定值，文字列给 `minmax(下界, 1fr)`**
 *   —— 下界不能是 0（㊲ 那段「整列成一根竖线」的账就是这么来的）。
 *
 * ⚠ 选择器全部收在 `.admin-shell-main` 里：**壳只在电脑上挂**
 *   （app.js 的 desktopAdmin），所以这就是「桌面档」的判据，不需要别的开关。
 *   手机档一个像素都不动 —— 那边是 `?desk=0`，根本没有 `.admin-shell`。
 * ────────────────────────────────────────────────────────────────────────── */

/* 外框内边距与卡片管理 / 文章管理对齐（16 → 24） */
body[data-ardot="admin-ip-blacklist"] .admin-shell-main .bk-body {
  padding: var(--sp-6);
  gap: var(--sp-4);
}

/* ── 工具栏：两层改一层（搜索吃满，计数与「+ 添加IP」贴右）──────────────
 * 手机档竖排是对的（一行放不下），桌面档那条空行白占 56 —— 跟 ㊲ 的
 * `.ac-toolbar` 同一条改法。 */
body[data-ardot="admin-ip-blacklist"] .admin-shell-main .bk-bar {
  flex-direction: row;
  align-items: center;
  gap: var(--sp-3);
}
body[data-ardot="admin-ip-blacklist"] .admin-shell-main .bk-search {
  flex: 1 1 0;
  min-width: 0;
}
body[data-ardot="admin-ip-blacklist"] .admin-shell-main .bk-tools {
  flex: 0 0 auto;
  gap: var(--sp-3);
}

/* ── 一行一条 ──────────────────────────────────────────────────────────
 * 四列：IP 定宽 · 原因吃剩下 · 元信息定宽 · 操作定宽。
 * 固定段 190 + 200 + 124 + 三道 gap(48) + 行内边距(32) = **594**，
 * 1203 的可用宽里「原因」那一列拿 **577** —— 够放「同一秒提交 40 道题（脚本刷题）」
 * 这句（实测最长的一句约 340px）。 */
body[data-ardot="admin-ip-blacklist"] .admin-shell-main .bk-rows {
  gap: 0;
}
body[data-ardot="admin-ip-blacklist"] .admin-shell-main .bk-row {
  display: grid;
  align-items: center;
  gap: var(--sp-4);
  padding: var(--sp-2) var(--sp-4);
  /* 48 在 --sz-tap-min（44）之上留一点余量；跟 ㊲ 那张表同一个数 */
  min-height: 48px;
  grid-template-columns: 190px minmax(240px, 1fr) 200px 124px;
  /* 卡片的形状去掉：桌面档这张表是**一整块**，不是一堆卡 */
  background: transparent;
  border-radius: 0;
  box-shadow: none;
  /* 分隔线用 border-base 不用 border-light：后者对白底只有 1.13:1，看不见（㊲ 量过） */
  border-bottom: 1px solid var(--cl-border-base);
}
body[data-ardot="admin-ip-blacklist"] .admin-shell-main .bk-row:hover {
  /* 悬停用品牌浅紫而不是灰 —— 灰在这个系统里是「不可用」的语义 */
  background: var(--cl-primary-50);
}

/* 桌面档一行一条：格子不许换行，超了收省略号（手机档要能换行，那是另一档） */
body[data-ardot="admin-ip-blacklist"] .admin-shell-main .bk-cell {
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
/* 「原因：」那个前缀是手机档用来**顶替表头**的（这一屏没有表头行）。
   桌面档四列已经对齐、位置固定，前缀就成了每行都重复两遍的噪音。 */
body[data-ardot="admin-ip-blacklist"] .admin-shell-main .bk-cell--reason::before {
  content: none;
}
/* 操作那一格：两枚按钮横排、贴右。
   `margin-top: 0` 压在帧那条 `margin-top: var(--sp-1)` 上 —— 那 4px 在手机档是
   「操作行与元信息行之间的距」，在桌面档它们是**同一行的两列**，
   留着只是把这一行撑高 4px。 */
body[data-ardot="admin-ip-blacklist"] .admin-shell-main .bk-cell--ops {
  display: flex;
  flex-direction: row;
  flex-wrap: nowrap;
  align-items: center;
  justify-content: flex-end;
  gap: var(--sp-4);
  margin-top: 0;
}
/* 行上那两枚按钮：48（`--sz-control`，手机档的舒适高）→ **44**。
   44 不是随手挑的：它是 HIG 的可点目标下限、也是这一屏的 `--sz-tap-min`，
   而且**桌面档那排行内文字按钮本来就是 44**（① 那条 `.gaozi-act` 与 `.admin-ops`）
   —— 卡片管理 / 文章管理那两张表的行高就是 **60**（实测）。
   不改的话我这一行是 69，比同一个后台里别的表多 9px。
   ⚠ `--sz-control` 是**各屏共用的词汇**，不能去改它，只能在这一屏的桌面档里收。 */
body[data-ardot="admin-ip-blacklist"] .admin-shell-main .bk-cell--ops .bk-btn {
  height: 44px;
}
/* ⚠⚠ 空格子在桌面档**必须留着**（`display: block`）—— 它是网格里的一列，
   让它位之后右边的列会整体左移一格（「原因」空的那条，IP 后面直接接上了元信息，
   而「复制/移出」跑到了元信息那一列）。跟 ㊲ 那条是同一个坑。
   ⚠ 必须带 **`!important`**，否则这一条**根本不生效**：
   空值是控制器写的（`view.js` 的 `setTextOrHide`），它把
   `node.style.display = "none"` **写成行内样式** 并挂上 `hidden` 属性。
   行内样式比任何选择器都硬（除非对方也 `!important`），
   所以只写 `display: block` 的话这条规则是**隐形的** —— 看代码像写对了，
   屏幕上照样错位、且不报任何错。已用探针量到（那一行的四格左缘
   是 `260 / 466 / 1075`，少了一个）。 */
body[data-ardot="admin-ip-blacklist"] .admin-shell-main .bk-row .bk-cell:empty {
  display: block !important;
}

/* ══════════════════════════════════════════════════════════════════════
 * ㊿-12 试题检索（`9:223`）桌面档：一屏一行、四列对齐（2026-10-02）
 *
 * 这一屏是「后台逐屏按 HIG 重排」里**最后一屏**没碰过的 —— 改之前
 * `app.css` 里带 `9:223` 的规则**一条都没有**。它跟题库管理/组织管理/公告管理
 * 是**同一副骨架**（`后台列表卡（手机）`：顶行「主标题 + 徽标」、
 * 次行「字段1 弹性 操作」），量到的样子也和那三屏改前一模一样 ——
 * 手机那套卡片被直接拉到 1203 宽（1455×750 实测）：
 *
 *   · 一条 **108** 高，一屏只放得下 3 条；同一次搜索里还有 **152** 的
 *     （次行带 `flex-wrap: wrap`，题干一长，「编辑/删除」就掉到第三行去，
 *      `.admin-ops-line` 那条 `flex-wrap` 是给手机写的）；
 *   · 题干在第 1 行左端，**题型徽标却甩到第 1 行的最右端**
 *     （x=1374，离题干尾巴 1122）—— 中间全是「弹性」撑出来的空气；
 *   · 「编辑 / 删除」掉在第 2 行右端；
 *   · 卡片白底压在近白的页面上，**行的边界看不见**；
 *   · 两个筛选药丸各占 **596** 宽，里面写着「全部题库」四个字。
 *
 * ── 为什么这屏用**栅格**，不像 ㊿-1 给题库管理那三屏用 flex ──────────
 * ㊿-1 那三屏的行是「名字 + 两个事实 + 三枚动作」，名字短（最长 11 字），
 * 顶行给 42% 就够，所以走「[标题+徽标] [事实…动作]」两块横排。
 * 这一屏第一列是**题干全文**（几十字的长句），是这一屏唯一要读的东西，
 * 该拿到最宽的一列；而「题型」是个**可扫的属性**，值得单独成一列对齐 ——
 * flex 那套里徽标紧跟在题干尾巴后面，每行题干长短不同，徽标就排成一条**斜线**。
 * 所以照抄的是 ㊲（卡片管理/文章管理）和 ㊿-11（IP 黑名单）那一套：
 * **行高 61、四列栅格、发丝分隔线、悬停品牌浅紫**。
 * 61 不是随手写的：8+8 内边距 + 44 的动作行 + 1 分隔线，跟卡片管理逐字节同档。
 *
 * ── 顶行 / 次行在桌面档**不生成盒子**（`display: contents`）─────────
 * 四列里有两列（题干、题型）是「顶行」的孩子，另两列（题库·章节、操作）
 * 是「次行」的孩子。不把这两层摊平，栅格就只认到两个格子，题干和徽标
 * 只能挤在同一格里 —— 那就又回到「徽标排成一条斜线」了。
 * `display: contents` 让四个孙节点直接成为栅格项，**每一列写死列号**。
 *
 * ⚠ 列号写死，跟 ㊲ 那条 `:empty { display: block !important }` 是
 *   **同一件事的两个方向**：这一屏的「字段2 / 字段3 / 弹性」三格控制器一律
 *   `display: none`（`view.js` 的 `setTextOrHide` 写的是**行内样式**），
 *   自动放置会把后面的格子往左挪 —— 写死列号就不存在「让位」这回事。
 *   所以这里**不需要**那条 `!important`。（IP 黑名单那屏是自动放置，
 *   才必须补 `!important` 把空格子按回网格里。）
 *
 * ⚠ 手机档一个字不动：`body[data-ardot]` + `.admin-shell-main` 双重限定，
 *   跟 ㊿-1 到 ㊿-11 同一个写法。手机壳整屏换、**根本不挂 `.admin-shell`**。
 * ⚠ 窄窗口：桌面壳是按「主指针是不是鼠标」认的、**不看窗口宽度**
 *   （见 `app/device.js` 开头那段），所以把窗口往回拖会撞上这一屏。
 *   四列的固定段 84 + 280 + 124 = 488，加三道缝 48、行内边距 32，
 *   题干列下界 240 ⇒ 整行要 **808**。视口窄于约 1060 时一行装不下，
 *   那时让**列表自己横向滚**（鼠标在，滚动条能用），
 *   而不是把题干压成一根竖线 —— ㊲ 那段「整列成一根竖线」的账就是这么来的。
 * ══════════════════════════════════════════════════════════════════════ */
body[data-ardot="9:223"] .admin-shell-main [data-name="列表"] {
  /* 行与行之间不留缝：分隔线是自己画的，再留 12 的缝就成了「一叠卡片」。 */
  gap: 0;
  /* 窄窗口的退路：整行装不下时列表横向滚（见上面那段算式）。 */
  overflow-x: auto;
}
body[data-ardot="9:223"] .admin-shell-main [data-name="列表"] > .admin-row {
  display: grid;
  align-items: center;
  gap: var(--sp-4);
  /* 除题干外全是定值 —— 列与列才可能跨行对齐。先写 min-width，不写 width：
     行宽仍旧跟着容器走（`align-self: stretch`），窗口再宽也是铺满。 */
  grid-template-columns: minmax(240px, 1fr) 84px 280px 124px;
  min-width: 808px;
  margin: 0;
  padding: var(--sp-2) var(--sp-4);
  /* 44 的动作行 + 8+8 内边距 + 1 分隔线 = 61，跟卡片管理/文章管理同档。
     这里写 min-height 而不是 height：将来这一屏多一格（比如加个「纠错数」），
     行该自己长高，而不是把内容裁掉。 */
  min-height: 48px;
  background: transparent;
  border-radius: 0;
  box-shadow: none;
  /* 分隔线用 border-base 不用 border-light：后者对白底只有 1.13:1，看不见（㊲ 量过）。 */
  border-bottom: 1px solid var(--cl-border-base);
}
body[data-ardot="9:223"] .admin-shell-main [data-name="列表"] > .admin-row:hover {
  /* 悬停用品牌浅紫而不是灰：灰底在这个系统里是「不可用」的语义（㊲ 的账）。 */
  background: var(--cl-primary-50);
}
/* 顶行 / 次行摊平：不生成盒子，四个孙节点直接落进上面那套栅格。 */
body[data-ardot="9:223"] .admin-shell-main .admin-row > [data-name="顶行"],
body[data-ardot="9:223"] .admin-shell-main .admin-row > [data-name="次行"] {
  display: contents;
}
/* 第 1 列 · 题干。长度全屏不一，所以这一列是唯一会长会缩的那列；
   `min-width: 0` 不能省 —— 不写的话栅格项的 min-content 会把这一列顶宽，
   `minmax` 的下界就白写了（手机上这条是老账，㊲ 里记得更细）。 */
body[data-ardot="9:223"] .admin-shell-main .admin-row [data-name="主标题"] {
  grid-column: 1;
  min-width: 0;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
/* 第 2 列 · 题型徽标。
   ⚠ 用 `^=` 不是 `=`：控制器按优先级挑徽标（先 Success 后 Primary，
   见 `admin-list.js` 的 `setBadge`），写死某一种，另一种出现时就落到自动放置上，
   整行的列会跟着错。 */
body[data-ardot="9:223"] .admin-shell-main .admin-row [data-name^="Component/Badge/"] {
  grid-column: 2;
  justify-self: start;
}
/* 第 3 列 · 「题库 · 章节」。 */
body[data-ardot="9:223"] .admin-shell-main .admin-row [data-name="字段1"] {
  grid-column: 3;
  min-width: 0;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
/* 第 4 列 · 操作。`flex-end` 而不是靠 `justify-self`：这一格是 `.admin-ops`
   （`display:flex`），让它铺满 124 的列、里面的按钮贴右边 —— 按钮文案变长
   也只会用掉列里的余量，不会整格往左溢出去。 */
body[data-ardot="9:223"] .admin-shell-main .admin-row [data-name="操作"] {
  grid-column: 4;
  justify-content: flex-end;
}

/* ㊿-13 试题检索桌面档：搜索行与筛选行并成一行（2026-10-02）
 *
 * 手机上「搜索行（搜索框 + 搜索按钮）」和「筛选行（题库 / 题型两个药丸）」
 * 是上下两行（各 44 高 + 一道 16 的缝 = 104）。到 1203 宽还分两行，
 * 代价不只是那 60px：两个药丸各摊到 **596** 宽，一格「全部题库」四个字
 * 占六百像素，看着像输入框，点开才发现是选择器。
 *
 * 并成一行：`[搜索框 …………………………………] [搜索] [全部题库 ▾] [全部题型 ▾]`
 * （搜索框 1fr，两个药丸各 240 —— 最长的题库名「毛泽东思想和中国特色
 *  社会主义理论体系概论」21 字在 13px 下约 273，装不下会截 —— 所以给 240
 *  的是**药丸本身**，点开是系统自己的选择器，一行放不下它会自己折行；
 *  药丸里露出的那截按 `ellipsis` 收尾。）
 *
 * ⚠ 不给「内容区」换 `display`：它同时是**滚动容器**
 *   （`[data-scroll="1"]`，`overflow: hidden` 是 `!important` 的，
 *   见 app.css 上面那一段），改成 grid 会把滚动语义一起换掉。
 *   所以只用 `flex-wrap`：让「列表」「页脚」各占一整行（`flex-basis: 100%`），
 *   上面两个行内元素自己并到第一行。
 * ⚠ `flex-wrap` 不是可选的：窗口窄下来时这两块并排装不下，
 *   得让它们**体面地换回两行**，而不是把搜索框压成一条缝。
 * ⚠ 「内容区」的内边距**保持在 16**，不学 ㊲/㊿-11 收成 24：
 *   这一屏的面包屑左缘在 236（实测），内容区也是 236，两者是**对齐**的；
 *   改成 24 会让内容比面包屑缩进 8px，凭空造出一处不对齐。
 *   （题库管理 9:115 是同一副骨架，量过也是 16/236。）
 * ⚠ 手机档一个字不动（双重限定，同上）。 */
body[data-ardot="9:223"] .admin-shell-main [data-name="内容区"] {
  /* ⚠ `flex-direction: row` 一个字都不能省：稿子写的是 `column`，
     光加 `flex-wrap: wrap` 就成了「竖排 + 换列」—— 四块变成**四列**，
     整屏横向溢出 833px（实测），而且**不报任何错**。 */
  flex-direction: row;
  flex-wrap: wrap;
  align-content: flex-start;
}
body[data-ardot="9:223"] .admin-shell-main [data-name="搜索行"] {
  flex: 1 1 320px;
  min-width: 0;
}
body[data-ardot="9:223"] .admin-shell-main [data-name="筛选行"] > [data-name="Component/Select/Default"] {
  flex: 0 0 240px;
}
body[data-ardot="9:223"] .admin-shell-main [data-name="列表"],
body[data-ardot="9:223"] .admin-shell-main [data-name="页脚"] {
  flex: 0 0 100%;
}

/* ── ㊿-15 桌面后台的按钮：只有一档 44（2026-10-02）─────────────────────
 *
 * 机器主人这一轮的原话：「……按钮乱放、没层级、风格各异……」
 *
 * ── 量出来的底账（1455×750，15 屏，改之前）──────────────────────────
 *   高 44 × 189 枚 ｜ 高 48 × 62 枚 ｜ 高 32 × 34 枚 ｜ 高 40 × 5 枚
 *   同一个组件（`Component/Button/*`）、同一个语义，在**帧 CSS 里各写各的
 *   高**（`web/assets/pages/*.css`，一屏一个文件）：
 *     · 「上一页」——分栏那 6 屏是 32（`10-127.css:82` 写死 `height:32px`）、
 *       不分栏的 5 屏是 48（`9-115.css:92` 写的是 `var(--sz-control)`）
 *     · 「+ 新增章节」——卡片管理/文章管理 48、题库详情 32
 *     · 「复制」「更多⋯」——激活码 48、IP 黑名单 44
 *     · 纠错管理最直白：**同一屏里**，列表行那 5 枚 Ghost 是 32、
 *       右边详情里同名的 5 枚是 44
 *     · 题库详情整排 40（`9-187.css:57` 写死）
 *
 * ── 为什么统一到 44，而不是 48 ───────────────────────────────────────
 *   **跟同一行的邻居齐平，才叫「有层级」。** 桌面档这一排里，
 *   搜索框 44、下拉 44（实测：试题检索 SearchBar 44、用户管理/资料管理
 *   Select 44）—— 按钮是这一行里**唯一换高度**的东西，所以看着「乱放」。
 *   48 是**手机档**的触控舒适高（`--sz-control`）。桌面档留 48 的那 62 枚里
 *   最扎眼的是页脚翻页键：一屏里最重的元素成了「上一页」，比主操作还肥。
 *   32 / 40 又低于同行控件，按钮像被压扁。
 *   ⇒ 桌面档一档 44（`--sz-tap-min`）：指针点得到，也不再跟谁打架。
 *
 * ── ⚠ 只挂 `.admin-shell-main`（电脑档），手机档一个字不动 ─────────────
 *   手机档**根本不挂 `.admin-shell`**（整屏换壳），这条到不了那边；
 *   那边 48 / 44 是手指尺度，原样保留。
 *   ⚠ 必须挂 `.admin-shell-main`，不能只写 `body[data-ardot=…]`：
 *     `data-ardot` 在**两档都会镜像到 `<body>`**，少写这一层就漏进手机。
 *
 * ── 今天**不动**的三处（写在这儿，免得下轮又想动）────────────────────
 *   · `.ae-mdt`（编辑文章的 MD 工具条，28/34 高）：那是**图标工具条**，
 *     11 枚挤一行、本来就是要密，另一物种；而且它不是稿子按钮
 *     （自己一个类、不带 `data-name`），这条选择器碰不到它。
 *   · `.admin-org-tab`（组织详情的页签）：页签不是按钮，本来就是 44/圆角 0。
 *   · 圆角与字号这一轮**不碰**：稿子按钮 `--rd-md`(12)/16px、
 *     行内动作 `--rd-sm`(8)/13px —— 那是「页级 / 行内」两种角色。
 *     打架的是高度那四个数，先只收高度。
 *
 * ── 为什么选择器是这五个类 ───────────────────────────────────────────
 *   稿子那族 `Component/Button/*`（它们是 `div`，不是 `<button>`），
 *   加上后台自己那四族动作类。**不写裸 `button`** —— `.ae-mdt` 正是
 *   `<button>`，写裸标签会把它一起吹成 44、整条工具条报废。
 *   特异性够：帧 CSS 是 `.n-10-141{height:32px}`（0,1,0），
 *   这条是 (0,2,0)，跟两份 CSS 谁先加载无关。
 *   ⚠ **这层前缀不只是「别漏到手机」，还是压过帧 CSS 所需要的特异性。**
 *     种故障时实测过：把 `.admin-shell-main` 去掉之后，选择器掉回 (0,1,0)，
 *     跟帧 CSS 平手 —— 而 `web/assets/pages/*.css` 是**后**加载的，
 *     于是帧赢，48 那几屏当场弹回 48。两个作用缺一不可。
 *
 * ⚠ 顺带修好的一处：激活码的行高是 65，就是因为行内那两枚是 48
 *   （见 ㊿-14 注）。收到 44 之后那一行回到 61，跟其余十屏同一条配方。 */
.admin-shell-main [data-name^="Component/Button"],
.admin-shell-main .gaozi-act,
.admin-shell-main .ak-btn,
.admin-shell-main .bk-btn,
.admin-shell-main .ac-btn {
  height: var(--sz-tap-min);
  min-height: var(--sz-tap-min);
}

/* ── ㊿-15 续：纠错管理卡片上那两枚「删除」要是红的（2026-10-02）────────
 *
 * 「没层级」最直白的一处：稿子（`10-116.css`）把
 * 「已解决 / 忽略 / 编辑题目 / 删除反馈 / 删除题目」五枚画成**一个颜色**
 * （`--cl-primary-700`），删数据和标状态看着一样安全。
 * 全后台其余 11 屏的行内「删除」都是红的（`.gaozi-act--danger`），
 * 连这一屏**右栏详情**里的同名动作也是（走 `detailSheet`）——
 * 只有卡片上这一排漏了。
 *
 * ⚠ 类名 `.admin-act--danger` 是**在 JS 里按标签挂的**
 *   （`app/routes/admin-feedbacks.js` 的 `wireCard`，理由写在那儿）：
 *   这五枚是帧 HTML 画的裸 `div`，`data-name` 一律是
 *   `Component/Button/Ghost/Default`，CSS 认不出哪一枚是删除。
 * ⚠ 颜色必须**连里层标签一起**压：稿子把颜色写在
 *   `.n-10-124__10-99__2-296{color:var(--cl-primary-700)}`（0,1,0）上，
 *   只给外壳写 color 会被它盖住、量出来还是紫的。
 * ⚠ **这一条反过来不挂档位**（跟本节其余规则不同）：红 = 危险是全后台的
 *   通用约定，`.gaozi-act--danger` 自己就没挂档位、手机档也照红。
 *   只压桌面档的话，同一枚「删除反馈」在电脑上红、在手机上紫 ——
 *   那就是这一轮刚修掉的「同一个东西两种长相」，只不过换了个轴。
 *   这个类**只可能挂在两枚破坏性按钮上**（JS 里按标签挂），
 *   所以不挂档位不会误伤手机档的任何版式。 */
.admin-act--danger,
.admin-act--danger [data-name="标签"] {
  color: var(--cl-danger-700);
}
.admin-act--danger:hover {
  background: var(--cl-danger-50);
}

/* ══════════════════════════════════════════════════════════════════════
 * 编辑文章（桌面档 `3-8593`）—— 纸面上那个源码框与预览块
 *
 * 稿子把 `纸张` 画成一块**渲染好的正文**（`纸文·…`），而这一屏要能编辑
 * ⇒ 源码框**盖在纸面上**、坐标照稿子那个 `纸张` 量（见 admin-article-desk.js 文件头）。
 *
 * ⚠ 源码框与预览块**同一个盒子**、同一套字体，切换时纸面不跳 ——
 *   字体写死成纸面上那一套（稿子 `纸文` 的字号/行高），不引新令牌。
 * ══════════════════════════════════════════════════════════════════════ */
.xq-article-src {
  position: absolute;
  left: 0;
  top: 0;
  width: 100%;
  height: 100%;
  box-sizing: border-box;
  margin: 0;
  padding: 0;
  border: none;
  outline: none;
  resize: none;
  background: transparent;
  color: var(--cl-text-primary);
  font: inherit;
  font-size: var(--fs-body);
  line-height: 1.7;
  overflow: auto;
  white-space: pre-wrap;
  word-break: break-word;
}
.xq-article-preview {
  position: absolute;
  left: 0;
  top: 0;
  width: 100%;
  height: 100%;
  box-sizing: border-box;
  overflow: auto;
  color: var(--cl-text-primary);
  font-size: var(--fs-body);
  line-height: 1.7;
}
/* `纸张` 自己得是定位上下文，源码框才盖得准（稿子那两格是绝对定位的兄弟，
   这里给纸面补一个 relative —— **不写坐标**，只把坐标系立起来）。 */
.xq-article-paper-anchor {
  position: relative;
}

/* ══════════════════════════════════════════════════════════════════════
 * 组件状态 —— 照**规范屏**落成通用态（2026-10-06 第五十八轮）
 *
 * 两张规范屏把「控件有哪几种状态、各是什么颜色」定死了，但桌面档的按钮/行
 * 都是**孤立 FRAME、没有组件实例**（见 `项目记录/44-号` 第二节）⇒ 稿子上量不出
 * 「悬停是什么样」。所以这一节是**照规范屏的量出来的值**落成 CSS 通用态：
 *
 *   `3-8750`「22 · 组件状态规范」：按钮 5 态 / 输入框 4 态 / 复选·单选·开关
 *   `3-8908`「23 · 反馈与状态」：表格行 4 态 / Toast 4 类 / 骨架屏 / 按钮加载 / 空态 / 错误态
 *
 * 全部用**令牌**写（值都是从那两张纸上量出来的），一个十六进制都不写：
 *   主按钮   静息 `--cl-brand` · 悬停 `--cl-nav-bg-deep` · 按下 再叠 `--cl-comp-bg-secondary`
 *            聚焦 同静息 + 2px 环 · 禁用 `--cl-bg-fourth`
 *   次按钮   静息/悬停底 `--cl-text-on-brand`（白），**悬停时叠** `--cl-comp-bg-tertiary`、
 *            按下叠 `--cl-comp-bg-secondary` ⇒ 用 `box-shadow: inset 0 0 0 999px <叠色>`
 *            表达「叠一层」（不改底、不动布局 —— 底本身还是白的那一层）
 *   危险按钮 静息/悬停 `--cl-semantic-warning`
 *   聚焦环   稿子上量到 **116×40 围着一枚 112×36** ⇒ 每边 2px，圆角 8
 *   表格行   静息 白 · 悬停 `--cl-comp-bg-tertiary` · 选中 `--cl-comp-emphasize-20`
 *            **+ 左侧 3px 竖条 `--cl-brand`** · 禁用 `--cl-comp-bg-secondary`
 *   输入框   静息 `--cl-bg-secondary` · 聚焦 白 + 2px 蓝环 · 错误 白 + 2px 红环 · 禁用 `--cl-bg-fourth`
 *
 * ⚠ 一律只在**桌面档**里生效（`[data-ds="xq"]` 打头）：手机档那些稿子自带的
 *   按压反馈是另一套（`.xq-link` 那条），两档别互相污染。
 * ⚠ 用 `:where()` 把选择器权重压到 0，让**屏自己写的内联样式**照样赢 ——
 *   不然「这一枚按钮本来就是红底」（确认框里那枚）会被下面这条统一刷成品牌色。
 * ══════════════════════════════════════════════════════════════════════ */

/* ── 按钮：悬停 / 按下 ───────────────────────────────────────────────── */
:where([data-ds="xq"]) :where([data-name^="按钮·"]):where(:not([data-static])):hover,
:where([data-ds="xq"]) :where([data-name^="次按钮·"]):where(:not([data-static])):hover {
  box-shadow: inset 0 0 0 999px var(--cl-comp-bg-tertiary);
}
:where([data-ds="xq"]) :where([data-name^="按钮·"]):where(:not([data-static])):active,
:where([data-ds="xq"]) :where([data-name^="次按钮·"]):where(:not([data-static])):active {
  box-shadow: inset 0 0 0 999px var(--cl-comp-bg-secondary);
}
:where([data-ds="xq"]) :where([data-name^="主按钮·"]):where(:not([data-static])):hover {
  background: var(--cl-nav-bg-deep);
}
:where([data-ds="xq"]) :where([data-name^="主按钮·"]):where(:not([data-static])):active {
  background: var(--cl-nav-bg-deep);
  box-shadow: inset 0 0 0 999px var(--cl-comp-bg-secondary);
}
:where([data-ds="xq"]) :where([data-name^="危险按钮·"]):where(:not([data-static])):hover {
  background: var(--cl-semantic-warning);
  box-shadow: inset 0 0 0 999px var(--cl-comp-bg-tertiary);
}
:where([data-ds="xq"]) :where([data-name^="危险按钮·"]):where(:not([data-static])):active {
  background: var(--cl-semantic-warning);
  box-shadow: inset 0 0 0 999px var(--cl-comp-bg-secondary);
}

/* ── 按钮：聚焦环（键盘走到才亮 —— `:focus-visible`）─────────────────
 * 稿子上那枚环是 **116×40 围着 112×36** ⇒ 每边 2px。用 outline 而不是 border：
 * border 会改盒子尺寸，那一屏是绝对定位、一改就错位。 */
:where([data-ds="xq"]) :where([data-name^="按钮·"], [data-name^="主按钮·"], [data-name^="次按钮·"], [data-name^="危险按钮·"]):focus-visible {
  outline: 2px solid var(--cl-brand);
  outline-offset: 0;
  border-radius: 8px;
}

/* ── 按钮：禁用（灰底 + 灰字，**不只降透明度**）────────────────────────
 * 稿子写死了这一点（`3-8750` 的副题：「灰字，不只降透明度」）——
 * 只降透明度的话，一枚主色按钮看起来还是主色（只是"淡一点"），
 * 而「能不能按」这件事必须一眼看出来。 */
:where([data-ds="xq"]) :where([data-name^="按钮·"], [data-name^="主按钮·"], [data-name^="次按钮·"], [data-name^="危险按钮·"])[data-disabled="1"] {
  background: var(--cl-bg-fourth) !important;
  box-shadow: none !important;
  cursor: not-allowed !important;
}
:where([data-ds="xq"]) :where([data-name^="按钮字·"], [data-name^="主按钮字·"], [data-name^="次按钮字·"], [data-name^="危险按钮字·"])[data-disabled="1"] {
  color: var(--cl-text-tertiary) !important;
}

/* ── 表格行：悬停 / 选中（**3px 左条**，不只靠底色）────────────────── */
:where([data-ds="xq"]) :where([data-name^="行"]):where(:not([data-xq-inline])):hover {
  background: var(--cl-comp-bg-tertiary);
}
:where([data-ds="xq"]) :where([data-name^="行"])[data-selected="1"] {
  background: var(--cl-comp-emphasize-20);
}
/* 选中那一条竖条：由控制器给 `data-selected` 之后这里补一块伪元素 ——
   ⚠ 伪元素**不占布局**，所以不会把那 48 高的一行挤变形。 */
:where([data-ds="xq"]) :where([data-name^="行"])[data-selected="1"]::before {
  content: "";
  position: absolute;
  left: 0;
  top: 0;
  width: 3px;
  height: 100%;
  background: var(--cl-brand);
}
/* 禁用行：灰底 + 不许点。 */
:where([data-ds="xq"]) :where([data-name^="行"])[data-disabled="1"] {
  background: var(--cl-comp-bg-secondary);
}

/* ── 我们自己的控件（.xq-q-ctl / .xq-q-opt / 源码框等）：聚焦环 ──────── */
.xq-q-ctl:focus,
.xq-article-src:focus,
.xq-article-preview:focus {
  outline: 2px solid var(--cl-brand);
  outline-offset: 0;
}
/* 校验错误态：2px 红环（稿子 `3-8750` 的「输入·错误」那一格）——
   ⚠ 错误**还要有一行就地说明**（`错误行·…`，稿子 `3-7737` 画了三行）；
   只画红框不写原因，等于让用户猜哪里错了。 */
.xq-q-ctl[data-invalid="1"] {
  outline: 2px solid var(--cl-semantic-warning);
  outline-offset: 0;
}

/* ══ 首页轮播（2026-10-17）══════════════════════════════════════
 *
 * 「学习进度」那张卡变成一块可以左右滑的板子：第 0 张还是原来那张卡
 * （一个节点都没改，见 app/carousel.js 文件头），后面是后台公告里勾了
 * 「首页轮播」的内容位。
 *
 * 三条讲究：
 *   · **只动 transform**：滑动/自动播都只改 track 的 translate3d，走合成器线程，
 *     不碰 left/width，长列表滚动时不会抢主线程。
 *   · **高度由第 0 张决定**：内容位是绝对定位的（intrinsic 高度 0），
 *     所以板子高度永远等于原来那张 Hero 卡 —— 加几张内容位，版式一毫米都不动。
 *   · **一张的时候完全隐形**：圆点 `display:none`（JS 控制），
 *     没配轮播内容的站看起来跟以前一模一样。
 */
.xq-car {
  position: relative;
  align-self: stretch;
  width: 100%;
  flex-shrink: 0;
  /* 横向必须裁掉（左右两张推在外面，不裁就露一条边）；
     纵向**必须放行** —— Hero 自己带一条往下的投影（2026-10-18 起是
     `0 20px 44px -18px`，往下最远 ~24px，见 .screen [data-name="Hero"]），
     父层一 `overflow:hidden` 那圈影就被切掉了，卡片会看起来「变平」。
     `overflow: clip` 是唯一能做到「一个方向裁、另一个方向不裁」的写法
     （`hidden` 会强迫另一个方向也变成滚动容器）。
     老浏览器（iOS 15 及更早）不认 `clip`：上面那行 `hidden` 兜底，
     代价只是少一圈很淡的投影，不是版式问题。 */
  overflow: hidden;
}
@supports (overflow: clip) {
  .xq-car {
    overflow-x: clip;
    overflow-y: visible;
  }
}
.xq-car-track {
  display: flex;
  flex-direction: row;
  align-items: stretch;
  /* 顺滑的切换曲线：起步快、收尾软（跟全站那两条缓动令牌一档） */
  transition: transform 520ms var(--xq-ease-soft, cubic-bezier(.4,0,.2,1));
  will-change: transform;
}
/* 拖拽中 / 关掉动画：立刻跟手，不要过渡（拖的时候有过渡就是「黏」） */
.xq-car.is-drag .xq-car-track,
.xq-car.is-instant .xq-car-track {
  transition: none;
}
.xq-car-slide {
  position: relative;
  flex: 0 0 100%;
  min-width: 0;
  display: flex;
  flex-direction: column;
  border-radius: var(--rd-xl);
}
/* 只有内容位要裁（图片得守着圆角）——第 0 张不裁，
   否则 Hero 那圈投影会被这一层再次切掉（见 .xq-car 上面那段）。 */
.xq-car-slide--content {
  overflow: hidden;
}
/* 第 0 张里就是原稿的 Hero：撑满整张，别的什么都不改 */
.xq-car-slide > * {
  align-self: stretch;
  min-width: 0;
}

/* ── 内容位（公告）────────────────────────────────────────── */
.xq-car-slide--content {
  /* 没图时就是这张渐变底；有图时它被图片盖住，只剩底部那层可读性渐变 */
  background: linear-gradient(135deg, var(--cl-primary-700), var(--cl-primary-500));
  /* 投影 2026-10-18 晚 整条撤掉（用户：「用户首页轮播卡片下边的投影关掉」）。
     这里与第 0 张 Hero 同步改成 none —— 两张卡滑动时不能一张有影一张没影，
     那比两条影子都难看。旧值留在上面那条被划掉的注释里，别再单独恢复这一处。 */
  box-shadow: none;
}
.xq-car-slide--content.is-link {
  cursor: pointer;
}
.xq-car-img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}
.xq-car-body {
  position: relative;
  flex: 1 1 auto;
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  gap: var(--sp-1);
  padding: var(--sp-4);
  /* 底部压一层深色渐变：图再亮，标题也读得清（只在有图时真的看得见） */
  background: linear-gradient(to top, rgba(15, 23, 42, 0.72) 0%, rgba(15, 23, 42, 0) 62%);
}
.xq-car-title {
  color: #fff;
  font-size: var(--fs-heading);
  font-family: "Noto Sans SC", var(--ff);
  font-weight: var(--fw-semibold);
  line-height: var(--lh-heading);
  /* 最多两行，再长就省略 —— 卡片高度是 Hero 定的，标题不能把它顶开 */
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}
.xq-car-desc {
  color: rgba(255, 255, 255, 0.86);
  font-size: var(--fs-caption);
  font-family: "Noto Sans SC", var(--ff);
  font-weight: var(--fw-regular);
  line-height: var(--lh-caption);
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

/* ── 圆点 ─────────────────────────────────────────────────
 * 放在卡片**下面**，不压在内容上（Hero 底部是那三格数字，压上去会挡字）。
 * 一张的时候整块藏起来，所以默认页面不多出任何高度。 */
.xq-car-dots {
  display: flex;
  flex-direction: row;
  justify-content: center;
  align-items: center;
  gap: 6px;
  height: 14px;
  margin-top: 2px;
}
.xq-car-dot {
  width: 6px;
  height: 6px;
  padding: 0;
  border: 0;
  border-radius: 999px;
  background: var(--cl-border-strong, rgba(15, 23, 42, 0.18));
  cursor: pointer;
  transition: width 240ms var(--xq-ease-soft, cubic-bezier(.4,0,.2,1)),
              background-color 240ms linear;
}
.xq-car-dot.is-on {
  width: 18px;               /* 当前这张拉长 —— 不用颜色也能看出来 */
  background: var(--cl-primary-700);
}
.xq-car-dot:focus-visible {
  outline: 2px solid var(--cl-primary-700);
  outline-offset: 3px;
}

/* 关了动效的人：切换直接到位（JS 那边也不再自动播） */
@media (prefers-reduced-motion: reduce) {
  .xq-car-track { transition: none; }
}

/* ══ 底部 TabBar 换风格（2026-10-17）══════════════════════════════
 *
 * 用户给了参考图：**毛玻璃胶囊**、未选中是灰图标、选中是深蓝实心图标，
 * 选中项**没有色块底**。原稿子是「白胶囊 + 选中项铺一块主色」的分段控件。
 *
 * 只改观感，不动结构：稿子的节点、尺寸、间距、图标路径一个都没换，
 * 全靠属性选择器压在生成类名之上（`[data-name^="TabBar/"]` 这一层
 * 在**每一张**带底栏的稿子里都成立：首页/公告/我的……）。
 *
 * ⚠ 选中项的底色写在生成类名上（`…__2-752{background:var(--cl-primary-700)}`），
 *   所以必须 `!important` —— 这是覆盖，不是它原本的设计意图。
 * ⚠ 选中/未选中的图标在稿子里是**两个不同的节点**
 *   （`IconNav/Home/Active` 与 `IconNav/Home/Inactive`），所以换图标是整屏换的时候
 *   自动发生的，这里不用管；只管颜色。
 */
[data-name^="TabBar/"] [data-name="栏容器"] {
  /* 底栏那一带铺一层很淡的蓝灰，给上面的毛玻璃一点东西可糊
     （页面本身是近白的，纯白底上做毛玻璃等于没做）。 */
  background: linear-gradient(to top, rgba(206, 219, 245, 0.55), rgba(206, 219, 245, 0));
}
[data-name^="TabBar/"] [data-name="胶囊"] {
  background: linear-gradient(160deg, rgba(235, 240, 252, 0.78), rgba(244, 247, 255, 0.62)) !important;
  -webkit-backdrop-filter: blur(20px) saturate(1.6);
  backdrop-filter: blur(20px) saturate(1.6);
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.85),
    inset 0 0 0 1px rgba(255, 255, 255, 0.55),
    /* ★ 2026-10-20 用户两轮反馈：第一轮「太浓了，范围太大了」原 0 10px 28px / .10
       收到 0 4px 12px / .08；第二轮「还是有点浓，色彩范围太大了，加一个淡淡的
       投影就行」再收到 0 1px 4px / .06 —— blur 4px、位移 1px，只在胶囊下缘留一道
       很浅的托影，「浮起」是暗示不是声明。蓝色也再压一档（.08→.06），贴近中性灰。
       ⚠ 生效层是**这条**（!important + 同特异性后到）；1746 那段 ⑭ 的
         box-shadow 是回落层，改阴影两处要一起看，别只改一处。 */
    0 1px 4px rgba(30, 64, 175, 0.06) !important;
}
/* 选中项：不要色块（参考图里那一块是空的，只有图标和字变蓝） */
[data-name^="TabBar/"] [data-name="胶囊"] > div {
  background: transparent !important;
  border-radius: var(--rd-tab);
  transition: transform 260ms cubic-bezier(.22, 1, .36, 1);
}
/* 未选中：灰 */
[data-name^="TabBar/"] [data-name^="IconNav/"] {
  color: #8B90A6 !important;
  transition: color 220ms linear;
}
[data-name^="TabBar/"] [data-name^="IconNav/"] + div {
  color: #8B90A6 !important;
  transition: color 220ms linear;
}
/* 选中：深蓝（参考图那个蓝，比主色紫更冷一档；只在这一处用）
   ⚠ 用相邻兄弟选择器认「图标后面的那一格」= 文字。
     稿子里文字本来就是图标的下一个兄弟，这是它的结构事实。 */
[data-name^="TabBar/"] [data-name$="/Active"] {
  color: #1B45C8 !important;
}
[data-name^="TabBar/"] [data-name$="/Active"] + div {
  color: #1B45C8 !important;
  font-weight: 600 !important;
}
/* 点按：只给一点点回弹（不是「按下去」的色块）。
   ⚠ 只作用在底栏 —— 列表卡不加压感，用户明确说过「不要那种按下去的感觉」。 */
[data-name^="TabBar/"] [data-name="胶囊"] > div[data-press="down"] {
  transform: scale(0.94);
}
@media (prefers-reduced-motion: reduce) {
  [data-name^="TabBar/"] [data-name="胶囊"] > div { transition: none; }
}

/* ══ 页面底色：2026-10-18 用户点名「APP 前端用户端的底色改成白色」═══════════
 *
 * 原来这里铺的是三层（2026-10-17 深夜二改留下的）：纵向蓝灰渐变 + 两团主题色
 * 光斑 + 一层 feTurbulence 颗粒。三层一起才让底「不是一块平的淡紫」。
 *
 * 现在底要纯白，那就**整块撤掉，不留残影**：
 *   · 光斑是带色相的（indigo/紫），留在白底上会显脏、显廉价；
 *   · 颗粒压在白底上是一层可见的灰麻点，不是「纸感」是「没擦干净」。
 * 所以这一段现在只给一块纯白，不做任何加法。
 *
 * ⚠ 连带的事在下面「列表卡 3.0」那一段：原来卡是**半透明白**，靠透出这层
 *   纹理跟底分层。底没了纹理，卡就必须变成**不透明白 + 靠描边分层**，
 *   否则就是用户上一轮打回过的那个问题——「卡片跟底色都一个颜色了」。
 * ⚠ 保留 `!important`：稿子生成类带 background，页面 CSS 运行时注入、排在
 *   app.css 之后，同特异性时它赢。
 */
html[data-theme="亮色"] .screen > :first-child {
  background: #ffffff !important;
}

/* ══ 墓碑：滚动驱动的 `xq-scrub` 已于 2026-10-18 随 `.xq-in` 一起拆除 ════════
 *
 * 原来这里是 `@supports (animation-timeline: view())`：列表项按**滚动进度**
 * 从 `opacity:.7 / translateY(14px) scale(.97)` 长到正常 —— 位置由滚到哪儿决定。
 * 用户：「我只是要滑动缓动，没必要藏着不加载」→ 列表项必须一进页就是全尺寸全透明度。
 *
 * 现在跟手的那一层只剩 `app/scroll.js`（滚轮 lerp 缓动，动的是**视口**不是内容）。
 * 关动效的兜底规则也不需要了 —— 没有任何规则会把列表藏起来。
 */

/* ── 轮播图：等比裁切，不拉伸（2026-10-17）─────────────────────
 * 用户：「自动裁剪超出，不要拉伸图片，尺寸不够可以底色填充。」
 * · `object-fit: cover` —— 等比放大到铺满，**多出来的部分裁掉**，
 *   绝不会像 `fill` 那样把人脸拉扁（这是"不拉伸"的唯一正确写法）。
 * · 卡片本身有渐变底（.xq-car-slide--content 的 background）——
 *   透明图、图还没加载完、加载失败，露出来的都是这块底色，不是白块。
 * · `object-position: center` —— 裁的时候从中间裁。
 */
.xq-car-img {
  object-position: center;
}

/* ══ 列表卡「透亮玻璃」2.0（2026-10-17 深夜二改）════════════════════
 *
 * 一版的错，写下来免得再犯：
 *   ① 每张卡都上 `backdrop-filter: blur(16px)` —— 一屏 6~10 张卡，每张都是
 *      一个 backdrop 合成面，滚动时每帧重糊一遍。用户原话「滑动得那么那么
 *      卡顿」。**滚动列表里的卡永远不要上 backdrop-filter**，底栏那颗胶囊
 *      能用是因为它只有一颗、且钉在原地不跟内容滚。
 *   ② 卡底和页面底都是浅蓝白，糊完一个色 ——「跟底色都一个颜色了，看不出来」。
 *   ③ 给每张卡加了统一的外投影 `0 6px 22px` —— 用户看到的是「列表下边非常
 *      整齐的阴影」，一排卡各带一条同款影子，假得刺眼。
 *
 * 2.0 的做法（对齐「教你画背景」那张参考图的分层思路）：
 *   · **卡 = 半透明白**（alpha 0.62~0.74），透下去是刚铺好的「渐变+光斑+
 *     颗粒」页面底 —— 颗粒透上来，卡和底自然分层，不需要模糊；
 *   · **边缘只给发丝**：一圈 1px 白高光（inset）+ 一圈极淡的冷灰描边 ——
 *     没有外投影，卡与卡之间靠底纹分界；
 *   · **零合成成本**：纯 background/box-shadow，滚动时不重算任何东西。
 *
 * 3.0（2026-10-18，底改纯白当天）：上面那套**整套作废**，因为它的前提是
 * 「底有纹理可透」，而底现在是一块纯白 —— 半透明白卡在白底上就是白对白，
 * 正是 2.0 当初要解决的那个问题换个形状又回来了。新做法：
 *   · **卡 = 不透明白**，不再依赖透底；
 *   · **分层全靠边缘**：一圈 1px 冷灰描边（#E8E8EF，就是 tokens 里的
 *     `--cl-border-base`）+ 一圈极淡的外投影。
 *     描边在白底上才看得见（原来 0.16 的灰描边是给带色底配的，在白底上等于没有）；
 *   · 仍是纯 background/box-shadow，零合成成本不变。
 *
 * ★ 3.1（2026-10-19，用户第三次报同一件事）—— **外投影整条删掉**。
 *   原话：「每一个列表卡片好像下边都有一个投影……我希望的是每一个卡片它单独
 *   就是一个组件，而不是把很多卡片绑定到一个组件上，我不喜欢这种嵌套结构」。
 *
 *   结构上本来就是一卡一节点（`home.js` 把每张卡 append 进列表容器，没有外层
 *   卡片包着它们）—— 他看到的「嵌套」是**光学的**：一排卡各带一条同款
 *   `0 4px 14px`，间距 12px 时影子互相叠住，读起来就是「一整块底板托着一列行」。
 *   这正是 2.0 当初写下的那条错（③「一排卡各带一条同款影子，假得刺眼」），
 *   3.0 为了「把卡从白底上托起来」又把它加了回来 —— 同一个坑走第二遍。
 *
 *   所以：**分层只用描边，不用投影**。白底 + 1px `--cl-border-base` 就够把卡
 *   从页面底上摘出来（QuickRow 那三格一直是这个做法，从没人说过它看不出来）。
 *   卡与卡之间由 12/16px 的间距负责，不靠影子 —— 影子只能表达「谁浮在上面」，
 *   表达不了「这是两张独立的卡」。
 *
 * ⚠ 别再因为「太平了」把外投影加回来。真觉得不够，加的是**间距**或**描边深浅**，
 *   不是影子：一列同款影子在任何间距下都会连成一片。
 *
 * 覆盖名单不变（全是「一条列表里重复出现」的卡，展示卡不进）：
 *   题库卡片/带统计 · 题库卡片/紧凑 · 组织卡片 · 章节卡片 · 收藏卡 · 错题卡 ·
 *   资料卡 · 公告卡 · 入口卡 · 四格统计卡 · 账号卡 · 记录卡片
 * ⚠ 题目目录卡/轮播卡照旧不进（专注屏对比度、轮播是独立设计）。
 * ⚠ background 必须 `!important`：稿子生成类带 background，页面 CSS 运行时
 *   注入、排在 app.css 之后，同特异性时它赢。
 */
html[data-theme="亮色"] :is(
  [data-name="题库卡片/带统计"],
  [data-name="题库卡片/紧凑"],
  [data-name="组织卡片"],
  [data-name="章节卡片"],
  [data-name="收藏卡"],
  [data-name="错题卡"],
  [data-name="资料卡"],
  [data-name="公告卡"],
  [data-name="入口卡"],
  [data-name="四格统计卡"],
  [data-name="账号卡"],
  [data-name="记录卡片"]
) {
  background: #ffffff !important;
  /* ★ 3.1：只留描边，外投影两条删掉（理由见上）。
     描边**比 `--cl-border-base`（#E8E8EF）深一档**：影子撤掉之后，这圈线是卡片
     在白底上唯一的分界信号，而 #E8E8EF 是稿子给「卡片内部的分隔线」配的浅度 ——
     两者同一个深度，卡边界就和卡里的分隔线一样淡，一行行读下去还是分不清
     哪是「卡」哪是「卡里的一条线」。深一档（#E2E2EA）既立得住，又不会像影子。 */
  box-shadow: inset 0 0 0 1px #e2e2ea !important;
}

/* ══ 组卡 / 章节行的字号层级（2026-10-19）════════════════════════════
 *
 * 用户：「那个组里边的每个章节的每个组的卡片的名字呀，那个字号再小一点。
 *         现在有点太大了，影响观感，处理好这些字号的比例。」
 *
 * 稿子给的是三档一把抓：
 *   组卡名 17px · 章节名 16px · 序号 16px · 右边「22/32」13px
 * 问题不在某个字大小，在**层级**：17 和 16 差一像素，两种卡的主标题读起来一样重；
 * 而序号那一格是「1 2 3」这种一位数，占着和标题一样的 16px —— 数字比字还抢眼。
 *
 * 改成一列能站住的梯度（Δ 至少 1px，否则等于没分）：
 *   组卡名 16px / 600 ← 一屏里最重的字（它是「进哪个库」的决策依据）
 *   章节名 15px / 500 ← 比组卡低一档：它是库里的下一层，不该跟库名平级
 *   序号小字 12px    ← 纯定位用的角标，跟「22/32」那档辅助信息同层
 *   辅助信息 12/13px（稿子原样，不动）
 *
 * ⚠ 字重也要跟着降一档，光降字号没用 —— 这两行名字在稿子里都是 **600**。
 *   中文在 15~16px 上挂 600，横画会糊在一起，人的第一反应就是「字很大」，
 *   而右侧那行 13px / 400 的「22/32」显得又小又灰，两边不成比例。
 *   组卡名保持 600（它必须是最重的），章节名降到 500（Medium）——
 *   差这一档，章节名立刻退到「下一层」，而且比再降 1px 字号更不伤可读性。
 *
 * ⚠ 只改 font-size / font-weight，**不碰行高和盒子高度**：这些卡的行高是稿子
 *   按「名字占一行」排的，行高跟着缩会把卡片压扁、进度条贴上去。字号变小只会
 *   让名字那行更松，正好把「太长要省略」的余量多留出两三个字。
 * ⚠ 选择器按**结构**认，不按 data-name 认 —— 名字节点的 data-name 就是样文
 *   （「行政职业能力测验」），真数据一来就换，按它写必然失配。
 *   失配的后果只是这几行退回稿子字号，版式不会破。
 */
.screen [data-name="题库卡片/紧凑"] [data-name="Frame 6"] > :first-child,
.screen [data-name="题库卡片/带统计"] [data-name="Frame 1"] [data-name="Frame 1"] > :first-child {
  font-size: 16px;
}
.screen [data-name="章节列表"] [data-name="名称行"] > :first-child,
.screen [data-name="章节卡片"] [data-name="名称行"] > :first-child {
  font-size: 15px;
  font-weight: 500;
}
.screen [data-name="章节列表"] [data-role="row"] [data-name="序号"],
.screen [data-name="章节卡片"] [data-role="row"] [data-name="序号"] {
  font-size: 12px;
}

/* ══ 首页 · 组织切换：一个下拉选择器（2026-10-19 晚，第二次改）════════
 *
 * 先记下这一块当天走过的两步，免得下一次又从「一排胶囊」重新发明：
 *   上午：胶囊横滑 + 藏掉滚动条 + 右端一枚 sticky 的「切换」按钮 → 底部抽屉。
 *         滚动条那条确实是痛点（桌面浏览器给 overflow-x:auto 画的原生条，
 *         Windows 下 15~17px 还带两枚箭头，手机上看不见所以只有他电脑上看得到）。
 *         但「切换」两个字**不回答「我在哪个组织」** —— 选中的胶囊一滑出视野就没了，
 *         30 个组织一屏只放得下两个半，它绝大多数时候在视野外。
 *   晚上：用户「加一个下拉列表类的切换，让用户知道自己目前在哪个组织，
 *         就是把那个按钮什么的记住高度一致、宽度一致，最基本的对齐」。
 *
 * 所以整行换成一个下拉选择器，横滑胶囊和那枚按钮一起撤掉：
 *   [ 我的组织 ]  [ 专插本·深圳职业技术大学《护理综合》            ▾ ]
 *
 * ── 对齐怎么落成可以量的东西（这是用户这一轮真正在要的）──────────────
 *   ① **同行等高**：`height:36px` 写死。稿子里那枚胶囊量出来就是 36，这一行
 *      没有第二个高度。标签用 line-height 跟着 36 走，不靠 padding 撑 ——
 *      靠 padding 撑的高会随字体回退差 1~2px（Noto Sans SC 拿不到时掉到雅黑）。
 *   ② **同列等宽**：触发器 `flex:1 1 auto; min-width:0` —— 宽度 = 行宽 − 标签 − gap，
 *      组织名长短只决定里面那行省略号画哪儿，**控件本身不跳**。切换组织时
 *      这一格不会一会儿 180、一会儿 240，右边也不会留一截空白。
 *   ③ **基线对齐**：触发器是一个 flex 行、`align-items:center`，文字和那枚箭头
 *      同一条中线。箭头用固定 16×16 的盒子装，不跟着字号变 ——
 *      否则换成 20 号字时箭头会被撑大，一眼看出没对过。
 *   ④ 名字单行 + 省略号：再长也占一行，不换行、不把 36 撑成 52。
 *
 * ⚠ 稿子给 OrgRow 写的是 `overflow:hidden`（它画的时候两枚胶囊正好放得下）。
 *   这一行不再横滑，控制器会把 overflow 改成 visible；这里**不再**写
 *   scrollbar-width / ::-webkit-scrollbar —— 不横滑了，那条规则留着只会让下一个人
 *   以为这里还在滚。
 */
.screen [data-name="OrgRow"] {
  align-items: center;
}

.screen [data-role="org-label"] {
  flex: 0 0 auto;
  font-size: 13px;
  font-weight: 500;
  line-height: 36px;          /* 跟触发器同一条水平线，不用 padding 撑 */
  color: var(--cl-text-tertiary, #74737b);
  white-space: nowrap;
}

.screen [data-role="org-switch"] {
  flex: 1 1 auto;
  min-width: 0;
  height: 36px;
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 0 10px 0 16px;
  border: 0;
  border-radius: 999px;
  background: #fff;
  /* 描边跟列表卡同一条色（#E2E2EA）：这一行是「白底上的一枚控件」，
     和卡片同一层，用同一条线才像同一个系统里的东西。 */
  box-shadow: inset 0 0 0 1px #e2e2ea;
  color: var(--cl-text-primary, #1c1b22);
  font: inherit;
  text-align: left;
  cursor: pointer;
}
.screen [data-role="org-switch"]:active {
  background: var(--cl-bg-sunken, #f3f3f7);
}
.screen [data-role="org-switch"]:focus-visible {
  outline: 2px solid var(--cl-primary-600, #6d4aff);
  outline-offset: 2px;
}

.screen [data-role="org-current"] {
  flex: 1 1 auto;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-size: 15px;
  font-weight: 500;
  line-height: 1.2;
}
/* ★ 长名字的降级（2026-10-19）：**装不下才缩**，不是一律缩小。
   两档由 JS 量出来挂上（home.js 的 fitOrgName），跟 Hero 那行数字同一个手法。
   ⚠ 只改 font-size：36 那一行高度是跟下面卡片对过的，行高一动整行就歪。 */
.screen [data-role="org-current"].org-current--tight {
  font-size: 14px;
}
.screen [data-role="org-current"].org-current--tiny {
  font-size: 13px;
}

/* 组织列表还没回来时的占位条：**跟真实控件同高**（36），所以数据到了只换内容、
   不动版式 —— 这一行原来是被整行藏掉的，回来时把下面整块往下顶 36px，
   用户把那一跳读成「加载卡顿」。 */
.screen [data-role="org-skeleton"] {
  flex: 1 1 auto;
  height: 36px;
  border-radius: 999px;
  background: var(--cl-bg-sunken, #f3f3f7);
}

.screen [data-role="org-caret"] {
  flex: 0 0 auto;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 16px;
  height: 16px;
  color: var(--cl-text-tertiary, #74737b);
  transition: transform 160ms var(--xq-ease-out, ease);
}
.screen [data-role="org-caret"] svg {
  width: 16px;
  height: 16px;
}
.screen [data-role="org-switch"][aria-expanded="true"] [data-role="org-caret"] {
  transform: rotate(180deg);
}
@media (prefers-reduced-motion: reduce) {
  .screen [data-role="org-caret"] { transition: none; }
}

/* ── 下拉浮层 ──────────────────────────────────────────────────
 * 锚在触发器下方、**宽度跟触发器一致**（对齐原则的延伸：浮层和它的触发控件必须
 * 同宽，不然一眼就看得出是两块拼的）。
 *
 * ⚠ 这里**允许有外投影**，跟列表卡那条「不许投影」不冲突：投影在这里表达的是
 *   「我浮在页面上面」，是一次性的浮层，不是一列重复出现的卡。一列同款影子才会
 *   连成一片，单个浮层不会。
 * ⚠ 30 个组织要在里面滚着找，滚动条照旧藏掉（同一条理由：桌面那条灰杠会把它挤窄）。
 */
.screen [data-role="org-menu"] {
  position: fixed;
  /* ⚠ 显式 auto：base.css 有一条 .stage > .screen > * {height:100%}，
     同一个特异性、谁在后面谁赢 —— 这里写出来才不依赖挂载位置。 */
  height: auto;
  z-index: 7800;              /* 低于 .screen-cover(8000)：换屏垫场要盖得住它 */
  margin: 0;
  padding: 6px;
  background: #fff;
  border-radius: var(--rd-md, 12px);
  box-shadow:
    inset 0 0 0 1px #e2e2ea,
    0 10px 30px rgba(28, 27, 34, 0.16);
  overflow-y: auto;
  overscroll-behavior: contain;
  scrollbar-width: none;
  -ms-overflow-style: none;
  -webkit-overflow-scrolling: touch;
}
.screen [data-role="org-menu"]::-webkit-scrollbar {
  display: none;
  width: 0;
}

.screen [data-role="org-menu"] [data-role="org-item"] {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  width: 100%;
  min-height: 44px;           /* 触摸目标：一行 44，手指按得准 */
  padding: 0 10px;
  border-radius: var(--rd-sm, 8px);
  font-size: 15px;
  color: var(--cl-text-primary, #1c1b22);
  cursor: pointer;
}
.screen .screen [data-role="org-menu"] [data-role="org-item"]:active {
  background: var(--cl-bg-sunken, #f3f3f7);
}
.screen .screen [data-role="org-menu"] [data-role="org-item"][data-on="1"] {
  background: var(--cl-primary-50, #f3efff);
  color: var(--cl-primary-600, #6d4aff);
  font-weight: 600;
}
.screen [data-role="org-menu"] [data-role="org-name"] {
  flex: 1 1 auto;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.screen [data-role="org-menu"] [data-role="org-check"] {
  flex: 0 0 auto;
  font-size: 12px;
  font-weight: 500;
}

/* ══ 刷题页 · 判分/选中的动效（逻辑在 app/routes/quiz.js 的 paintBody）════

   2026-10-19 重做。旧版是判分时给整列选项加 `.xq-pop`（6px 位移淡入 +
   50ms 错峰）—— 用户看完的原话：「会认为这是刷新加载」。位移+淡入播在
   **已经在屏上**的一整列上，读起来就是「这一列被重新下载了一遍」。
   （墓碑：.xq-pop / .xq-pop.is-in 两条 + reduced-motion 那条，已拆除。）

   现在的口径：**节点照旧整列换，动画只落在「颜色真变了的那一行」** ——
   quiz.js 重建前记旧背景色、重建后写回再两帧后切新色，靠行自己的
   background 过渡渐变过去（见下面「选项按下去的那一下」里的 transition）。
   · 选中/判分读作一次原地变色，不是整列刷新；
   · 没变色的行一个像素不动，等于没重建。

   这里只管判分新增的**尾图标**（答对的勾 / 答错的叉）：它们是新冒出来的
   元素，单独淡入一次，`--i` 沿用原来的 50ms 错峰 —— 视线从「我选的」
   扫到「正确答案」的读法不变，但不再有整列位移。 */
@keyframes xq-tail {
  from { opacity: 0; transform: scale(0.6); }
  to   { opacity: 1; transform: none; }
}
.screen [data-name^="选项行"].xq-tail [data-name^="Icon/"] {
  animation: xq-tail 200ms var(--xq-ease-out) both;
  animation-delay: calc(var(--i, 0) * 50ms);
}

/* 关了动效的人：一条都不许播（不是「播快一点」）。 */
@media (prefers-reduced-motion: reduce) {
  .screen [data-name^="选项行"].xq-tail [data-name^="Icon/"] { animation: none; }
}

/* ══ 刷题页 · 选项默认态的底色跟题干卡对齐（2026-10-18）════════════
   用户：「另外选项卡片颜色应该跟题干一致。」

   稿子原本给的是两档：
     题干卡（12-9 `.n-12-33` / 12-1 `.n-12-21` / 12-5 `.n-12-27`）→ `var(--cl-bg-card)`
     选项行/默认（`.n-12-140/142`、`.n-12-131/133/134`、`.n-12-135/138`）→ `var(--cl-bg-sunken)`
   sunken 比 card 暗一档，于是同一屏里题干是亮的、四个选项是灰的，
   读起来像「选项那块还没加载出来」。这次把默认态拉平到 card。

   ⚠ **只覆盖 `选项行/默认` 一个态**。已选（primary-100）/答对（success-50）/
     答错（danger-50）那三态是判分结果的**全部视觉信息**，跟着一起染掉，
     判分前后就没有区别了 —— 那比配色不齐严重得多。
   ⚠ 特异性必须压得住稿子页 CSS：`web/assets/pages/*.css` 是 view.js 动态插的
     `<link>`，排在 app.css **之后**，同特异性会反过来盖掉这条。
     `.n-12-140` 是 (0,1,0)，所以这里写成 `.screen [data-name=…]`（0,2,0）。
   ⚠ 圆角/描边**不动**：用户只说了颜色。选项 `--rd-md` 比题干 `--rd-lg` 小一号，
     加上 `inset` 那圈 `--cl-border-base` 描边是稿子的层级，不是色差 bug。 */
.screen [data-name="选项行/默认"] {
  background: var(--cl-bg-card);
}

/* ══ 刷题页 · 选项行的过渡与按压反馈 ═══════════════════════════════════
   未判时点一个选项，行本身要给一次物理反馈 —— 手指按下去的东西得「动一下」，
   否则选没选上只能靠颜色变判断（色弱用户尤其吃亏）。
   ⚠ 只给 scale，不给颜色/位移：颜色是稿子按状态帧给的，代码不另配。

   background-color / box-shadow 两条是 2026-10-19「原地变色」的运载 ——
   quiz.js 重建选项列时把旧行颜色写回新行、两帧后切新色，靠这里渐变过去
   （连行内圈那道描边一起变，不然底色渐了、圈还是硬切）。240ms 比
   transform 慢一档：颜色是「状态变了」，按压是「手指在」，两者节奏分开。
   ⚠ 旧注释里的 `:not(.xq-pop)` 已随 .xq-pop 一起拆除（2026-10-19）。 */
.screen [data-name^="选项行"] {
  transition:
    transform 120ms var(--xq-ease-out),
    background-color 240ms var(--xq-ease-out),
    box-shadow 240ms var(--xq-ease-out);
}
.screen [data-name^="选项行"]:active {
  transform: scale(0.985);
}
@media (prefers-reduced-motion: reduce) {
  .screen [data-name^="选项行"] { transition: none; }
  .screen [data-name^="选项行"]:active { transform: none; }
}

/* ══ 刷题页 · 题目富文本的「有图 / 无图」版式（2026-10-19）════════════
   题干/材料/解析/选项都走 renderInlineInto 的行内渲染：粗斜码、行内链接、
   $公式$ 天然不破版（KaTeX 行内、随文字走），**唯一能撑破版面的是图片** ——
   所以「有图无图」的判定版式就这一条规则，作用面只有 img：
   · max-width: 100%   图比槽宽时按槽宽缩，绝不把选项行/题干卡顶出屏；
   · max-height: 168px 竖图的上限（约屏高 1/5）—— 四行选项全变图就翻不了页了；
   · object-fit: contain + 圆角 + 基线：横图竖图都完整显示、不拉伸，
     跟文字同一行时垂直居中，边角跟卡片同一套圆角。
   ⚠ 没图的选项一个字节都不受影响（这几条只命中 img）。
   ⚠ 只圈**正文槽**，不写裸 `.screen img` —— 轮播（.xq-car-img）等非正文图
     有自己的裁剪规则，被这里 max-height 砍到会变形。 */
.screen [data-name="题干"] img,
.screen [data-name="材料正文"] img,
.screen [data-name="解析"] img,
.screen [data-name^="选项行"] img {
  max-width: 100%;
  max-height: 168px;
  object-fit: contain;
  border-radius: var(--rd-sm);
  vertical-align: middle;
}

/* ══ 章节练习 · 搜题 + 管理员改题（2026-10-19）══════════════════════════
   逻辑全在 app/routes/bank.js 的 wireSearchBtn / openSearch / openEditor。
   三块东西：①章节头右边那枚「搜题」按钮 ②全屏搜索面板 ③管理员编辑浮层。

   ⚠ 挂载点是**稿子根**（dom.firstElementChild），不是 .screen —— base.css 有
     `.stage > .screen > * { height:100% }`，挂 .screen 下会被拉成整屏白板
     （首页 org-menu 那次量过）。所以这里全部 fixed + 显式 height:auto，
     不依赖挂载位置、也不靠书写顺序这个巧合。

   ⚠ z-index 阶梯（从低到高）：org-menu 7800 → screen-cover 8000 →
     qsearch 8600 → qedit 8700 → 遮罩 8999 → 底部容器 9000 → devbar 9998。
     编辑浮层要盖在搜索面板上，所以 qedit 高一档。 */

/* ① 章节头横排 + 搜题按钮 ───────────────────────────────────── */
/* 稿子的「章节头」原本是两行文字竖排；wireSearchBtn 把两行包进 qs-headcol，
   头本身变「左列 + 右按钮」。data-qs 只在真的装了按钮时才写，无章节的稿子帧
   （2-1488）不会被这条横排误伤。 */
.screen [data-name="章节头"][data-qs="1"] {
  display: flex;
  flex-direction: row;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
}
.screen [data-name="章节头"] [data-role="qs-headcol"] {
  flex: 1 1 auto;
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: 2px;
}
.screen [data-name="章节头"] [data-role="qsearch-btn"] {
  flex: 0 0 auto;
  display: inline-flex;
  align-items: center;
  gap: 4px;
  height: 32px;
  padding: 0 12px;
  border: 1px solid var(--cl-border-base, #e7e7f0);
  border-radius: 999px;
  background: var(--cl-bg-card, #fff);
  color: var(--cl-primary-600, #7b61ff);
  font-size: var(--fs-caption, 13px);
  font-weight: 600;
  cursor: pointer;
}
.screen [data-name="章节头"] [data-role="qsearch-btn"]:active {
  background: var(--cl-primary-50, #f3efff);
}
.screen [data-name="章节头"] [data-role="qsearch-btn"] [data-role="qs-ico"] {
  display: inline-flex;
  width: 14px;
  height: 14px;
}
.screen [data-name="章节头"] [data-role="qsearch-btn"] svg {
  width: 14px;
  height: 14px;
  stroke: currentColor;
}

/* ② 全屏搜索面板 ────────────────────────────────────────────── */
.screen [data-role="qsearch"] {
  position: fixed;
  inset: 0;
  height: auto;
  z-index: 8600;
  display: flex;
  flex-direction: column;
  background: var(--cl-bg-page, #fff);
  /* 软键盘弹起时整体上移（--xq-kb 由 app.js 维护，没键盘时是 0px） */
  padding-bottom: var(--xq-kb, 0px);
  transition: padding-bottom 0.18s ease;
}
/* 顶栏：返回 + 输入框 */
.screen [data-role="qsearch"] [data-role="qs-bar"] {
  flex: 0 0 auto;
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 10px 12px;
  border-bottom: 1px solid var(--cl-border-base, #e7e7f0);
  background: var(--cl-bg-card, #fff);
}
.screen [data-role="qsearch"] [data-role="qs-back"] {
  flex: 0 0 auto;
  height: 36px;
  padding: 0 10px;
  border: none;
  border-radius: var(--rd-sm, 8px);
  background: transparent;
  color: var(--cl-text-primary, #1c1b22);
  font-size: var(--fs-body, 15px);
  cursor: pointer;
}
.screen [data-role="qsearch"] [data-role="qs-back"]:active {
  background: var(--cl-bg-sunken, #f3f3f7);
}
.screen [data-role="qsearch"] [data-role="qs-input"] {
  flex: 1 1 auto;
  min-width: 0;
  height: 36px;
  padding: 0 12px;
  border: 1px solid var(--cl-border-base, #e7e7f0);
  border-radius: var(--rd-sm, 8px);
  background: var(--cl-bg-sunken, #f3f3f7);
  color: var(--cl-text-primary, #1c1b22);
  font-size: var(--fs-body, 15px);
  outline: none;
  -webkit-appearance: none;
  appearance: none;
}
.screen [data-role="qsearch"] [data-role="qs-input"]:focus {
  border-color: var(--cl-primary-500, #3b6cf6);
  background: var(--cl-bg-card, #fff);
}
/* 范围分段：全部 / 题干 / 选项 / 解析 */
.screen [data-role="qsearch"] [data-role="qs-scopes"] {
  flex: 0 0 auto;
  display: flex;
  gap: 8px;
  padding: 10px 12px;
}
.screen [data-role="qsearch"] [data-role="qs-scopes"] button {
  flex: 1 1 0;
  height: 32px;
  border: 1px solid var(--cl-border-base, #e7e7f0);
  border-radius: 999px;
  background: var(--cl-bg-card, #fff);
  color: var(--cl-text-secondary, #5b5a66);
  font-size: var(--fs-caption, 13px);
  cursor: pointer;
}
.screen [data-role="qsearch"] [data-role="qs-scopes"] button[data-on="1"] {
  border-color: var(--cl-primary-600, #7b61ff);
  background: var(--cl-primary-50, #f3efff);
  color: var(--cl-primary-600, #7b61ff);
  font-weight: 600;
}
/* 结果列表 */
.screen [data-role="qsearch"] [data-role="qs-list"] {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  overscroll-behavior: contain;
  -webkit-overflow-scrolling: touch;
  padding: 0 12px 24px;
  scrollbar-width: none;
}
.screen [data-role="qsearch"] [data-role="qs-list"]::-webkit-scrollbar {
  display: none;
  width: 0;
}
.screen [data-role="qsearch"] [data-role="qs-tip"] {
  padding: 32px 0;
  text-align: center;
  color: var(--cl-text-secondary, #5b5a66);
  font-size: var(--fs-caption, 13px);
}
.screen [data-role="qsearch"] [data-role="qs-count"] {
  padding: 8px 2px;
  color: var(--cl-text-secondary, #5b5a66);
  font-size: var(--fs-caption, 13px);
}
/* 一条结果 = 一个独立卡（不嵌套、不连片投影，同首页列表卡那条口径） */
.screen [data-role="qsearch"] [data-role="qs-item"] {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 12px;
  margin-bottom: 8px;
  border-radius: var(--rd-md, 12px);
  background: var(--cl-bg-card, #fff);
  box-shadow: inset 0 0 0 1px var(--cl-border-base, #e7e7f0);
  cursor: pointer;
}
.screen [data-role="qsearch"] [data-role="qs-item"]:active {
  background: var(--cl-bg-sunken, #f3f3f7);
}
.screen [data-role="qsearch"] [data-role="qs-main"] {
  flex: 1 1 auto;
  min-width: 0;
}
.screen [data-role="qsearch"] [data-role="qs-title"] {
  color: var(--cl-text-primary, #1c1b22);
  font-size: var(--fs-body, 15px);
  line-height: 22px;
  /* 两行截断：长题干不把列表撑成一屏一条 */
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  line-clamp: 2;
  overflow: hidden;
  word-break: break-all;
}
/* 命中高亮（逐段 span 创建，不拼 innerHTML —— 见 bank.js 的 highlightInto） */
.screen [data-role="qsearch"] [data-role="qs-title"] mark {
  background: transparent;
  color: var(--cl-primary-600, #7b61ff);
  font-weight: 600;
}
.screen [data-role="qsearch"] [data-role="qs-meta"] {
  margin-top: 4px;
  color: var(--cl-text-secondary, #5b5a66);
  font-size: var(--fs-caption, 13px);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* 管理员才看得到的编辑按钮（非管理员那个节点根本不会被创建） */
.screen [data-role="qsearch"] [data-role="qs-edit"] {
  flex: 0 0 auto;
  height: 32px;
  padding: 0 12px;
  border: 1px solid var(--cl-primary-600, #7b61ff);
  border-radius: var(--rd-sm, 8px);
  background: var(--cl-bg-card, #fff);
  color: var(--cl-primary-600, #7b61ff);
  font-size: var(--fs-caption, 13px);
  font-weight: 600;
  cursor: pointer;
}
.screen [data-role="qsearch"] [data-role="qs-edit"]:active {
  background: var(--cl-primary-50, #f3efff);
}
/* 翻页 */
.screen [data-role="qsearch"] [data-role="qs-pager"] {
  display: flex;
  justify-content: center;
  gap: 12px;
  padding: 12px 0;
}
.screen [data-role="qsearch"] [data-role="qs-pager"] button {
  min-width: 96px;
  height: 36px;
  border: 1px solid var(--cl-border-base, #e7e7f0);
  border-radius: var(--rd-sm, 8px);
  background: var(--cl-bg-card, #fff);
  color: var(--cl-text-primary, #1c1b22);
  font-size: var(--fs-caption, 13px);
  cursor: pointer;
}
.screen [data-role="qsearch"] [data-role="qs-pager"] button:disabled {
  color: var(--cl-text-disabled, #b5b5c2);
  background: var(--cl-bg-secondary, #f3f3f8);
  cursor: default;
}

/* ③ 管理员编辑浮层（盖在搜索面板上）────────────────────────── */
.screen [data-role="qedit"] {
  position: fixed;
  inset: 0;
  height: auto;
  z-index: 8700;
  display: flex;
  flex-direction: column;
  background: var(--cl-bg-page, #fff);
  padding-bottom: var(--xq-kb, 0px);
  transition: padding-bottom 0.18s ease;
}
.screen [data-role="qedit"] [data-role="qe-bar"] {
  flex: 0 0 auto;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  padding: 10px 12px;
  border-bottom: 1px solid var(--cl-border-base, #e7e7f0);
  background: var(--cl-bg-card, #fff);
}
.screen [data-role="qedit"] [data-role="qe-title"] {
  flex: 1 1 auto;
  text-align: center;
  color: var(--cl-text-primary, #1c1b22);
  font-size: var(--fs-body, 15px);
  font-weight: 600;
}
.screen [data-role="qedit"] [data-role="qe-cancel"],
.screen [data-role="qedit"] [data-role="qe-save"] {
  flex: 0 0 auto;
  height: 36px;
  padding: 0 14px;
  border-radius: var(--rd-sm, 8px);
  font-size: var(--fs-body, 15px);
  cursor: pointer;
}
.screen [data-role="qedit"] [data-role="qe-cancel"] {
  border: none;
  background: transparent;
  color: var(--cl-text-secondary, #5b5a66);
}
.screen [data-role="qedit"] [data-role="qe-cancel"]:active {
  background: var(--cl-bg-sunken, #f3f3f7);
}
.screen [data-role="qedit"] [data-role="qe-save"] {
  border: none;
  background: var(--cl-primary-600, #7b61ff);
  color: #fff;
  font-weight: 600;
}
.screen [data-role="qedit"] [data-role="qe-save"]:disabled {
  background: var(--cl-text-disabled, #b5b5c2);
  cursor: default;
}
.screen [data-role="qedit"] [data-role="qe-body"] {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  overscroll-behavior: contain;
  -webkit-overflow-scrolling: touch;
  padding: 14px 12px 32px;
  scrollbar-width: none;
}
.screen [data-role="qedit"] [data-role="qe-body"]::-webkit-scrollbar {
  display: none;
  width: 0;
}
.screen [data-role="qedit"] [data-role="qe-field"] {
  margin-bottom: 16px;
}
.screen [data-role="qedit"] [data-role="qe-label"] {
  margin-bottom: 6px;
  color: var(--cl-text-secondary, #5b5a66);
  font-size: var(--fs-caption, 13px);
}
.screen [data-role="qedit"] [data-role="qe-ta"] {
  width: 100%;
  min-height: 88px;
  padding: 10px 12px;
  border: 1px solid var(--cl-border-base, #e7e7f0);
  border-radius: var(--rd-sm, 8px);
  background: var(--cl-bg-sunken, #f3f3f7);
  color: var(--cl-text-primary, #1c1b22);
  font-size: var(--fs-body, 15px);
  line-height: 22px;
  resize: vertical;
  outline: none;
  box-sizing: border-box;
}
.screen [data-role="qedit"] [data-role="qe-ta"]:focus {
  border-color: var(--cl-primary-500, #3b6cf6);
  background: var(--cl-bg-card, #fff);
}
/* 选项一行：[✓答案][key][value][删] —— 固定列宽，同列等宽 */
.screen [data-role="qedit"] [data-role="qe-opt"] {
  display: flex;
  align-items: center;
  gap: 6px;
  margin-bottom: 6px;
}
.screen [data-role="qedit"] [data-role="qe-pick"] {
  flex: 0 0 32px;
  width: 32px;
  height: 32px;
  border: 1px solid var(--cl-border-base, #e7e7f0);
  border-radius: 50%;
  background: var(--cl-bg-card, #fff);
  color: var(--cl-success-700, #1a9e5c);
  font-size: 14px;
  font-weight: 700;
  cursor: pointer;
}
.screen [data-role="qedit"] [data-role="qe-pick"][data-on="1"] {
  border-color: var(--cl-success-500, #1a9e5c);
  background: var(--cl-success-50, #e9f7ef);
}
.screen [data-role="qedit"] [data-role="qe-key"] {
  flex: 0 0 44px;
  width: 44px;
  height: 32px;
  padding: 0 8px;
  border: 1px solid var(--cl-border-base, #e7e7f0);
  border-radius: var(--rd-sm, 8px);
  background: var(--cl-bg-card, #fff);
  color: var(--cl-text-primary, #1c1b22);
  font-size: var(--fs-caption, 13px);
  text-align: center;
  outline: none;
}
.screen [data-role="qedit"] [data-role="qe-val"] {
  flex: 1 1 auto;
  min-width: 0;
  height: 32px;
  padding: 0 10px;
  border: 1px solid var(--cl-border-base, #e7e7f0);
  border-radius: var(--rd-sm, 8px);
  background: var(--cl-bg-card, #fff);
  color: var(--cl-text-primary, #1c1b22);
  font-size: var(--fs-body, 15px);
  outline: none;
}
.screen [data-role="qedit"] [data-role="qe-key"]:focus,
.screen [data-role="qedit"] [data-role="qe-val"]:focus {
  border-color: var(--cl-primary-500, #3b6cf6);
}
.screen [data-role="qedit"] [data-role="qe-del"] {
  flex: 0 0 40px;
  height: 32px;
  border: none;
  border-radius: var(--rd-sm, 8px);
  background: transparent;
  color: var(--cl-danger-600, #c0272d);
  font-size: var(--fs-caption, 13px);
  cursor: pointer;
}
.screen [data-role="qedit"] [data-role="qe-del"]:active {
  background: var(--cl-danger-50, #fbecec);
}
.screen [data-role="qedit"] [data-role="qe-add"] {
  width: 100%;
  height: 36px;
  margin-top: 2px;
  border: 1px dashed var(--cl-border-base, #e7e7f0);
  border-radius: var(--rd-sm, 8px);
  background: transparent;
  color: var(--cl-text-secondary, #5b5a66);
  font-size: var(--fs-caption, 13px);
  cursor: pointer;
}

/* ════════════════════════════════════════════════════════════════════════
   用户端组卡上的「立体书」封面（2026-10-20 重做：真 3D 盒子）
   用户原话：「这些没有封面的图片，我们在用户那边也要同步」「不需要那个底图
   外框了」「近大远小」「注意圆角」。

   结构跟后台题库管理（adminx/engine.js 的 bookCover）一模一样，四个面：
     face  封面（贴图/文字）   spine 书脊   edge 页口纸层   top 纸页顶断面
   透视由挂载点的 perspective 给（`mountBook` 里按 8 倍宽度设，44/48/60 三档
   各有各的视距），近大远小是几何算出来的。
   ⚠ 立着的书看不见底面，所以没有 bottom 面 —— 做了反而像书底下垫了张纸。

   ⚠ `container-type` 和 `transform-style: preserve-3d` **不能同元素**：
     contain:layout 会把 3D 强制拍平，还不报错。所以 `.xq-book` 只做尺寸容器、
     `.xq-book-3d` 才是 3D 舞台。
   ⚠ 这里没有"浅紫圆角底框"了（`mountBook` 里 background/overflow 一起拆的）：
     书自己会往卡片上投一道影子，底下再垫一块圆角浅色就成了"贴纸放在框里"。
   ════════════════════════════════════════════════════════════════════════ */
.xq-book {
  position: relative;
  width: 100%; height: 100%;
  container-type: inline-size;
}
.xq-book-3d {
  position: absolute; inset: 0;
  transform-style: preserve-3d;
  /* 30° 比后台那 26° 再大一点：这格只有 44~60px，角度小的话书脊投影不到 3px，
     立体感直接消失。translateX 回补旋转后整体的左移。
     ⚠ **不加 rotateX**：俯角会让书顶前倾、底边变宽，看着像"书要往前倒"（用户原话
       「有种书本快掉下来的感觉」）。立体感全交给 rotateY 的水平透视。 */
  transform: translateX(2%) rotateY(30deg);
  transition: transform .35s cubic-bezier(.22, .75, .25, 1);
}

/* 封面：唯一承载内容的面，沿 +z 推出半个厚度 */
.xq-book-face {
  position: absolute; inset: 0;
  transform: translateZ(9cqw);
  /* 圆角：靠书脊那侧小（真书装订边几乎是直角）、对开那侧大 */
  border-radius: 2% 6% 6% 2%;
  background: linear-gradient(158deg,
    hsl(var(--bk-h, 258) 74% 64%),
    hsl(var(--bk-h, 258) 70% 47%));
  overflow: hidden;
  box-shadow: 5cqw 8cqw 14cqw -8cqw rgba(15, 23, 42, .45);
}
/* 书脊：绕左边缘转 90°，中间一道竖向高光（精装书的书脊都是圆弧） */
.xq-book-spine {
  position: absolute; left: 0; top: 0; bottom: 0;
  width: 18cqw;
  transform-origin: 0% 50%;
  transform: translateZ(-9cqw) rotateY(-90deg);
  backface-visibility: hidden;
  border-radius: 2% 0 0 2%;
  background: linear-gradient(90deg,
    hsl(var(--bk-h, 258) 62% 50%) 0%,
    hsl(var(--bk-h, 258) 66% 60%) 34%,
    hsl(var(--bk-h, 258) 60% 78%) 50%,
    hsl(var(--bk-h, 258) 66% 60%) 66%,
    hsl(var(--bk-h, 258) 60% 46%) 100%);
}
/* 贴图的书：书脊靠封面那端渐变到封面主色 —— 书脊是封面纸折进装订边的，
   颜色直接怼一条同色相深色边会像两个零件拼的（用户：「书脊直接吸色，
   邻近色要有一个过渡色更自然」）。--bk-tint 是吸色时带出来的封面平均 RGB。
   ⚠ 几何：书脊局部 x=100% 那端旋转后落在 z=+9cqw，正是贴着封面左缘的位置
     （0% 端在背面）—— 所以过渡色必须收在 100%，深边留在 0%。
   无图的书没有 --bk-tint，保持纯色相渐变（下面这条不生效）。 */
.xq-book.has-tint .xq-book-spine {
  background: linear-gradient(90deg,
    hsl(var(--bk-h, 258) 58% 46%) 0%,
    hsl(var(--bk-h, 258) 66% 60%) 32%,
    hsl(var(--bk-h, 258) 60% 78%) 48%,
    hsl(var(--bk-h, 258) 64% 58%) 62%,
    hsl(var(--bk-h, 258) 60% 52%) 76%,
    rgb(var(--bk-tint, 122 108 168)) 100%);
}
/* 页口 / 顶断面：细白纸层。纯白会把整本书的明压掉，压到 #fdfefe/#d9e1ec 这一档。
   ⚠ 页口这一面在 30° 下是背向的，必须藏背面 —— 否则右边会多出一条本不该存在的
     白边，视觉上读成"书脊在右边"，方向全反。 */
.xq-book-edge {
  position: absolute; right: 0; top: 0; bottom: 0;
  width: 18cqw;
  transform-origin: 100% 50%;
  transform: translateZ(-9cqw) rotateY(90deg);
  backface-visibility: hidden;
  background: repeating-linear-gradient(90deg, #fdfefe 0 1px, #d9e1ec 1px 2px);
}
.xq-book-top {
  position: absolute; left: 0; top: 0; width: 100%; height: 18cqw;
  transform-origin: 0% 0%;
  transform: translateZ(-9cqw) rotateX(90deg);
  background: repeating-linear-gradient(180deg, #fdfefe 0 1px, #e0e7f1 1px 2px);
}

/* 上传的封面贴到书壳正面 */
.xq-book-art {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  object-fit: cover; display: block;
  z-index: 0;
}
/* 没图（本来没封面 / 图 404）：藏掉贴图，露出文字书 */
.xq-book:not(.has-art) .xq-book-art { display: none; }
.xq-book.has-art .xq-book-body { display: none; }

/* 覆膜反光：斜向一道白。压在书名上会糊字，所以 z-index 低于正文 */
.xq-book-face::after {
  content: "";
  position: absolute; inset: 0;
  z-index: 1;
  background: linear-gradient(112deg,
    rgba(255, 255, 255, 0) 38%,
    rgba(255, 255, 255, .14) 46%,
    rgba(255, 255, 255, .05) 54%,
    rgba(255, 255, 255, 0) 62%);
  pointer-events: none;
}
.xq-book-body {
  position: absolute; inset: 0;
  /* 左边不用再给装订让位 —— 书脊已经是独立的一个面 */
  padding: 9% 7% 8% 10%;
  display: flex; flex-direction: column; justify-content: center;
  color: #fff;
}
.xq-book-kind {
  font-size: 6px;
  font-weight: 600; letter-spacing: .12em;
  opacity: .8; line-height: 1.2;
}
.xq-book-name {
  margin-top: 6%;
  display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical;
  overflow: hidden;
  font-size: 8.5px;
  font-weight: 700; line-height: 1.28;
  text-shadow: 0 1px 2px rgba(0, 0, 0, .26);
  word-break: break-word;
}
/* 60 宽那档（题库详情的概览卡）：字放大一级，否则 8.5px 摆在 60 宽的书上发空 */
.xq-book--lg .xq-book-kind { font-size: 7px; }
.xq-book--lg .xq-book-name { font-size: 10px; }

/* ════════════════════════════════════════════════════════════════════════
   2026-10-20 用户端三屏改造（#27 我的书架化 / #28 资料提密度 / #29 公告提密度）

   用户原话（逐字）：
   ① 「在我的那个界面，我们要给它进行个书架形式的改造。然后把快捷入口滚动到
       上面，书架放在下边，就是要给它进行一个类型的改造」
   ② 「资料列表这个列表太大了，字号太大了，下载按钮又太大了，图标又太大了，
       整个空间密度很小、信息密度很小，然后不够密」
   ③ 「包括公告的列表也是一样的，我们要做一次改造」

   ⚠ 帧 CSS（gaozi/assets/pages/*.css）是 gen.mjs 的生成物，**不能改**；
     且它是运行时动态注入的，排在 app.css **后面** —— 同特异性时帧赢。
     所以这里所有覆盖一律双类起步（.res-page .res-card = 0,2,0 压得住 0,1,0），
     子元素用 data-name 选择器：节点编号（.n-8-946）是稿子坐标，稿子重新生成
     会变，data-name 不会。
   ════════════════════════════════════════════════════════════════════════ */

/* ── #27/#31 「我的」页 · 组织书架 ──────────────────────────────────────
   视觉参照后台组织管理的 .ax-shelf（同日已书架化），用户端格子小一档：
   书 56 宽（book.js 里 mountBook 的 lg 档），393 宽屏排 4 列。
   结构在 profile.js 的 orgShelfCell()：书 → 组织名 → 「管理员 · 剩余N天」；
   第一格是激活入口（orgActivateCell，虚线「+」位，2026-10-20 从大卡片改来）。
   ⚠ align-self:stretch 是**必需的**（#31 修复）：组织区那一格是
     align-items:flex-start（帧 .n-8-993），子节点不 stretch ⇒ 书架缩成
     内容宽，auto-fill 排不开，每行只挤出 2 格（用户报的「两个一行」就是它）。
     撑满之后 390 屏 4 列、窄屏自然掉 3/2 列 —— auto-fill 自适应，不用 media query。 */
.org-shelf {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(76px, 1fr));
  gap: 12px 6px;
  align-content: start;
  align-self: stretch;
  padding: 4px 0 6px;
}
.org-shelf-cell {
  position: relative;
  min-width: 0;
  display: flex; flex-direction: column; align-items: center;
  gap: 5px;
  padding: 6px 2px 7px;
  border-radius: var(--rd-md);
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  text-align: center;
  transition: transform .12s ease, background-color .12s ease;
}
.org-shelf-cell:active {
  transform: scale(.95);
  background: rgba(28, 27, 34, .05);
}
/* ⚠ 这格**不能加 overflow:hidden**：书影子（.xq-book-face 的 box-shadow）会往
   格子边界外投一点点，裁掉就从"书摆在架上"变成"贴纸嵌在框里"。 */
.org-shelf-book {
  display: flex; align-items: center; justify-content: center;
}
.org-shelf-name {
  max-width: 100%;
  font-size: 12.5px; font-weight: var(--fw-semibold);
  line-height: 1.3;
  color: var(--cl-text-primary);
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
  overflow: hidden;
  word-break: break-word;
}
.org-shelf-meta {
  max-width: 100%;
  font-size: 11px; line-height: 1.3;
  color: var(--cl-text-tertiary);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}

/* ── #28 资料页 · 提密度 ─────────────────────────────────────────────
   帧的原值：卡 padding 16 / gap 12、图标底 44×44、图标 24、资料名 17px、
   说明 13px、下载按钮高 48 padding-x 20 字号 15、content gap 16 padding 16/20。
   一条卡 ≈ 96px（含间距）；改完 ≈ 64px —— 一屏从 6 条变 9 条左右。 */
.res-page .res-list {
  gap: 10px;
  padding: 12px 14px 20px;
}
/* 提示条常驻文字，跟着紧凑一档（它不是列表，但同样占一屏高度） */
.res-page [data-name="提示条"] { padding: 8px 10px; gap: 6px; }
.res-page [data-name="提示文字"] { font-size: 12px; line-height: 17px; }

.res-page .res-card {
  gap: 8px;
  padding: 9px 10px;
  border-radius: 12px;
}
.res-page .res-card [data-name="图标底"] {
  width: 36px; height: 36px;
  border-radius: 10px;
  flex: 0 0 auto;
}
.res-page .res-card [data-name="Icon/CloudDownload"] {
  width: 20px; height: 20px;
}
.res-page .res-card [data-name="文字列"] { gap: 1px; }
.res-page .res-card [data-name="资料名"] {
  font-size: 15px; line-height: 20px;
  /* 长文件名单行省略：换行会把卡撑高，密度就白提了 */
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.res-page .res-card [data-name="资料说明"] {
  font-size: 12px; line-height: 16px;
}
/* 下载按钮：48 → 34（用户原话「下载按钮又太大了」）。
   ⚠ 34 低于 44 的触控建议，但卡上真正的大热区是整条卡的右半边，
     这是用户作为产品负责人点名要的密度，照办。 */
.res-page .res-card [data-name="Component/Button/Secondary/Default"] {
  height: 34px;
  padding: 0 12px;
  border-radius: 8px;
  flex: 0 0 auto;
}
.res-page .res-card [data-name="Component/Button/Secondary/Default"] [data-name="标签"] {
  font-size: 13px; line-height: 18px;
}

/* ── #29 公告页 · 提密度 ─────────────────────────────────────────────
   帧的原值：卡 padding 16 / 卡内 gap 8、标题 17px、时间 13px、
   content gap 16 padding 16/20。同一套量级压下来，两页手感一致。 */
.ann-page .ann-list {
  gap: 8px;
  padding: 12px 14px 20px;
}
.ann-page .ann-card {
  gap: 4px;
  padding: 9px 12px;
  border-radius: 12px;
}
.ann-page .ann-card [data-name="标题行"] { gap: 8px; }
.ann-page .ann-card [data-name="公告标题"] {
  font-size: 15px; line-height: 20px;
  /* 长标题最多两行，超出省略 —— 公告标题没有 clamp，长标题能把卡撑成三行 */
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
  overflow: hidden;
}
.ann-page .ann-card [data-name="查看详情"] {
  font-size: 12px; line-height: 18px;
}
/* 公告时间与公告描述共用帧类（描述行是 JS 借公告时间的 class 画的），一起降档 */
.ann-page .ann-card [data-name="公告时间"],
.ann-page .ann-card [data-name="公告描述"] {
  font-size: 12px; line-height: 16px;
}
