/* Andromate 群控台样式
 *
 * ★★★ 视觉风格：莫兰迪柔和（2026-10-21 改版）
 *
 * 设计目标：暖白背景 + 鼠尾草绿主色 + 灰粉/浅沙辅色，
 * 低饱和、留白舒展、圆角柔和，减少生硬的边框与阴影。
 * 正文用深灰而非纯黑（纯黑在暖白底上过于生硬、且长时间阅读刺眼）。
 * 主要按钮用更深的品牌色，让"重点依然清晰"——
 *   柔和 ≠ 全部灰掉。层级靠**明度与饱和度的细微差别**表达，
 *   而不是靠边框和阴影堆出来的"硬"。
 *
 * ★ 莫兰迪色的实现要点（避免做成"洗白的深色主题"）：
 *   低饱和 ≠ 低对比。莫兰迪色是「掺了灰的高明度色」，
 *   相互之间的差主要体现在**色相**而非明度上，
 *   所以同色系元素必须靠留白和字重拉开层次，
 *   不能指望颜色本身区分度足够。
 *
 * ★★ 关键取舍：视频画面区域保持深色。
 *   19 宫格 + 放大窗的**画面本身**必须是深底 ——
 *   手机截图内容千变万化（白底 App、深色壁纸、彩色图标），
 *   浅色画框会与内容抢注意力、降低辨识度。
 *   所以：外层 UI 全面转暖白莫兰迪，
 *   画面(canvas/video)保留深底，视觉重心自然落在画面上。
 *   这不是"没改完"，是刻意的。
 */

:root {
  /* ---------- 底色层：暖白 → 浅沙 ---------- */
  --bg: #f4f1ea;          /* 页面底：暖白（带一点点黄绿灰，不是纯白） */
  --bg-soft: #efebe2;     /* 次级底：稍深的暖白，用来做分区 */
  --panel: #fbf9f5;       /* 面板/卡片：接近纯白但带暖调 */
  --panel2: #eae5d9;      /* 次级面：浅沙色，输入框/条目底 */
  --line: #e0d9cb;        /* 分隔线：极浅沙，比原来的 #252a35 柔和得多 */
  --line-soft: #eae4d8;   /* 更弱的分隔线 */

  /* ---------- 文字层：深灰，不是纯黑 ---------- */
  --fg: #4a4640;          /* 正文：暖调深灰，比 #000 柔和、比 #e8eaed 有层次 */
  --fg-strong: #35322d;   /* 强调文字/标题 */
  /* ★ --dim / --dim-faint 的明度是**算出来的**，不是拍的。
     浅色主题下这两个「弱化色」最容易不达标：
     莫兰迪色本身明度高，再叠上"弱化"就彻底看不清了。
     目标：dim ≥ 4.5:1（正文级，因为 .muted 用在提示文字上）、
     dim-faint ≥ 3.0:1（仅用于时间戳等非关键信息）。
     见 _check_contrast.py 的实测。 */
  --dim: #726c61;         /* 次要说明文字（页面底 4.62:1 / 面板 4.95:1） */
  --dim-faint: #948d80;   /* 最弱（时间戳等，3.13:1） */

  /* ---------- 品牌色：鼠尾草绿 ---------- */
  --pri: #7d9070;         /* 鼠尾草绿：主品牌色 */
  --pri-deep: #5f7054;    /* 更深的品牌色：主要按钮用（保证重点清晰） */
  --pri-soft: #e3e9dc;    /* 浅鼠尾草：选中态底色 */
  --pri-line: #b9c7ac;    /* 鼠尾草描边 */

  /* ---------- 辅助色：灰粉 + 浅沙 ---------- */
  --rose: #c4a8a0;        /* 灰粉 */
  --rose-soft: #f0e6e2;   /* 浅灰粉底 */
  --sand: #d9c9a8;        /* 浅沙 */
  --sand-soft: #f2ead9;   /* 浅沙底 */

  /* ---------- 语义色：莫兰迪化 ---------- */
  /* 关键：语义色不能一味降低饱和度，否则「失败」和「成功」
     看起来一样、就失去了重点。做法是**降亮度、保留色相辨识**，
     并保证在暖白底上的对比度 ≥ 4.5:1（正文级）。
     四个语义色的明度都是按对比度反推出来的：
       err  #9c5a51 → 5.02:1
       ok   #57753f → 5.36:1
       warn #8a6a2e → 5.24:1
       info #5b7288 → 5.15:1
     低饱和靠「掺灰」实现，不靠「变浅」——
     变浅会让它们在暖白底上消失。 */
  --ok: #57753f;          /* 鼠尾草深绿：正常/成功 */
  --ok-soft: #e6ede1;
  --warn: #8a6a2e;        /* 暖赭：警告 */
  --warn-soft: #f5ecdb;
  --err: #9c5a51;         /* 灰粉深：失败 */
  --err-soft: #f4e3e0;
  --info: #5b7288;        /* 雾霾蓝：提示 */
  --info-soft: #e4e9ee;

  /* ---------- 画面区：保持深色（见文件头说明） ---------- */
  --screen: #1c1a17;      /* 画面底：暖调深灰，不是死黑 */
  --screen-line: #3a3630;

  /* ---------- 阴影：柔和、极淡 ---------- */
  --shadow-sm: 0 1px 2px rgba(74, 70, 64, .04);
  --shadow-md: 0 2px 8px rgba(74, 70, 64, .06);
  --shadow-lg: 0 8px 24px rgba(74, 70, 64, .10);
  /* 圆角：统一柔和 */
  --r-sm: 8px;
  --r-md: 12px;
  --r-lg: 18px;
}

* { box-sizing: border-box; }

/* ★ 通用兜底：任何带 hidden 属性的元素都必须真正隐藏。
   .login/.focus/.tile-spinner 都自带 display 声明，
   会覆盖 [hidden] 的浏览器默认 display:none（实测导致
   「点关闭无反应」）。这一条把全站的坑一次性堵住，
   以后新增带 display 的元素不会再犯同样的错。 */
[hidden] { display: none !important; }

html, body {
  margin: 0;
  height: 100%;
  background: var(--bg);
  color: var(--fg);
  font: 14px/1.6 -apple-system, "Segoe UI", "PingFang SC",
        "Microsoft YaHei", sans-serif;
  /* 细滚动条：默认的深色滚动条在暖白底上非常突兀 */
  scrollbar-width: thin;
  scrollbar-color: #cfc8ba transparent;
  /* ★ 页面本身不滚动：滚动只发生在宫格/面板/日志这些内部容器里。
     之前 main 高度写死 calc(100vh - 47px) 时，
     header 一变高就会多出页面级滚动条（实测 1089 > 1080）。
     改成 flex 布局后高度自动配平，再加 overflow:hidden 兜底。 */
  overflow: hidden;
}
::-webkit-scrollbar { width: 9px; height: 9px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb {
  background: #cfc8ba;
  border-radius: 5px;
  border: 2px solid transparent;
  background-clip: content-box;
}
::-webkit-scrollbar-thumb:hover { background-clip: content-box; background-color: #b8b0a0; }

button {
  padding: 7px 13px;
  background: var(--panel);
  color: var(--fg);
  border: 1px solid var(--line);
  border-radius: var(--r-sm);
  cursor: pointer;
  font-size: 13px;
  transition: background .15s ease, border-color .15s ease, color .15s ease;
}
button:hover { background: var(--panel2); border-color: var(--pri-line); }
button.pri {
  /* ★ 主要按钮用**更深**的品牌色：莫兰迪色偏浅，
     若按钮也用浅鼠尾草绿，在暖白底上会「重点丢失」。
     加深一档（--pri-deep）既保持色相一致，又让主次分明。 */
  background: var(--pri-deep);
  border-color: var(--pri-deep);
  color: #fff;
}
button.pri:hover { background: #526347; border-color: #526347; }
button.danger { border-color: #d9b5ae; color: var(--err); background: var(--err-soft); }
button.danger:hover { background: #eed9d5; border-color: #c99a92; }
button.rec { border-color: #ddc9a6; color: var(--warn); background: var(--warn-soft); }
button.rec.recording {
  background: var(--err);
  border-color: var(--err);
  color: #fff;
}
button.on { background: var(--pri-soft); border-color: var(--pri-line); color: var(--pri-deep); }
button.block { width: 100%; margin: 8px 0; }
button.ghost { border-color: transparent; color: var(--dim); background: transparent; }
button.ghost:hover { color: var(--fg); background: var(--panel2); }
button:disabled { opacity: .45; cursor: default; }
button:focus-visible {
  outline: 2px solid var(--pri);
  outline-offset: 2px;
}

/* ★★★ 勾选框/单选框必须排除（2026-10-23 修「权限勾选错位」）：
 *   这条规则原本写 `input, select {...}`，把 type=checkbox 也套上了
 *   7px 内边距 + 1px 边框 —— 勾选框被撑成一个 ~30px 的高盒子，
 *   Chrome 把勾选方块画在盒子**左上角**，视觉上就「浮在标签上一行」，
 *   角色权限矩阵 / 归属勾选全部错位。排除后勾选框回归原生尺寸。 */
input:not([type="checkbox"]):not([type="radio"]), select {
  padding: 7px 10px;
  background: var(--panel);
  color: var(--fg);
  border: 1px solid var(--line);
  border-radius: var(--r-sm);
  font-size: 13px;
  min-width: 0;
  transition: border-color .15s ease, box-shadow .15s ease;
}
/* 勾选/单选：显式归零（防 UA 样式或旧规则残留再把它撑大） */
input[type="checkbox"], input[type="radio"] { padding: 0; }
input:focus, select:focus {
  outline: none;
  border-color: var(--pri);
  box-shadow: 0 0 0 3px rgba(125, 144, 112, .16);
}
input.range { padding: 0; width: 110px; accent-color: var(--pri); }

.muted { color: var(--dim); }
.tiny { font-size: 12px; }
.ellipsis { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.spacer { flex: 1; }

.pill {
  padding: 3px 11px;
  border-radius: 11px;
  font-size: 12px;
  border: 1px solid var(--line);
  color: var(--dim);
  background: var(--panel);
  white-space: nowrap;
}
.pill.ok { color: var(--ok); border-color: var(--pri-line); background: var(--ok-soft); }
.pill.warn { color: var(--warn); border-color: #ddc9a6; background: var(--warn-soft); }
.pill.err { color: var(--err); border-color: #d9b5ae; background: var(--err-soft); }

/* ---------- 登录 ---------- */

.login {
  display: grid;
  place-items: center;
  height: 100%;
  /* 登录卡在极矮的视口下可能超出，此时允许自身滚动，
     否则 body 的 overflow:hidden 会把底部内容裁掉。 */
  overflow: auto;
  padding: 24px 0;
}
/* 同 .focus[hidden]：display:grid 会覆盖 hidden 属性的
   display:none，必须显式声明。 */
.login[hidden] { display: none; }
.login-card {
  width: 380px;
  padding: 36px;
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  box-shadow: var(--shadow-lg);
}
.login-card h1 { margin: 0 0 4px; font-size: 20px; color: var(--fg-strong); }
.login-card label { display: block; margin: 20px 0 6px; font-size: 12px; color: var(--dim); }
.login-card .errmsg { color: var(--err); min-height: 20px; font-size: 13px; }
/* 运维 Token 入口收进折叠区：日常用账号口令，
   Token 只是排障/脚本化的旁路，不该占据首屏。 */
.login-extra { margin-top: 18px; }
.login-extra summary { cursor: pointer; color: var(--dim); }
.login-extra label { margin-top: 14px; }
code {
  background: var(--panel2);
  color: var(--fg-strong);
  padding: 2px 6px;
  border-radius: 5px;
}

/* ---------- 布局 ---------- */

header {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 13px 24px;
  /* ★ 去掉生硬的 border-bottom，改用**极浅的分隔线**。
     莫兰迪风格的关键就是「少用线，用留白分区」。 */
  border-bottom: 1px solid var(--line-soft);
  background: var(--bg-soft);
  flex-wrap: wrap;
  /* ★ 不参与压缩：header 高度由内容决定，
     剩余空间全部给 main（flex:1）。 */
  flex: none;
}
header h1 { font-size: 16px; margin: 0; font-weight: 600; color: var(--fg-strong); }

/* ★★★ #app 改为纵向 flex 布局（2026-10-21 改版时修）。
 *
 * 原来 main 的高度写死成 calc(100vh - 47px)，
 * 这个 47 是「header 的高度」的硬编码副本。
 * 改版时 header 的 padding 从 10px 提到 13px，
 * header 实际变成 60px，而 main 还在按 47px 算
 * → 文档总高 1089 > 视口 1080，**多出 9px 的页面级滚动条**。
 *
 * 这种「两处数值脱钩」的坑本轮已经踩过两次
 * （canvas 尺寸 vs 卡片宽度、header 高度 vs main 高度），
 * 所以这里直接改成 flex：header 撑自己的高度，
 * main 用 flex:1 吃掉剩余空间，**不再有任何硬编码高度**。
 * 以后改 header 的 padding，main 自动跟随。
 */
#app {
  display: flex;
  flex-direction: column;
  height: 100%;
  min-height: 0;
}

main {
  display: grid;
  /* ★ 右栏宽度改用变量：既能折叠让位给宫格，
     又不用在这里写死具体像素。 */
  grid-template-columns: 1fr var(--panel-w, 380px);
  flex: 1;
  /* 两轴都要 0：main 自己是 #app 的 flex 项（min-height:auto 会顶高），
     同时它内部的 1fr 轨道也受 min-width:auto 影响。 */
  min-width: 0;
  min-height: 0;
  transition: grid-template-columns .18s ease;
}
/* ★ 折叠态：右栏宽度归零，宫格独占整屏。
   用户反馈「宫格也太小了」——实测右栏硬占 680px，
   1920 视口下宫格只剩 1208px，格子被压到 164x292。
   折叠后宫格可用宽度从 1208px 涨到 1832px（+51%）。 */
body.panel-collapsed main {
  grid-template-columns: 1fr 0;
}
/* ★ 折叠态必须把 --panel-w 也置 0。
   之前 --panel-w 从未被真正定义（各处都在用 fallback 380px），
   折叠只改了 grid-template-columns，变量没跟着变。
   于是 .focus 的 right: var(--panel-w, 380px) 在折叠后仍是 380px，
   遮罩右侧会留出一条 380px 的空隙（宫格被遮住却看不见）。
   把变量本身归零，两处（grid 列宽 / 遮罩右边界）就永远一致。 */
body.panel-collapsed { --panel-w: 0px; }
body.panel-collapsed .panel {
  border-left: none;
  overflow: hidden;
}
/* ★★ 折叠态：右栏宽 0，但 .tabs 仍是 5 列 grid。
 *   5 列 × minmax(0,1fr) 在 0 宽容器里 → 每列 0 宽，
 *   标签内容（white-space:nowrap）撑破列宽 → scrollWidth > clientWidth，
 *   实测折叠态标签栏横向溢出（scrollWidth 379 vs clientWidth 0）。
 *   虽然 .panel 有 overflow:hidden 把它裁掉了、用户看不见，
 *   但溢出值本身是脏的 —— 而且如果将来 .panel 去掉 overflow:hidden，
 *   就会真的把整个页面撑出横向滚动条。
 *   折叠时直接把标签藏掉最干净：右栏本来就不可见。 */
body.panel-collapsed .tabs { display: none; }

.stage {
  display: flex;
  flex-direction: column;
  min-width: 0;
  /* ★★ 必须有 min-height:0，否则本元素在 grid 父容器里
     会被内容高度撑破（grid 项默认 min-height:auto）。
     实测 1366×768：.stage 高 1020 > 视口 768，
     底下的 .grid 自然也就跟着超出视口，
     而 html/body 是 overflow:hidden → 底部几台被裁掉且无滚动条。
     这正是「宫格固定了大小、看不到底部几台」的根因。 */
  min-height: 0;
  overflow: hidden;
  /* ★ 留白舒展：内边距从 12/16 提到 16/24，
     莫兰迪风格的「呼吸感」主要来自留白而非颜色。 */
  padding: 18px 24px;
  gap: 12px;
}

.toolbar {
  display: flex;
  align-items: center;
  gap: 10px;
  flex-wrap: wrap;
}

.hint { color: var(--dim); font-size: 12px; }

/* ---------- 宫格 ---------- */

.grid {
  flex: 1;
  /* ★ 三个条件缺一不可，缺任何一个都会退回「内容撑破、看不到底部」：
       flex:1        —— 吃掉 .stage 的剩余高度
       min-height:0  —— 允许被压缩到小于内容高度（flex 项默认 min-height:auto）
       overflow-y:auto —— 超出时出滚动条
     父级 .stage 的 min-height:0 是这套机制能生效的前提。 */
  min-height: 0;
  overflow-x: auto;
  overflow-y: auto;
  overscroll-behavior: contain;
  position: relative;
  display: grid;
  /* ★ 格子最小宽度可调（用户要求「宫格能否实现自主滑动调节大小」）。
     minmax 的下限用 CSS 变量 --cell-min 驱动，
     JS 拖动滑块时只改这一个变量，
     浏览器用 grid 重新布局，无需重建 DOM、无需重连视频流。
     上限给 1fr：格子随容器等分剩余空间。 */
  grid-template-columns: repeat(auto-fill, minmax(var(--cell-min, 150px), 1fr));
  gap: 14px;
  /* ★ align-content:start —— 行紧贴顶部，不在底部留大片空白。
     配合上面的滚动，19 台就能真正「滚到底看全」。 */
  align-content: start;
  padding-bottom: 10px;
}

.tile {
  position: relative;
  /* ★ 画面底保持深色（见文件头「关键取舍」）。
     用暖调深灰 #1c1a17 而不是死黑，与整体暖调协调。 */
  background: var(--screen);
  border: 1px solid var(--line);
  border-radius: var(--r-md);
  overflow: hidden;
  cursor: pointer;
  /* ★★★ 高度用 padding-top 百分比撑，不用 aspect-ratio（2026-10-21 实测修掉）。
   *
   * 【真凶】用户反馈「宫格调整放大后显示堆叠到一起了」。
   * 实测（真实 Chrome，_lab_grid.py）：
   *   --cell-min=140 → 行间隙 [-214, -215, -214]
   *   --cell-min=220 → 行间隙 [-383, -383, -383, -384]
   *   --cell-min=420 → 行间隙 [-875, -876, ...]
   * 负间隙 = 下一行压在上一行身上，视觉上就是「堆叠」。
   *
   * 【机理】CSS Grid 的**行盒高度**由 `grid-auto-rows`（未设 → auto）
   * 按内容/隐式行高算，而 **aspect-ratio 只决定格子自身的高宽比**，
   * 两者是**脱钩**的。`align-content: start` 让各行紧贴顶部，
   * 于是行盒远小于格子高度 → 格子互相压叠。
   *
   * 【为什么用 padding-top 而不是别的办法】
   * 实测 8 个方案（_lab_grid.py，判据=行间隙必须 ≥ 0）：
   *   基线 aspect-ratio            → -215 ❌
   *   canvas 改绝对定位            → -215 ❌（证明与 canvas 无关）
   *   tile height:100% + aspect:auto→ 行高塌成 4px ❌（循环依赖）
   *   固定列宽 + 派生行高          → ✅ 但列宽不再填满宽度
   *   **padding-top 撑高 + 1fr 列宽** → ✅ 间隙精确 14px、列宽仍填满  ★本方案
   *
   * 【为什么 padding-top 能解决】
   * 百分比 padding 相对**包含块宽度**计算，且 padding **参与内容高度**
   * → grid 的行盒高度就跟着列宽走了。
   *   16/9 = 177.7778%（= 9:16 的倒数）
   * canvas 因此必须绝对定位（否则 height:100% 相对 0 高的内容盒 → 塌陷）。
   */
  padding-top: 177.7778%;
  box-shadow: var(--shadow-sm);
  transition: border-color .15s ease, box-shadow .15s ease;
}
.tile canvas {
  display: block;
  /* ★ 必须绝对定位：tile 的内容盒高度已被 padding-top 撑成 0，
     流内布局的 height:100% 会解析成 0（画面消失）。
     absolute + inset:0 让它铺满 padding box（含 padding 区），
     再由 object-fit:contain 保持 9:16 不变形。 */
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: contain;
  background: var(--screen);
}
.tile:hover {
  border-color: var(--pri-line);
  box-shadow: var(--shadow-md);
}

/* ---------- 选中态（2026-10-24，第十九轮） ----------
 *
 * ★★ 用户反馈：「机子宫格被选中的时候，为什么没有特别的显示
 *   例如被勾选，或者方框发亮之类的？」
 *
 * 【原状为什么不够】
 *   旧实现只有 `border-color: var(--pri)` + `0 0 0 2px rgba(...,.28)`。
 *   两个问题叠加 ⇒ 实际看不见：
 *     ① 边框本身只有 1px，颜色从 --line 变 --pri，
 *        而画面区是深色底（--screen），1px 的色差在缩略图尺度下几乎不可辨；
 *     ② 外光晕透明度只有 .28 —— 在深色画面上基本等于没有。
 *   实测：19 宫格缩到 150px 宽时，选中与未选中的边框差 ≈ 2 个色阶。
 *
 * 【改法：双通道 —— 「看得见的勾」+「看得见的亮」】
 *   ① 勾选圈 `.tile-check`（新元素，见下）
 *      —— 形状语义（实心圈=已选）比纯色边框强得多，
 *         缩到 120px 格子也一眼能数出「选了几台」。
 *   ② 边框加粗成 2px 实心环 + 外层光晕
 *      —— 「方框发亮」的直观诉求。
 *
 * ★ 落位：**左下角**（left:6px; bottom:27px）
 *   角标位已被占满，见 .tile-owner 注释里的那张表：
 *     左上 = .tile-label（昵称）
 *     左上第二行 = .tile-busy（占用徽标，top:24px）
 *     右上 = .tile-badge（MSE 降级）
 *     右上第二行 = .tile-refresh（单格刷新，top:28px）
 *     右下 = .tile-owner（归属角标）
 *     底部通栏 = .tile-meta
 *   ⇒ 与 .tile-owner **同高度、左右对称**（右 6px / 左 6px），
 *     视觉上是一对，不打架；且离左上两行最远。
 */
.tile.selected {
  border-color: var(--pri-deep);
  /* ★ z-index:2 —— 光晕必须能盖住相邻格子。
     .tile 是 position:relative + z-index:auto ⇒ 同层级按文档顺序绘制，
     后面的格子会盖住前面选中格子的外光晕，
     表现为「同一个操作，选中第 3 台有光晕、选第 17 台没有」。
     2 足够：内部角标（12/13）不受影响（它们在 tile 自身的
     stacking context 内比较），marquee（20）仍在最上层。 */
  z-index: 2;
  /* 2px 实心环 + 外层光晕 —— 「方框发亮」的直观诉求。
     0 0 0 2px 是**实心**环（不是模糊光晕），
     这样在深色画面上是一条能数出宽度的硬边。 */
  box-shadow:
    0 0 0 2px var(--pri),
    0 0 16px rgba(125, 144, 112, .55),
    var(--shadow-md);
}

/* 勾选圈：空心 = 可勾选（仅框选模式显示）；实心+对勾 = 已选 */
.tile-check {
  position: absolute;
  left: 6px; bottom: 27px;
  width: 20px; height: 20px;
  border-radius: 50%;
  border: 1.5px solid rgba(240, 236, 228, .45);
  background: rgba(40, 37, 32, .55);
  backdrop-filter: blur(6px);
  display: none;
  place-items: center;
  z-index: 12;
  /* 点它等于点格子 ⇒ 继承 tile 的点击行为，
     但不能挡住 hover 提示。 */
  pointer-events: none;
}
/* 框选模式：所有格子露出空心圈 = 「这里可以勾」的提示 */
.grid.select-mode .tile .tile-check { display: grid; }
/* 已选：实心绿底 + 对勾 */
.tile.selected .tile-check {
  display: grid;
  background: var(--pri);
  border-color: var(--pri);
}
/* 对勾用**边框画**，不用 ✓ 字符 ——
   字体缺字形时会变成豆腐块，而这是最不能出错的指示元素。
   （.tile-rename 之类才用文本符号。） */
.tile-check::after {
  content: '';
  width: 4px; height: 8px;
  border: solid var(--screen);
  border-width: 0 2px 2px 0;
  transform: rotate(45deg) translate(-1px, -1px);
  opacity: 0;
  transition: opacity .12s ease;
}
.tile.selected .tile-check::after { opacity: 1; }

.tile[data-state="failed"], .tile[data-state="stalled"] {
  border-color: var(--err);
}
.tile[data-state="idle"] { opacity: .62; }
/* 有错误的格子加角标，一眼能看出哪台出问题 */
.tile[data-err] .tile-spinner {
  color: var(--err);
  font-size: 11px;
  padding: 8px;
  text-align: center;
  line-height: 1.4;
}

.tile-label {
  position: absolute;
  left: 0; top: 0;
  padding: 3px 9px;
  /* ★ 画面上的标签：底色必须深（要压在任意内容上都能看清），
     但用暖调深灰 + 圆角 + 轻微内缩，与整体的柔和感一致。 */
  background: rgba(40, 37, 32, .72);
  backdrop-filter: blur(6px);
  color: #f0ece4;
  font-size: 12px;
  border-bottom-right-radius: var(--r-sm);
  /* ★★ 标签现在装的是**昵称**（最长 24 字符），必须单行省略：
     换行会把标签撑高，而左上角只有这么点地方 ——
     标签一换行就会被 `.tile-busy`（占用徽标）压住。
     line-height 1.2 让标签压到 ~20px 高，给下面那行徽标腾位置。 */
  line-height: 1.2;
  max-width: calc(100% - 12px);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
/* 可改名的标签（归属人 / 管理员）给个可点的提示 */
.tile-label.editable { cursor: text; }
.tile-label.editable:hover { background: rgba(40, 37, 32, .88); }

/* 就地改名的输入框。落在画面区 ⇒ 沿用画面区的深色调 */
.tile-rename {
  width: 96px;
  border: 1px solid var(--pri-line);
  border-radius: 4px;
  background: var(--screen);
  color: var(--pri-soft);
  font-size: 12px;
  line-height: 1.2;
  padding: 1px 4px;
  outline: none;
}
.tile-meta {
  position: absolute;
  left: 0; right: 0; bottom: 0;
  padding: 3px 8px;
  background: rgba(40, 37, 32, .72);
  backdrop-filter: blur(6px);
  font-size: 11px;
  color: #cdc7bb;
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
}
.tile-spinner {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  color: #b8b1a4;
  font-size: 12px;
  background: var(--screen);
}
.tile[data-stream="live"] .tile-spinner { display: none; }

/* ---------- 放大窗加载遮罩（2026-10-24） ----------
 * 场景：①开窗起播（等首帧）②切原生档 360x640→1080x1920
 *   （服务端 scrcpy stop+start，约 1.2s 无帧）。
 * 为什么值得一个动画而不是干等：那 1.2s 里画面停在旧帧/黑屏，
 *   用户看到的是「卡死了」；一个转圈 + 文案把「系统在干活」
 *   这件事讲清楚，主观等待时间能砍半。
 * ★ 颜色全部复用既有色板（--screen / --screen-line /
 *   #b8b1a4 文字 = tile-spinner 同色 / #b7d1a8 高亮 = tile-badge 同色），
 *   不引入新颜色 ⇒ 莫兰迪对比度检查天然通过。
 * ★ 画面区保持深色（UI 铁律），不参与莫兰迪化。 */
.focus-loading {
  position: absolute;
  inset: 0;
  z-index: 5;
  display: grid;
  place-items: center;
  /* 与 --screen 同色的半透明：盖住旧帧但不死黑，过渡不生硬 */
  background: rgba(28, 26, 23, .78);
  backdrop-filter: blur(2px);
}
.focus-loading-box {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 10px;
}
.focus-loading-spin {
  width: 34px;
  height: 34px;
  border-radius: 50%;
  border: 3px solid var(--screen-line);
  border-top-color: #b7d1a8;
  animation: focus-spin .8s linear infinite;
}
@keyframes focus-spin { to { transform: rotate(360deg); } }
.focus-loading-text {
  color: #b8b1a4;
  font-size: 12px;
  letter-spacing: .5px;
}
/* 尊重系统的「减少动态效果」偏好（前庭障碍用户） */
@media (prefers-reduced-motion: reduce) {
  .focus-loading-spin { animation-duration: 2.4s; }
}

/* ---------- 排队等操作权（2026-10-24） ----------
 * 两件东西：①放大窗头部的排队按钮 ②「轮到你了」浮层。
 * ★ 颜色全部复用既有色板：排队中 = --warn/--warn-soft（暖赭，
 *   「等着」的语义色），浮层用 --pri-soft 底 + --pri-deep 边
 *   （品牌色，正面事件）。零新颜色 ⇒ 对比度检查天然通过。
 * ★ 不用 --info 系：浮层是「好事发生」，蓝灰偏冷会显得像系统告警。 */

/* 排队中态：与「等待」语义一致的暖赭，和按钮.rec 同色系 */
button#focusQueue.q-on {
  border-color: #ddc9a6;
  color: var(--warn);
  background: var(--warn-soft);
}

/* 「轮到你了」浮层：固定右上角，不抢焦点（见 index.html 注释） */
.q-toast {
  position: fixed;
  top: 14px;
  right: 14px;
  z-index: 100;                 /* 盖过放大窗（focus 的层叠上下文内最高 5） */
  width: 240px;
  padding: 12px 14px;
  background: var(--panel);
  border: 1px solid var(--pri-line);
  border-radius: var(--r-md);
  box-shadow: var(--shadow-lg);
}
.q-toast-title {
  font-size: 13px;
  font-weight: 600;
  color: var(--pri-deep);
  margin-bottom: 4px;
}
.q-toast-body {
  font-size: 12px;
  color: var(--fg);
  line-height: 1.5;
  margin-bottom: 10px;
}
.q-toast-actions {
  display: flex;
  gap: 8px;
}
.q-toast-actions button { flex: 1; }

/* ---------- 设备借出（2026-10-24，多人协作第一批 L4） ----------
 * 三件东西：①放大窗头部的借出按钮 ②借出面版（选人+选时长）
 * ③借出通知浮层（复用 .q-toast 结构，不另起炉灶）。
 * ★ 颜色全部复用既有色板：借入中 = --warn/--warn-soft（与排队
 *   的 .q-on 同色系，「进行中/等着」语义）；收回 = --err/--err-soft
 *   （与 button.danger 同色系，「撤销」语义）。零新颜色。 */

/* 借出按钮三态：默认（借出）/ l-on（我是借入者：归还）/
   l-danger（我借出的：收回）。与 focusQueue 的 q-on 同一手法。 */
button#focusLend.l-on {
  border-color: #ddc9a6;
  color: var(--warn);
  background: var(--warn-soft);
}
button#focusLend.l-danger {
  border-color: #d9b5ae;
  color: var(--err);
  background: var(--err-soft);
}

/* 借出面版：居中浮层（不抢焦点、不 alert），
   与 .q-toast 同一层叠与投影体系。 */
.lend-pop {
  position: fixed;
  top: 12vh;
  left: 50%;
  transform: translateX(-50%);
  z-index: 100;
  width: 288px;
  background: var(--panel);
  border: 1px solid var(--pri-line);
  border-radius: var(--r-md);
  box-shadow: var(--shadow-lg);
}
.lend-head {
  display: flex;
  align-items: center;
  padding: 10px 12px;
  font-size: 13px;
  color: var(--fg-strong);
  border-bottom: 1px solid var(--line-soft);
}
.lend-head .mini {
  border: none;
  background: transparent;
  color: var(--dim);
  font-size: 12px;
  padding: 2px 6px;
  cursor: pointer;
}
.lend-head .mini:hover { color: var(--fg); background: var(--panel2); }

/* 时长预设（分段控件）：选中 = 品牌色，未选 = 次级面 */
.lend-dur {
  display: flex;
  gap: 6px;
  padding: 10px 12px 0;
}
.lend-dur button {
  flex: 1;
  font-size: 11px;
  padding: 5px 0;
  border: 1px solid var(--line);
  background: var(--panel2);
  color: var(--dim);
  border-radius: var(--r-sm);
  cursor: pointer;
}
.lend-dur button.on {
  background: var(--pri-soft);
  border-color: var(--pri-line);
  color: var(--pri-deep);
}

/* 候选列表：一行一个账号，选中高亮 */
.lend-list {
  display: flex;
  flex-direction: column;
  gap: 4px;
  max-height: 216px;
  overflow-y: auto;
  padding: 10px 12px;
  font-size: 12px;
  color: var(--dim);
}
.lend-row {
  text-align: left;
  font-size: 12px;
  padding: 7px 9px;
  border: 1px solid var(--line);
  background: var(--panel);
  color: var(--fg);
  border-radius: var(--r-sm);
  cursor: pointer;
}
.lend-row:hover { background: var(--bg-soft); }
.lend-row.on {
  background: var(--pri-soft);
  border-color: var(--pri-line);
  color: var(--pri-deep);
}

.lend-foot {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 10px 12px;
  font-size: 11px;
  border-top: 1px solid var(--line-soft);
}
.lend-foot #lendConfirm { flex: 0 0 auto; }

.tile-badge {
  position: absolute;
  right: 6px; top: 6px;
  padding: 2px 7px;
  font-size: 10px;
  border-radius: 7px;
  background: rgba(40, 37, 32, .72);
  backdrop-filter: blur(6px);
  color: #b7d1a8;
}
.tile-badge.warn { color: #e6c78d; }
.tile-badge:empty { display: none; }

/* ---------- 单格刷新按钮（2026-10-23） ----------
 * 落位：右上第二行（top:28px）—— 右上第一行是 .tile-badge（MSE 降级
 * 才出现），左上两行是昵称/占用徽标，右下是归属角标，只有这里空着。
 * 悬停格子时淡入；不常驻，避免 19 个常驻小按钮压在画面上。
 * 样式与画面区角标同族：暖调深灰底 + 浅字（画面区保持深色，铁律）。
 */
.tile-refresh {
  position: absolute;
  right: 6px;
  top: 28px;
  z-index: 13;
  width: 26px;
  height: 26px;
  padding: 0;
  display: grid;
  place-items: center;
  border: 1px solid var(--line);
  border-radius: var(--r-sm);
  background: rgba(40, 37, 32, .82);
  backdrop-filter: blur(6px);
  color: #f0ece4;
  font-size: 14px;
  line-height: 1;
  cursor: pointer;
  opacity: 0;
  transition: opacity .12s ease, background .12s ease;
}
.tile:hover .tile-refresh { opacity: 1; }
.tile-refresh:hover {
  background: rgba(74, 68, 58, .92);
  border-color: var(--pri-line);
  color: var(--pri-soft);
}

/* ---------- 占用徽标（操作权租约） ----------
 *
 * ★ 与 `.tile-badge` 分开：badge 表达「设备状态」（running/stopped），
 *   busy 表达「操作权归属」。混在一起会看不清谁是谁。
 *
 * ★ 莫兰迪基线：调色只降亮度、**不动色相**
 *   （色相是状态间唯一区分维度 —— 改了色相就分不出状态了）。
 *   占用用暖色（--warn 家族），设备状态用绿/灰。
 *
 * ★ 边框用虚线而不是填充：画面区（.tile）刻意保持深色，
 *   实心色块会盖住画面内容。
 */
.tile-busy {
  position: absolute;
  /* ★★★ 从 `top: 6px` 挪到 `top: 24px`（2026-10-22）。
   *   原来它和 `.tile-label` **完全重叠**：
   *     label ≈ x∈[0,42] y∈[0,25]（12px 字 × 1.6 行高 + 3px×2 内边距）
   *     busy  ≈ x∈[6,65] y∈[6,22]
   *   ⇒ 徽标把标签压掉一大半。
   *   标签改成**昵称**（「1号」）之后更致命 —— 短昵称会被**完全盖住**，
   *   现象是「昵称不显示」，看起来像后端没生效。
   *   ⇒ 标签占左上第一行，徽标挪到它下面一行（top:24px），互不遮挡。
   */
  left: 6px; top: 24px;
  padding: 2px 7px;
  font-size: 10px;
  border-radius: 7px;
  background: rgba(40, 37, 32, .72);
  backdrop-filter: blur(6px);
  color: #e0c48f;
  border: 1px dashed rgba(224, 196, 143, .5);
  pointer-events: none;
  z-index: 12;
  max-width: calc(100% - 12px);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.tile-busy[hidden] { display: none; }

/* ---------- 归属角标（方案 B：只显示自己的） ----------
 *
 * ★ 落位：**右下角、meta 条之上**。
 *   左上 = 标签（昵称），右上 = `.tile-badge`（MSE 降级标识），
 *   底部通栏 = `.tile-meta`，左上第二行 = `.tile-busy`（占用徽标）
 *   ⇒ 归属角标只能放右下。
 *   24px = meta 条高度（11px 字 × 1.6 + 3px×2 ≈ 24px）+ 3px 间隙。
 *
 * ★ 用**品牌绿**而不是语义色：它表达的是「身份」不是「状态」，
 *   而语义色（ok / warn / err）已经被「运行中 / 被占用 / 故障」占满了。
 *   莫兰迪铁律：只降亮度、不动色相。
 * ★ 别人的设备**不显示任何归属信息**（方案 B 的安全边界）。
 */
.tile-owner {
  position: absolute;
  right: 6px;
  bottom: 27px;
  padding: 2px 7px;
  font-size: 10px;
  border-radius: 7px;
  background: rgba(40, 37, 32, .72);
  backdrop-filter: blur(6px);
  color: var(--pri-soft);
  border: 1px solid rgba(227, 233, 220, .3);
  pointer-events: none;
  z-index: 12;
}

/* 设备被他人占用时，给一圈虚线边框（不改背景，画面区保持深色） */
.tile.is-busy::after {
  content: '';
  position: absolute;
  inset: 0;
  border: 1px dashed rgba(224, 196, 143, .45);
  border-radius: inherit;
  pointer-events: none;
  z-index: 11;
}

.marquee {
  position: absolute;
  border: 1px solid var(--pri);
  background: rgba(125, 144, 112, .18);
  pointer-events: none;
  z-index: 20;
}
.grid.select-mode .tile { cursor: crosshair; }

/* ---------- 右栏 ---------- */

.panel {
  border-left: 1px solid var(--line-soft);
  background: var(--bg-soft);
  display: flex;
  flex-direction: column;
  /* ★ min-width: 0 与 min-height: 0 缺一不可。
     折叠态 main 的列定义是 `1fr 0`，而 grid 项的 min-width
     默认是 auto —— 意思是「不许小于内容的最小尺寸」。
     右栏内容（8 个标签 + 表单）固有宽约 465px，
     于是 0 宽的轨道被内容顶开，整页横向溢出 85px、
     同时被顶高 788px（1600x900 实测）。

     这与「宫格要能上下滚动」是同一个家族的问题：
     flex/grid 项要能收缩到小于内容，必须显式写 0。
     .stage / .grid 当初补了 min-height:0，.panel 漏了 min-width:0，
     而且不折叠时 380px 已经够宽，这一项压根不显形 ——
     只有折叠把轨道压到 0，它才暴露出来。 */
  min-width: 0;
  min-height: 0;
}

.tabs {
  /* ★★★ 改成 grid 两行五列（2026-10-21 用户反馈
   *   「右侧的菜单横向滚动还是没有显示完整，是否能展示成两行」）。
   *
   * 【为什么不是横向滚动】
   *   8 个标签固有宽约 465px，右栏只有 380px —— 横向滚动必然溢出。
   *   上一轮加的渐隐遮罩 + 底部进度指示条虽然让它「可发现」，
   *   但用户要的根本不是「能滑」，而是「一眼看全」。
   *   滚动式标签栏在群控场景下是反模式：用户要在 8 个页签间频繁切换，
   *   每次都要先滑一下才能确认自己在哪。
   *
   * 【为什么是 5 列而不是 4 列】
   *   5 列时第 1 行 = 控制/脚本/分组/告警/日志（5 个内容型），
   *   第 2 行 = 系统/高级/分辨率（3 个工具型）——
   *   **正好落在原有的语义分组边界上**，分组线画在「系统」左侧。
   *   4 列会把「日志」和「系统」挤到同一行，语义分组就废了。
   *
   * 【实测数据】_lab_tabs.py，判据=无横向溢出 + 恰好 2 行 + 无裁切：
   *   flex-wrap 自然换行     → 2 行但 6+2 分布，右栏 300px 时变 5+3（不稳定）
   *   flex-wrap 25% 基宽     → 3 行 ❌
   *   **grid 5 列            → 2 行 5+3，380px/300px 均无裁切 ✅**
   */
  display: grid;
  grid-template-columns: repeat(5, minmax(0, 1fr));
  border-bottom: 1px solid var(--line-soft);
  gap: 0;
  padding: 0 8px;
  flex: none;
  position: relative;
}

/* ★★★ 旧的「横向滚动 + 渐隐遮罩 + 底部进度指示条」整块删除
 *   （2026-10-21 用户要求标签栏「展示成两行」）。
 *
 *   那套机制是为「标签必须单行放不下、只能横滑」设计的，
 *   改成两行 grid 之后它有三个问题：
 *     1. 渐隐遮罩（mask-image）会**永久淡化第 2 行最右边的「分辨率」**，
 *        而现在根本没有溢出 —— 这是把「有内容」当「没内容」来提示；
 *     2. 底部指示条的宽度是 `calc(100% * 100% / 467px)`，
 *        那个 467px 是「8 个标签单行总宽」，
 *        两行布局下这个分母毫无意义，指示条会画一根莫名其妙的短横线；
 *     3. 指示条压在第 2 行标签下面，视觉上像第 2 行被划了一道。
 *
 *   JS 侧对应的 ensureTabVisible() / syncTabIndicator() 同步删除
 *   （否则就成了调用不存在函数的 ReferenceError）。
 */

/* ★ 组间分隔线：把「内容型」（控制/脚本/分组/告警/日志）与
   「工具型」（系统/高级/分辨率）在视觉上分开。
   ★ 两行 grid 布局下，原来的独立 <span class="tab-sep">
     会成为第 6 个 grid item，把「系统」挤到第 2 行第 2 格，
     5 列就变成 5+3 之外还多一个空位。
     所以分隔线改画在「系统」tab 的左边框上 ——
     语义正好落在两组边界处（第 2 行开头）。 */
.tab-sep { display: none; }
.tab[data-t="sys"] { border-left: 1px solid var(--line-soft); }

.tab {
  /* ★ grid 两行布局下，flex 属性不再适用（grid item 由列宽决定）。
     保留 min-width:0 让长标签能收缩而不是撑破列宽。 */
  min-width: 0;
  white-space: nowrap;
  border: none;
  border-radius: 0;
  background: transparent;
  color: var(--dim);
  padding: 11px 4px;
  position: relative;
  text-align: center;
}
/* ★ 选中态用「文字加深 + 底部短横线」，
   而不是整块填色 —— 莫兰迪风格里大面积色块很刺眼。 */
.tab:hover { color: var(--fg); background: transparent; }
.tab.on {
  background: transparent;
  color: var(--pri-deep);
  font-weight: 600;
  box-shadow: inset 0 -2px 0 var(--pri);
}

.tabpane { display: none; padding: 20px 18px; overflow: auto; flex: 1;
           min-width: 0; }
.tabpane.on { display: block; }

.tabpane h3 {
  font-size: 12px;
  text-transform: uppercase;
  letter-spacing: .6px;
  color: var(--dim);
  margin: 22px 0 10px;
  /* 小标题用细横线做分区，比用边框框更柔和 */
  padding-bottom: 6px;
  border-bottom: 1px solid var(--line-soft);
}
.tabpane h3:first-child { margin-top: 0; }

.row { display: flex; gap: 8px; align-items: center; margin: 8px 0;
      flex-wrap: wrap; min-width: 0; }
.row label { font-size: 12px; color: var(--dim); white-space: nowrap; }
.row input[type="number"] { width: 78px; }
.row4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin: 8px 0; }

.keys { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }

.list { display: flex; flex-direction: column; gap: 7px; margin: 8px 0; }

/* ★ 操作目标提示条（系统/高级/分辨率三个页签共用）。
   这三个页签的操作对象与「控制」页签同源（targets()），
   换页签后如果看不到当前会影响几台，用户会去猜。 */
.targetbar {
  flex: none;
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 7px 18px;
  font-size: 12px;
  color: var(--dim);
  background: var(--panel2);
  border-bottom: 1px solid var(--line-soft);
}
.targetbar b { color: var(--pri-deep); font-size: 13px; }
.targetbar .ellipsis { flex: 1; min-width: 0; }

/* 三个扩展页签内部：留白略紧，内容密度比「控制」页高得多。 */
.extpane { padding-top: 16px; }
.extpane textarea {
  width: 100%;
  resize: vertical;
  font-family: inherit;
  font-size: 13px;
  line-height: 1.5;
  padding: 8px 10px;
  border-radius: var(--r-sm);
  border: 1px solid var(--line);
  background: var(--panel);
  color: var(--fg);
}
.extpane select { flex: 1; min-width: 0; }

/* 两列按钮组：比 .row 整齐，且在窄面板里换行行为可控 */
.gridbtns {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(92px, 1fr));
  gap: 7px;
  margin: 8px 0;
}
.gridbtns button { margin: 0; }

/* 上传按钮：套一个 label 当按钮，file input 视觉隐藏。
   ★ 为什么不是 hidden / display:none：
     真实使用里点 label 能弹出文件框，靠的是 label→input 的
     **隐式关联**，input 本身不可见也能工作 ——
     所以这两种写法「看起来没问题」，但：
       1. Playwright 的 set_input_files 对不可见元素会静默跳过；
       2. 部分辅助技术会跳过 display:none 的表单控件。
     改成「保留布局盒 + 透明 + 极小」，
     既能被自动化设值、也能被屏幕阅读器感知。
   ★ 必须同时在 label 上做 focus 样式，
     否则键盘用户看不出能不能 Tab 过去。 */
.filebtn input {
  position: absolute;
  /* ★ 绝不能省的一行。
     这三个 input 是 position:absolute，而 .filebtn /
     .uploadrow / .tabpane / .panel / main / #app / body
     原本全是 static —— 没有任何定位祖先，
     于是它们按规范回退到「初始包含块」（= 视口）定位，
     直接跑到 left=1618（视口才1600）逃出所有裁剪，
     三个 input 依次堆到 bottom=1900，
     documentElement 被撑到 sw=1640 / sh=1837。

     .panel 的 overflow:hidden 裁不住它们 ——
     裁剪只作用于后代流内盒，而 absolute 元素一旦
     参照初始包含块定位，就不再受任何祖先裁剪约束。

     .filebtn 给 position:relative 之后，input 有了真正的
     定位祖先，从此被 label → tabpane → panel 逐层正确裁剪。 */
  inset: 0;
  width: 100%;
  height: 100%;
  max-width: 100%;
  /* file input 的 UA 默认固有宽度约 173px，
     不压住它即使定位正确也会撑破 label */
  opacity: 0;
  cursor: pointer;
  pointer-events: none;
}
.uploadrow { display: flex; gap: 7px; flex-wrap: wrap; margin: 8px 0;
             min-width: 0; }
.filebtn {
  flex: 1 1 96px;
  /* ★ min-width: 0 不可省。flex 项的 min-width 默认 auto，
     意思是「不许窄于内容最小尺寸」—— 而 flex-basis 96px
     + 「上传 / 安装应用」最窄不换行 + padding 20px，
     固有最小宽约 116px。右栏折叠后轨道压到 0，
     label 却顶在 116px 宽，位置跑到视口外
     （实测 left=1618，视口只有 1600），整页横向溢出 85px。

     white-space: nowrap 同样要去掉：不管轨道多窄，
     文字永远保持单行，宽度压不下去。 */
  min-width: 0;
  /* ★ input 的定位上下文，见上方 .filebtn input 的说明 */
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 8px 10px;
  font-size: 13px;
  border-radius: var(--r-sm);
  border: 1px dashed var(--line);
  background: var(--panel);
  color: var(--fg);
  cursor: pointer;
  white-space: normal;
  text-align: center;
  line-height: 1.35;
  overflow-wrap: anywhere;
  transition: background .12s, border-color .12s;
}
.filebtn:hover { background: var(--panel2); border-color: var(--pri); }
.filebtn:focus-within {
  outline: 2px solid var(--pri);
  outline-offset: 1px;
  border-color: var(--pri);
}
.filebtn input:focus-visible { outline: none; }

/* ---------- 应用安装包库（2026-10-24 第十九轮） ----------
 *
 * 用户需求：「默认 app 安装包的应用选择，就是有很多款 app 应用
 * 安装包可供自主选择安装」⇒ 服务器端仓库的列表。
 *
 * ★ 排版约束：右栏只有 ~380px 宽 ⇒ 一行只能放「名字 + 版本」，
 *   包名（小字）和按钮必须换行。
 * ★ 只用既有 CSS 变量（铁律⑧）：--panel / --panel2 / --line /
 *   --line-soft / --fg / --fg-strong / --dim / --dim-faint / --pri。
 */
.apklist {
  margin: 8px 0 4px;
  display: flex;
  flex-direction: column;
  gap: 6px;
  min-width: 0;
}
.apk-item {
  border: 1px solid var(--line);
  border-radius: var(--r-sm);
  background: var(--panel);
  padding: 8px 10px;
  min-width: 0;
}
.apk-top {
  display: flex;
  align-items: baseline;
  gap: 8px;
  min-width: 0;
}
.apk-name {
  font-size: 13px;
  color: var(--fg-strong);
  font-weight: 600;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  min-width: 0;
  flex: 1 1 auto;
}
.apk-ver {
  font-size: 11px;
  color: var(--dim);
  white-space: nowrap;
  flex: 0 0 auto;
}
.apk-pkg {
  font-size: 11px;
  color: var(--dim-faint);
  font-family: var(--mono);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  margin-top: 2px;
}
.apk-meta {
  font-size: 11px;
  color: var(--dim-faint);
  margin-top: 2px;
}
.apk-acts {
  display: flex;
  gap: 6px;
  margin-top: 7px;
  flex-wrap: wrap;
}
.apk-acts button {
  font-size: 12px;
  padding: 4px 10px;
  margin: 0;
}
.apk-empty {
  font-size: 12px;
  color: var(--dim-faint);
  padding: 10px;
  border: 1px dashed var(--line);
  border-radius: var(--r-sm);
  text-align: center;
}
/* 解析不出包名的条目（理论上不该入库）标黄提示 */
.apk-warn { color: var(--warn); }

/* 危险操作提示：复用 --warn 色但走背景淡染，
   不加实心色块 —— 面板里已经有多个 danger 按钮了。 */
/* 音量真实值回显（2026-10-21 用户反馈「音量加减都是提高音量」）。
 *
 * ★ 这条不是装饰，是「设备到底听不听 adb 命令」的唯一真相来源。
 *   之前界面只有一句「音量 +1 → 1 台，全部成功」，
 *   而该机型实测 `input keyevent 24/25` 完全无效 ——
 *   音量设 12 后连按 3 次仍是 12，界面却一直显示「成功」。
 *   用户没有对照数字就永远发现不了功能是坏的。
 *   读回真实音量并摆在按钮下方，才把这件事变成可验证的。
 *
 *   左侧用 --pri 色条：与音量按钮（primary 按钮）同色，
 *   表示「这是上面那组按钮的反馈」。
 */
.vol-now {
  padding: 5px 9px;
  margin: -2px 0 8px;
  border-left: 2px solid var(--pri);
  background: var(--panel);
  font-family: var(--mono);
  font-size: 12px;
  color: var(--fg-strong);
  border-radius: 0 var(--r-sm) var(--r-sm) 0;
  /* 读不到时（未选设备 / 读失败）不要用醒目底色误导，
     JS 会改文字内容，这里只给正常的静态样式 */
  overflow-wrap: anywhere;
}

/* 危险操作提示：复用 --warn 色但走背景淡染， */
.warn-note {
  border-left: 2px solid var(--warn);
  padding-left: 9px;
  color: var(--warn);
}

/* 话术条目：左侧标题 + 右侧内容摘录，横向排布省高度 */
.phrase {
  display: flex;
  flex-direction: column;
  gap: 4px;
  padding: 8px 10px;
  border: 1px solid var(--line-soft);
  border-radius: var(--r-sm);
  background: var(--panel);
}
.phrase-head { display: flex; align-items: center; gap: 7px; }
.phrase-title { font-weight: 600; font-size: 13px; }
.phrase-cat {
  font-size: 11px;
  color: var(--dim);
  background: var(--panel2);
  padding: 1px 7px;
  border-radius: 999px;
}
.phrase-used { font-size: 11px; color: var(--dim); margin-left: auto; }
.phrase-body {
  font-size: 12px;
  color: var(--dim);
  line-height: 1.45;
  overflow: hidden;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
}
.phrase-acts { display: flex; gap: 6px; }
.phrase-acts button { flex: 1; padding: 5px 8px; font-size: 12px; }

/* 输出区：单设备查询结果可能十几行，等宽字体 + 保留换行 */
.outbox {
  max-height: 190px;
  overflow: auto;
  font-family: var(--mono, ui-monospace, Consolas, monospace);
  font-size: 11.5px;
  line-height: 1.5;
  white-space: pre-wrap;
  word-break: break-all;
  padding: 8px 10px;
  border-radius: var(--r-sm);
  border: 1px solid var(--line-soft);
  background: var(--panel);
  color: var(--fg);
}
.outbox.err { border-color: var(--err); color: var(--err); }
.outbox:empty { display: none; }

.group, .script, .run {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 9px 11px;
  background: var(--panel);
  border: 1px solid var(--line-soft);
  border-radius: var(--r-sm);
  font-size: 13px;
  box-shadow: var(--shadow-sm);
}
.group .gname, .script .sname, .run .rname {
  flex: 1;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.gcount, .scount, .smode { color: var(--dim); font-size: 12px; }
.gacts, .sacts { display: flex; gap: 5px; }
.gacts button, .sacts button, .run button { padding: 4px 9px; font-size: 11px; }
.dot { width: 9px; height: 9px; border-radius: 50%; flex: none; }

.rbar {
  flex: 1;
  height: 6px;
  background: var(--panel2);
  border-radius: 3px;
  overflow: hidden;
}
.rbar i { display: block; height: 100%; background: var(--pri); }
.run.done .rbar i { background: var(--ok); }
.run.failed .rbar i, .run.stopped .rbar i { background: var(--err); }
.rstat { font-size: 11px; color: var(--dim); white-space: nowrap; }

.steps {
  max-height: 190px;
  overflow: auto;
  background: var(--panel);
  border: 1px solid var(--line-soft);
  border-radius: var(--r-sm);
  padding: 10px;
  font: 12px/1.7 ui-monospace, Consolas, monospace;
}
.step { color: var(--dim); }

.alert {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 9px 11px;
  border-radius: var(--r-sm);
  border-left: 3px solid var(--dim);
  background: var(--panel);
  font-size: 12px;
  cursor: pointer;
  box-shadow: var(--shadow-sm);
}
.alert.critical { border-left-color: var(--err); }
.alert.warn { border-left-color: var(--warn); }
.alert.info { border-left-color: var(--info); }
.alert.acked { opacity: .5; }
.alert .sev { font-weight: 600; }
.alert.critical .sev { color: var(--err); }
.alert.warn .sev { color: var(--warn); }
.alert.info .sev { color: var(--info); }
.alert .serial { color: var(--fg-strong); }
.alert .msg { flex: 1; color: var(--dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.alert .dur { color: var(--dim-faint); font-size: 11px; }

/* ---------- 告警去噪（2026-10-20） ----------
 *
 * 实测：19 台设备全部连续运行 > 1 小时时，
 * long_uptime 规则会给每台各产出一条 info 告警，
 * 内容几乎一模一样（「已连续运行 6574 秒」），
 * 于是告警面板被 17 条同质信息占满——
 * 真正需要看的 critical/warn 被淹没在下面。
 *
 * 前端解法：按 (code, severity) 聚合成一行，
 * 显示「×17」表示涉及多少台，展开才看逐台明细。
 * 这样「一堆提示」收敛成一行，信号立刻浮出来。
 */
.alert-group {
  border: 1px solid var(--line-soft);
  border-left: 3px solid var(--dim);
  border-radius: var(--r-sm);
  background: var(--panel);
  overflow: hidden;
  box-shadow: var(--shadow-sm);
}
.alert-group.critical { border-left-color: var(--err); }
.alert-group.warn { border-left-color: var(--warn); }
.alert-group.info { border-left-color: var(--info); }
.alert-group-head {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 9px 11px;
  font-size: 12px;
  cursor: pointer;
  user-select: none;
}
.alert-group-head:hover { background: var(--panel2); }
.alert-group .gcount {
  color: var(--fg-strong);
  font-weight: 600;
  font-size: 11px;
  background: var(--pri-soft);
  padding: 2px 7px;
  border-radius: 8px;
  flex: none;
}
.alert-group .gtitle { flex: 1; color: var(--dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.alert-group .gcaret { color: var(--dim-faint); font-size: 10px; flex: none; }
.alert-group-body { display: none; padding: 0 11px 8px; }
.alert-group.open .alert-group-body { display: block; }
.alert-group.open .gcaret { transform: rotate(90deg); }
/* 组内每台的明细行 */
.alert-detail {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 5px 0;
  font-size: 12px;
  border-top: 1px solid var(--line-soft);
}
.alert-detail .serial { color: var(--fg-strong); flex: none; }
.alert-detail .msg { flex: 1; color: var(--dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.alert-detail .dur { color: var(--dim-faint); font-size: 11px; flex: none; }
.alert-detail.acked { opacity: .5; }

/* 告警筛选条 */
.alert-filters {
  display: flex;
  align-items: center;
  gap: 7px;
  flex-wrap: wrap;
  margin: 8px 0 12px;
}
.alert-filters .fbtn {
  padding: 4px 11px;
  font-size: 11px;
  border-radius: 12px;
}
.alert-filters .fbtn.on {
  background: var(--pri-soft);
  border-color: var(--pri-line);
  color: var(--pri-deep);
  font-weight: 600;
}

/* ---------- 宫格尺寸滑块 ---------- */
.cell-size {
  display: flex;
  align-items: center;
  gap: 7px;
  font-size: 12px;
  color: var(--dim);
}
.cell-size input[type="range"] {
  padding: 0;
  width: 116px;
  accent-color: var(--pri);
}
.cell-size b {
  color: var(--fg);
  font-variant-numeric: tabular-nums;
  min-width: 40px;
  display: inline-block;
}
/* ★ 「自适应」小按钮（2026-10-21）。
   工具栏按钮都是全尺寸的（padding 8px 14px），
   塞进滑块这一行会显得头重脚轻，所以这里做一个紧凑变体。 */
.cell-size .mini {
  padding: 3px 9px;
  font-size: 12px;
  border-radius: var(--r-sm);
  white-space: nowrap;
  line-height: 1.5;
}
.cell-size .tiny {
  min-width: 34px;
  display: inline-block;
  font-variant-numeric: tabular-nums;
}

/* ---------- 单台放大 ---------- */

.focus {
  position: fixed;
  /* ★★★ 2026-10-21：从全屏遮罩改为**只盖左半区**。
   *
   *   原来 inset:0 → 整个视口都是遮罩层。
   *   实测：放大窗打开后，右栏「广播点击」按钮的
   *   document.elementFromPoint() 命中的是 .focus 本身，
   *   也就是说**右栏所有按钮都点不到** ——
   *   用户要求「放大后右侧功能也要能正常使用」被完全阻断。
   *
   *   现在右边界退到右栏左边（--panel-w），
   *   右栏保持可交互；面板折叠时（--panel-w=0）自动铺满。
   *
   *   ★ 必须用 var(--panel-w) 而不是写死数值：
   *     折叠态由 body.panel-collapsed main 改 grid 列宽，
   *     这里跟着变才能保持一致。
   */
  top: 0;
  bottom: 0;
  left: 0;
  right: var(--panel-w, 380px);
  /* ★ 遮罩改成暖调深色 + 轻微模糊：
     纯黑 rgba(0,0,0,.72) 在暖白底上非常生硬，
     暖调深色 + backdrop blur 更柔和，也更贴合整体调性。 */
  background: rgba(58, 53, 46, .58);
  backdrop-filter: blur(3px);
  display: grid;
  place-items: center;
  z-index: 50;
}
/* 面板折叠时遮罩铺满整屏（此时右栏不存在，没有可交互的东西要保护）。 */
body.panel-collapsed .focus { right: 0; }
/* ★ 必须显式盖掉 display:grid —— .focus 规则里的 display 会
   覆盖 [hidden] 的默认 display:none，导致点「关闭」后弹窗
   仍在（实测点按钮无反应，DOM 里节点一直存在）。
   这类「元素用 hidden 属性控制显隐 + 自身带 display 声明」
   的组合是隐藏属性失效的经典原因。 */
.focus[hidden] { display: none; }
.focus-card {
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  padding: 18px;
  box-shadow: 0 24px 64px rgba(50, 45, 38, .28);
  /* ★★★ 宽度必须跟随内部画面，而不是写死像素值（2026-10-20 修）。
   *
   *   用户反馈：「放大窗显示不全」。
   *
   *   实测证据（Playwright 量生产端真实 DOM，1920×1080 视口）：
   *     .focus-card    宽 370px
   *     .focus-stage   宽 680px   ← 内部画面
   *     canvas 右边超出卡片 325px
   *     canvas 下边超出卡片 251px
   *     card.scrollWidth 708 > clientWidth 370  → 横向溢出被裁
   *
   *   成因：上一轮把 canvas 从 340×604 放大到 680×1208（为了物理像素
   *   全量、不插值），但卡片宽度那行没跟着改，还停在 370px。
   *   而 .focus-card 有 overflow:hidden，于是多出来的 310px
   *   被**静默裁掉**——用户看到的画面只有左侧一条，
   *   且因为是「被裁」而不是「被压缩」，完全看不出是布局问题。
   *
   *   修法：宽度 = 画面宽 + padding×2 + border×2，用 calc 表达，
   *   以后再改 canvas 尺寸不必同步改这一行（根因就是两处数值脱钩）。
   *   max-width:94vw 兜底极窄屏。
   *
   *   ★ 2026-10-21：padding 从 14 提到 18，所以 +30 改成 +38
   *   （18×2 + 1×2 = 38）。这个数字必须跟着 padding 走，
   *   否则画面右边会被裁掉 4px —— 与「显示不全」是同一个坑。
   */
  /* ★★★ 2026-10-22：卡片宽度改为「画面 + 右侧功能栏」。
   *
   *   宽度由 JS **实测后**写入 --card-w（含 padding + border），
   *   CSS 这边不再自己算。
   *   原因：原来写死 `+ 38`（= padding 18×2 + border 1×2），
   *   改 padding 就会与它脱钩 —— 历史上因为这个魔数与 padding 不同步，
   *   画面右边被裁掉 4px（同一个坑踩过一次）。
   *   现在 JS 用 getComputedStyle 实测 padding/border，
   *   **魔数只剩一个来源**，改 padding 不会再漏。
   */
  width: var(--card-w, 720px);
  /* 右侧功能栏的宽度与间距：唯一定义处。
     JS 用 getComputedStyle 读回来参与宽度计算，
     保证「CSS 画的」和「JS 算的」永远是同一个值。 */
  /* ★★★ 2026-10-22：右栏宽度自适应。
   *
   *   ★ 这个值只是**首帧兜底** —— 每次 layoutFocus() 都会由 JS
   *     按遮罩宽重新计算并覆盖它（单一来源在 JS）。
   *
   *   ★ 为什么不在 CSS 里写 clamp(150px, 34%, 248px)：
   *     34% 相对**卡片内容宽度**，而卡片宽度正是 JS 算出来的
   *     ⇒ 循环依赖（JS 要知道右栏宽 → 要知道卡片宽 → …）。
   *     而且 getComputedStyle 读回来的会是未解析的 clamp 字符串，
   *     parseFloat 得 NaN → 静默走 fallback → 「JS 算的」与
   *     「CSS 画的」脱钩 ⇒ 卡片宽算错 ⇒ 画面被挤。
   *
   *   JS 那边按遮罩宽（视口给的确定量，与卡片宽无关）推导：
   *     sideW = clamp(150, (遮罩宽-48-padding) * 0.30, 248)
   *   宽屏 248，窄屏自动收窄给画面让路。
   */
  --side-w: 248px;
  --side-gap: 12px;
  /* ★ 遮罩变窄后，最大宽度必须跟着收窄，
     否则卡片会溢出遮罩（卡片 718px > 遮罩 1540px 尚可，
     但 1366 视口下遮罩只有 986px，卡片就超了）。
     max-width 用百分比而不是 vw：vw 算的是整个视口，
     扣不掉右栏的 380px，会导致卡片被右栏压住。 */
  max-width: calc(100% - 48px);
  max-height: calc(100vh - 40px);
  /* ★ max-height 放宽到 100vh，让 JS 成为唯一权威（2026-10-20）。
   *
   *   实测踩坑：原来写 max-height:94vh，而 JS 的余量算法
   *   是按「视口 - chrome - 边距」算的，两者口径不一致 →
   *   卡片被 max-height 钳制，实际高度比内容需要的高度少一截，
   *   多出来的那截正好把底部按键条挤出去（overflow:hidden 直接裁掉）。
   *   在 1440p 上实测：内容需要 1416px，钳到 1411px，差 5px 就把
   *   按键条切掉了一截。
   *
   *   表现极具迷惑性：画面明明完整（stage 没溢出），
   *   但下面一排按钮点不到。
   *
   *   现在 JS 通过迭代精确求解（会考虑按键条换行导致的 chrome 变化），
   *   CSS 这边只做兜底，数值给到 100vh，正常情况下**永不触发钳制**。
   */
  max-height: 100vh;
  /* 去掉 overflow:auto 的理由见下方长注释：内容高度恒定后，
     滚动条只会造成「整个页面跟着动」，宁可裁也不位移。 */
  overflow: hidden;
}
.focus-head {
  display: flex;
  align-items: center;
  gap: 10px;
  margin-bottom: 10px;
  /* 同上：头部的「点击 x=0.442 y=0.890」也在每次移动鼠标时重写，
     数值位数变化会让头部长度变化。nowrap + ellipsis 锁死它。 */
  white-space: nowrap;
  overflow: hidden;
}
.focus-head b {
  /*设备序列号定宽，不被坐标挤走 */
  flex: 0 0 auto;
}
#focusPoint {
  /* 坐标区吃掉剩余空间并自行截断，不把按钮顶出卡片 */
  flex: 1 1 auto;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.focus-head button {
  flex: 0 0 auto;
}
/* 画面 + 提示叠在一起。提示做成覆盖层，
   这样它不占额外高度，放大窗的纵向空间全留给画面。 */
/* ★★★ 2026-10-22：画面与右侧功能栏并排。
 *
 *   原来功能区（状态行 / 按键条 / 键盘条）在画面**下方横排**，
 *   于是：
 *     1) 功能区抢走画面高度预算（chrome 高度 = head + meta + keys + kbd），
 *        矮视口下画面被压到 120px 下限；
 *     2) 按键条横排时在窄栏里会折行，chrome 高度又变一次，
 *        layoutFocus 只能靠反复迭代去追（实测要 6 轮才收敛）。
 *
 *   改成右侧竖排后：
 *     - chrome 只剩顶部 .focus-head，高度**恒定**，不再需要迭代；
 *     - 画面高度预算 = 视口 - head - padding，直接算得出；
 *     - 功能区宽度固定（--side-w），按键条竖排永不折行。
 */
.focus-body {
  display: flex;
  flex-direction: row;
  align-items: flex-start;
  gap: var(--side-gap, 12px);
  /* ★ 铁律2：flex 项要能收缩到小于内容必须显式 min-width:0 */
  min-width: 0;
  /* 显式契约：画面与右栏永不换行。
     写出来是为了让「将来有人加 flex-wrap:wrap」被立刻挡住 ——
     一旦换行，--fw/--fh 的计算前提（单行）就不成立了。 */
  flex-wrap: nowrap;
}
.focus-side {
  /* flex 基准 = 宽度（0 0 = 不 grow 不 shrink） */
  flex: 0 0 var(--side-w, 248px);
  width: var(--side-w, 248px);
  /* ★ 高度与画面**等高**：卡片高度因此可精确计算
   *   （= head + fh + padding + border），
   *   不会出现「内容多高卡片就多高」把画面挤掉的情况。
   *   内容超出时右栏自己滚，卡片与画面都不受影响。 */
  height: var(--fh, 1208px);
  max-height: var(--fh, 1208px);
  overflow-y: auto;
  overflow-x: hidden;
  display: flex;
  flex-direction: column;
  /* 窄栏里的滚动条很碍眼，压到最细 */
  scrollbar-width: thin;
  /* ★ 铁律2：必须能收缩到内容高度以下，否则内部的 overflow-y:auto
   *   在「内容比容器高」时会失效（滚动容器不能比内容矮）。 */
  min-height: 0;
}
/* ★★★ 2026-10-22：右栏内的子项全部 flex:none。
 *
 *   默认 flex-shrink:1 ⇒ 内容总高超过 --fh 时它们被**压扁**：
 *     - #focusMeta  被切掉几行（文字看着像被截断）
 *     - .focus-keys 按钮被压扁（点不准）
 *   这两种都是「静默劣化」，不报错。
 *
 *   正确行为：内容超出 → .focus-side 出现滚动条 → 卡片与画面不受影响。
 */
.focus-side > #focusMeta,
.focus-side > .focus-keys,
.focus-side > #kbdBox {
  flex: none;
}
.focus-stage {
  position: relative;
  /* ★ flex:none —— 宽度是 JS 精确算出来的，不许被 flex 布局压缩。
   *   压缩的后果：stage 变窄但 canvas 仍按 --fw 画
   *   → 画面右侧溢出 stage → 被 .focus-card 的 overflow:hidden 裁掉。
   *   这正是「画面右边被裁掉几像素」那类 bug 的成因，
   *   而且是**静默**的（不报错，只是少了条）。 */
  flex: none;
  /* ★ 宽度跟随 --fw（由 JS 按实际画布尺寸写入）。
     原来写死 680px，但画布在 MSE 降级时会被换成 <video>，
     尺寸可能不同；用变量统一驱动就不会脱钩。 */
  width: var(--fw, 680px);
  height: var(--fh, 1208px);
}
/* ★★★ 放大窗 canvas 必须显式跟随 stage（2026-10-21 用户反馈「图标变糊」）。
 *
 *   实测（真实 Chrome，1920x1080）：
 *     canvas 后备缓冲 = 1088x1920   ← scrcpy 实际解码分辨率
 *     canvas 显示尺寸 = 515x910     ← 没有任何 CSS 约束时的默认值
 *     缩放比 = 0.473 → 浏览器插值 → 模糊
 *
 *   成因：`.tile canvas` 有 width:100%;height:100%，
 *     但**放大窗的 canvas 没有任何 CSS 规则命中**。
 *     无约束时 canvas 的显示尺寸 = 它的 width 属性值（1088），
 *     而 stage 只有 515 宽 → canvas 被挤着缩到 515 → 插值。
 *
 *   注意 1088 ≠ 1080：scrcpy 5.0.1 编码器对齐到 16 的倍数，
 *     1080 → 1088。这是设备侧行为，不是 bug。
 */
.focus-stage canvas,
.focus-stage video {
  display: block;
  width: 100%;
  height: 100%;
  background: var(--screen);
  /* ★ 缩放时用像素化而非平滑插值。
     非整数倍缩放（如 0.473）下，平滑插值会把图标边缘糊成一片；
     pixelated 保留了硬边，在缩小时反而更清晰可辨，
     且放大时能看出真实像素结构，便于判断是否真在放大。 */
  image-rendering: pixelated;
}
.focus-card canvas {
  display: block;
  /* 关键：后备缓冲与显示尺寸 1:1，靠这个禁止插值。
   *  但注意 —— 真正的清晰度取决于**设备推上来的分辨率**，
   *  而非这里的 CSS 尺寸。宫格档源是 202x360，
   *  画到 680 宽的画布上就是放大 3.4 倍，必然糊。
   *  放大窗走 native 档（1080x1920）才是真清晰。 */
  image-rendering: -webkit-optimize-contrast;
  image-rendering: crisp-edges;
  /* ★ 尺寸由 JS 按视口实测写入（--fw/--fh），
     不写死像素值 —— 写死过一次就出过一次「显示不全」。 */
  width: var(--fw, 680px);
  height: var(--fh, 1208px);
  /* ★ 画面底保持深色（见文件头「关键取舍」） */
  background: var(--screen);
  border-radius: var(--r-md);
  border: 1px solid var(--screen-line);
  cursor: crosshair;
  /* 关键：画面本身是控制面板，必须吃掉自身的文字选择与
     触摸滚动，否则双击会选中文本、长按会弹出右键菜单。 */
  user-select: none;
  -webkit-user-select: none;
  touch-action: none;
}
/* MSE 降级时 canvas 会被换成 <video>，尺寸规则要一并覆盖，
   否则降级路径下画布会退回默认 300x150 而变形。 */
.focus-card video {
  display: block;
  width: var(--fw, 680px);
  height: var(--fh, 1208px);
  background: var(--screen);
  border-radius: var(--r-md);
  border: 1px solid var(--screen-line);
  cursor: crosshair;
  user-select: none;
  -webkit-user-select: none;
  touch-action: none;
}
.focus-hint {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 10px;
  text-align: center;
  font-size: 12px;
  /* 提示压在画面上，底色仍需深色保证可读，
     但用暖调深灰 + 毛玻璃，与整体调性一致。 */
  color: #efe9de;
  background: rgba(40, 37, 32, .58);
  backdrop-filter: blur(6px);
  border-radius: var(--r-sm);
  padding: 4px 0;
  pointer-events: none;   /* 不能挡住画面上的点击 */
  opacity: .92;
}
/* ★ 2026-10-22：改为**竖排**（右侧窄栏里）。
 *   原来横排 + flex-wrap：卡片一窄就折行，
 *   折行又改变 chrome 高度 → layoutFocus 只能靠迭代追。
 *   竖排后每个按钮独占一行、高度恒定，chrome 永远不用为它让步。 */
.focus-keys {
  display: flex;
  flex-direction: column;
  gap: 7px;
  margin-top: 10px;
}
.focus-keys button {
  width: 100%;
  padding: 8px 12px;
  font-size: 13px;
  border-radius: var(--r-sm);
}
/* ★★★ 状态栏固定高度 + 永不换行（本轮修「底部那行文字让页面跟着动」）。
 *
 *   用户反馈：放大窗口后，手机画面底部那行文字会导致整个页面跟着动。
 *
 *   成因（不是猜测，是布局链路）：
 *     #focusMeta 的文本由 renderFocusMeta() 每 500ms 重写一次，
 *     而内容长度是变的（帧数位数、队列长度、静默毫秒数都在变）。
 *     文本变长 → 从 1 行折成 2 行 → .focus-card 变高
 *     → 卡片是 max-height:94vh + overflow:auto
 *     → 超过 94vh 就出现纵向滚动条 → **卡片内容整体上移**
 *     → 视觉上就是「整个页面跟着动」。
 *
 *   为什么这个反馈很容易被忽略：
 *     画面本身在动，很难分清是视频在动还是布局在抖；
 *     而且它只在「状态栏文本刚好跨过折行边界」的那几秒出现，
 *     一闪而过，截图也抓不到。
 *
 *   修法（三条一起，缺一条都会复现）：
 *     1. height 固定 —— 无论几行都不改变盒子高度
 *     2. nowrap    —— 永不折行，从根上排除「行数变化」
 *     3. overflow hidden + ellipsis —— 超出部分省略而不是撑高
 *   代价是长状态被截断，但信息在 title 里可看（见 app.js），
 *   且真正重要的首帧/握手耗时排在前面不会被截掉。
 */
#focusMeta {
  margin-top: 0;
  /* ★ 2026-10-22：右侧窄栏里单行会被截成省略号，什么都看不到，
   *   改成**固定 6 行高度 + 允许换行**。
   *   「固定高度」这个关键属性保留 —— 它才是防「整个页面跟着动」的那一条：
   *   文本长度变 → 行数变 → 盒子高度不变 → 卡片高度不变。 */
  height: 84px;            /* 6 行 × 14px，固定高度锁死布局 */
  line-height: 14px;
  white-space: normal;     /* 允许换行（窄栏必须） */
  overflow: hidden;
  font-variant-numeric: tabular-nums;  /* 数字等宽，跳动时不抖字 */
}

/* ---------- 日志 ---------- */

/* ★★★ 2026-10-21：日志从「页面底部固定悬浮层」搬进右栏标签页。
 *
 * 旧实现（position:fixed 的浮层）有四个问题：
 *   1) 压在宫格左下角，遮挡最后一行设备格子；
 *   2) 固定 max-height:130px + width:420px，
 *      一屏只能看七八条，且被截断的行要横向滚动才看全；
 *   3) pointer-events:none —— 完全不能选中文本，
 *      想复制一条报错去搜都做不到；
 *   4) 它不属于任何一个功能区，用户找不到「这个框是什么」。
 *
 * 现在它就是右栏的第五个标签页：
 *   高度吃满面板剩余空间、宽度吃满面板宽度，
 *   可以选中复制、可以按级别过滤、可以清空。
 */
.logpane {
  /* 面板型 tab：自身不滚动，滚动交给里面的日志区，
     否则会出现「外层滚一层、内层又滚一层」的双滚动条。 */
  overflow: hidden;
  display: none;
  flex-direction: column;
}
.logpane.on { display: flex; }

.log-toolbar {
  display: flex;
  align-items: center;
  gap: 6px;
  flex-wrap: wrap;
  margin-bottom: 8px;
}
.log-toolbar .spacer { flex: 1; }
.log-filters { display: flex; gap: 4px; }
.log-filters .fbtn {
  padding: 4px 9px;
  font-size: 11px;
}
.log-autoscroll {
  display: flex;
  align-items: center;
  gap: 4px;
  cursor: pointer;
  user-select: none;
}
.log-autoscroll input { accent-color: var(--pri); }

.log {
  flex: 1;
  min-height: 0;
  overflow: auto;
  /* ★ 日志区从「纯黑终端」改为浅沙底：
     整页已经是暖白，如果日志区再压一块深色，
     视觉上会破成一个孤立的深色方块；
     而且大量日志在深底上长时间看非常刺眼。 */
  background: var(--panel);
  border: 1px solid var(--line-soft);
  border-radius: var(--r-sm);
  padding: 10px 12px;
  font: 11.5px/1.7 ui-monospace, Consolas, monospace;
  color: var(--fg);
  /* 现在可以选中文本了（旧的固定浮层是 pointer-events:none） */
  user-select: text;
  -webkit-user-select: text;
}
/* 级别着色：失败标红，警告标黄，其余保持默认灰。
   日志最多的用途是排查「刚才那次操作到底成没成」，
   一屏几十条里靠肉眼扫颜色，比读文字快得多。 */
.log .lv-err { color: var(--err); }
.log .lv-warn { color: var(--warn); }
.log .lv-info { color: var(--fg); }
/* 被级别过滤掉的行直接隐藏 */
.log .lv-hidden { display: none; }
.log .ts { color: var(--dim-faint); margin-right: 7px; }

.log-hint { margin: 10px 0 0; }

/* 标签页上的未读计数徽标 */
.tab-badge {
  display: inline-block;
  min-width: 15px;
  margin-left: 5px;
  padding: 0 5px;
  border-radius: 8px;
  background: var(--pri-soft);
  color: var(--pri-deep);
  font-size: 10px;
  line-height: 16px;
  text-align: center;
  vertical-align: 1px;
}

/* 顶栏的「最新日志」预览按钮 */
.logpill {
  max-width: 260px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  cursor: pointer;
  font-family: ui-monospace, Consolas, monospace;
  font-size: 11px;
  background: var(--panel);
}
.logpill.lv-err { color: var(--err); border-color: #d9b5ae; background: var(--err-soft); }
.logpill.lv-warn { color: var(--warn); border-color: #ddc9a6; background: var(--warn-soft); }

@media (max-width: 1100px) {
  main { grid-template-columns: 1fr; }
  .panel { border-left: none; border-top: 1px solid var(--line-soft); }
}
/* ★★★ 放大窗自适应（2026-10-20 修「显示不全」）。
 *
 * 上一版写的是：
 *     transform: scale(min(1, (100vh - 120px) / 1208));
 *
 * ★ 这条规则**完全无效**，浏览器直接把它丢弃了。
 *   原因：CSS 的 min() 要求所有参数类型一致，
 *   而这里第一个参数 `1` 是 <number>，
 *   第二个 `(100vh - 120px) / 1208` 算出的是 <length>。
 *   类型冲突 → 整条 transform 声明作废 → transform 保持 none。
 *   实测（Playwright 量生产端真实 DOM）：
 *     getComputedStyle(canvas).transform === "none"
 *   于是 680x1208 的画布在 1080 高的视口里被硬裁掉 251px，
 *   横向又被卡片的 370px 裁掉 325px —— 就是「显示不全」。
 *
 * ★ 新方案：不用 transform 缩放，改成**直接算好显示尺寸**。
 *   理由：canvas 的 CSS 尺寸缩放是**合成器**做的（跟缩放图片一样），
 *   走 GPU，不触发重排、也不重采样画布位图。
 *   而 transform: scale 需要额外处理「布局盒 ≠ 视觉尺寸」，
 *   得靠负 margin 收占位，多一层就多一处能出错的耦合。
 *
 *   --fw / --fh 由 JS 按视口实测尺寸写入（见 app.js layoutFocus），
 *   保证「画布显示尺寸」与「卡片宽度」永远一致，不会再脱钩。
 *   画布的**后备缓冲**仍是设备原生分辨率（1080x1920），
 *   由 stream.js 的 _paint() 自动改写 canvas.width/height，
 *   所以显示得再小，细节也来自原生帧，不是放大出来的。
 */
.focus-fit .focus-stage {
  width: var(--fw, 680px);
  height: var(--fh, 1208px);
}

/* ==========================================================================
   键盘直输状态条（2026-10-21）
   ==========================================================================
   ★ 配色沿用莫兰迪基线（style.css :root 的 28 个 CSS 变量），
     不引入新色相 —— 这里的绿点只是「已就绪」的状态标记，
     用 --ok（#57753f 鼠尾草绿深调），与语义色系统一致。

   ★ 布局铁律复述：.focus-card 是纵向 flex 容器，
     这里新增的元素必须 flex:none，否则会被画布挤扁；
     且不能给 height:100% 之类会引入 min-height:auto 的写法。
   ========================================================================== */
/* ★ #kbdBox 是「提示条 + 能力边界说明」的共同容器。
   必须 flex:none + 自成纵向流，否则：
     1) 默认 block 会让子元素 margin 塌陷（measureChrome 量不到）；
     2) 在 .focus-card 的纵向 flex 里可能被压缩。 */
#kbdBox {
  flex: none;
  display: flex;
  flex-direction: column;
}

/* ★ 中文输入框（2026-10-22）。
   可见而不是隐藏：IME 候选窗要贴着输入框弹，
   隐藏元素会让候选窗跑到屏幕角落、用户看不到选了什么。 */
.kbd-ime-row {
  flex: none;
  display: flex;
  gap: 6px;
  margin-top: 8px;
}

.kbd-ime {
  flex: 1;
  /* ★ 铁律2：flex 项要能收缩到小于内容必须显式 min-width:0 */
  min-width: 0;
  padding: 5px 8px;
  font-size: 12px;
  line-height: 1.4;
  border-radius: 6px;
  border: 1px solid rgba(90, 82, 70, 0.28);
  background: var(--panel, #fbf9f5);
  color: var(--fg-strong, #35322d);
}

.kbd-ime::placeholder {
  color: var(--fg, #4a4640);
  opacity: 0.62;
}

.kbd-ime:focus {
  outline: none;
  border-color: var(--pri, #7d9070);
  box-shadow: 0 0 0 2px rgba(125, 144, 112, 0.18);
}

.kbd-hint {
  flex: none;
  margin-top: 8px;
  padding: 7px 10px;
  display: flex;
  align-items: center;
  gap: 8px;
  /* 莫兰迪：浅沙底 + 细边，不要用高饱和色块 */
  background: var(--panel2, #eae5d9);
  border: 1px solid rgba(90, 82, 70, 0.18);
  border-radius: 8px;
  font-size: 12px;
  line-height: 1.45;
  color: var(--fg, #4a4640);
}

/* 「已就绪」绿点：纯装饰，用 currentColor 继承语义色 */
.kbd-dot {
  flex: none;
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: var(--ok, #57753f);
  box-shadow: 0 0 0 3px rgba(87, 117, 63, 0.16);
}

.kbd-hint > span:nth-child(2) {
  flex: 1;
  min-width: 0;   /* ★ 铁律2：flex 项要能收缩到小于内容必须显式 min-width:0 */
  /* ★ 铁律3：需要能换行就必须 normal，nowrap 的 flex 项压不下去 */
  white-space: normal;
}

.kbd-hint b {
  font-weight: 600;
  color: var(--fg-strong, #35322d);
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}

/* ★ 2026-10-22：右侧窄栏里，提示条改为**竖排**（文字在上、按钮在下）。
 *   原来横排时按钮在右侧，窄栏里文字被压成每行两三个字。 */
.focus-side .kbd-hint {
  flex-direction: column;
  align-items: stretch;
  gap: 6px;
}
.focus-side .kbd-paste {
  width: 100%;
}
/* 窄栏里文字更窄，同样行数能显示的字更少 → 放宽到 6 行 */
.focus-side .kbd-limit {
  max-height: 5em;
}
.kbd-paste {
  flex: none;
  padding: 4px 10px;
  font-size: 12px;
  border-radius: 6px;
  border: 1px solid rgba(90, 82, 70, 0.28);
  background: var(--panel, #fbf9f5);
  color: var(--fg-strong, #35322d);
  cursor: pointer;
  white-space: nowrap;
}

.kbd-paste:hover {
  background: var(--pri, #7d9070);
  color: #fff;
  border-color: var(--pri-deep, #5f7054);
}

.kbd-paste:active {
  background: var(--pri-deep, #5f7054);
}

/* 能力边界说明：必须明显弱于主提示，但不能弱到看不见。
   用户需要知道「中文打不进去」是工具的限制而非自己操作错了。

   ★ 2026-10-21：加 max-height + overflow:hidden。
     这段是**说明文案**，不是操作入口，
     在 1366×768 这类矮视口下它会占掉 3 行（33px），
     而放大窗高度预算就那么多 —— 实测这一条直接把画面
     压到 120px 下限、overflow 冲到 402px。
     限高 2 行后超出的部分被裁掉，
     首行「中文 / emoji 走剪贴板+粘贴…」已经足够传达前提，
     用户需要细节时可以看日志（kbdNotice 会打完整说明）。
   ★ max-height 用 em 而不是 px：跟着 font-size 走，
     改字号不必回来改这里（与 .focus-card 的 +38 同类教训）。 */
.kbd-limit {
  flex: none;
  margin-top: 4px;
  padding: 0 2px;
  font-size: 11px;
  line-height: 1.45;
  max-height: 2.9em;      /* 2 行 */
  overflow: hidden;
  color: var(--fg, #4a4640);
  opacity: 0.78;
  white-space: normal;
}

/* ==========================================================================
   极矮视口兜底（2026-10-21）
   ==========================================================================
   1366×768 这类视口，放大窗的可分配高度 = 768 - chrome(205) - 边距(24)
   = 539px，而 chrome 里 .focus-keys(129) + .kbd-limit(65) 是**可选**的
   （不是操作唯一入口 —— 键盘直输已经覆盖了 HOME/BACK/ENTER，
   「输入文字」也有 Ctrl+Shift+V 剪贴板通道）。

   ⇒ 高度不够时优先牺牲说明文案，再牺牲按键条的重复入口，
     保住画面本身。用户能看到的画面比多两行说明重要。

   ★ 用 @media (max-height: 800px) 而不是 JS 判断：
     纯样式、零 JS 成本、且和布局解耦（改 JS 不会顺手改坏它）。
     断点取 800 而不是精确值，是为了覆盖 1366×768 / 1280×720 等常见档。 */
@media (max-height: 800px) {
  /* 说明文案在矮视口隐藏：它的信息在日志里也有（kbdWarnUnsupported），
     而这行字要占 2~3 行 = 33~65px，在 768 高的视口里是奢侈。
     ★ 保留这条：它只影响**说明文字**，不影响任何可点元素。 */
  .kbd-limit { display: none; }

  /* ★★★ 2026-10-22：**删除**「按键条压扁」规则。
   *
   *   这条规则原本是必需的 —— 那时功能区在画面**下方横排**，
   *   与画面抢同一个高度预算（chrome = head + meta + keys + kbd），
   *   矮视口下必须牺牲按键条才能保住画面。
   *
   *   改成右侧竖排后前提变了：
   *     .focus-side 高度 = --fh（与画面等高），自己 overflow-y:auto
   *     ⇒ 右栏内容超高只出滚动条，**画面高度不受任何影响**
   *     ⇒ 「高度不够就压扁按键」这个取舍**不再成立**
   *
   *   继续压扁的代价是实打实的（1366×768 实测）：
   *     按钮高 35px → 20~23px
   *   20px 高的点击目标在鼠标/触屏上都非常难点
   *   （低于 24px 就开始点不准），而画面尺寸**一点没变**
   *   （受 --fh 约束在 663px）—— 纯亏。
   */
}

/* ══════════════════════════════════════════════════════════════
   管理台（2026-10-22）
   ──────────────────────────────────────────────────────────────
   ★ 为什么是全屏浮层而不是右栏第 9 个页签：
     右栏只有 380px（--panel-w），要装下「用户表格 + 19 台归属格子图
     + 审计流水表」三块内容根本放不下 ⇒ 做成全屏浮层。
   ★ 配色严格取自 :root 的 28 个变量，**不新增任何颜色**：
     莫兰迪铁律 —— 调色只能降亮度，不能动色相。
     归属/租约/在线三种状态用「已有语义色的底色 + 深色描边」区分，
     不靠新色相。
   ══════════════════════════════════════════════════════════════ */

.admin-dlg {
  position: fixed;
  inset: 0;
  z-index: 60;                 /* 高于放大窗的 50 */
  background: rgba(58, 53, 46, .58);
  backdrop-filter: blur(3px);
  display: grid;
  place-items: center;
  padding: 24px;
}
/* ★ [hidden] 必须显式盖掉 display —— 全局已有
     `[hidden]{display:none!important}`，这里再声明一次是双保险
     （本文件顶部已说明过这个坑：元素自身带 display 会覆盖 hidden）。 */
.admin-dlg[hidden] { display: none; }

.admin-card {
  width: min(1280px, 100%);
  max-height: calc(100vh - 48px);
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  box-shadow: var(--shadow-lg);
  display: flex;
  flex-direction: column;
  overflow: hidden;
  min-height: 0;               /* flex 子项要能收缩到小于内容 */
}

.admin-head {
  flex: none;
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 12px 18px;
  border-bottom: 1px solid var(--line-soft);
  background: var(--bg-soft);
}
.admin-head h2 {
  margin: 0;
  font-size: 15px;
  font-weight: 600;
  color: var(--fg-strong);
}
.admin-nav { display: flex; gap: 6px; }
.admin-nav .abtn {
  padding: 5px 13px;
  font-size: 12px;
  border-radius: 13px;
}
.admin-nav .abtn.on {
  background: var(--pri-soft);
  border-color: var(--pri-line);
  color: var(--pri-deep);
  font-weight: 600;
}

.admin-body {
  flex: 1;
  min-height: 0;
  overflow: auto;
  padding: 16px 18px 20px;
}
.aview { display: none; }
.aview.on { display: block; }

.admin-cols {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 340px);
  gap: 22px;
  align-items: start;
}
/* ★ 窄视口（1366×768 常见）下不能硬挤成两栏：
     340px 固定列 + 1fr 在 1366 下剩 900px 还够，
     但 1280 以下就该堆叠，否则格子图被压到不可用。 */
@media (max-width: 1100px) {
  .admin-cols { grid-template-columns: minmax(0, 1fr); }
}

.admin-col { min-width: 0; }
.admin-col-wide { min-width: 0; }
.admin-col h3 {
  font-size: 12px;
  text-transform: uppercase;
  letter-spacing: .6px;
  color: var(--dim);
  margin: 4px 0 10px;
  padding-bottom: 6px;
  border-bottom: 1px solid var(--line-soft);
}
/* ★★★ 2026-10-23 修「权限勾选错位」第二根因：
 *   这两条泛型规则是给**表单标签/输入框**（建号、归属、审计筛选）写的，
 *   但 `.admin-col label`（0,1,1）特异性压过 `.perm-chk`（0,1,0）
 *   ⇒ 角色权限矩阵的每个勾选项被强制 display:block + 10px 外边距，
 *   勾选框与文字**上下堆叠**；`.admin-col input` 又把 checkbox 拉宽到
 *   整列（190px）。归属视图的 `.ac-pick` 勾选框同样被 blockify。
 *   ⇒ 用 :not() 把两类勾选标签/勾选框排除出泛型规则。 */
.admin-col label:not(.perm-chk):not(.ac-pick) {
  display: block;
  font-size: 12px;
  color: var(--dim);
  margin: 10px 0 4px;
}
.admin-col input:not([type="checkbox"]):not([type="radio"]),
.admin-col select { width: 100%; }
.errmsg { color: var(--err); font-size: 12px; min-height: 16px; margin: 6px 0 0; }

/* ---------- 条目列表（用户 / 租约 / 统计共用） ---------- */
.admin-list { display: flex; flex-direction: column; gap: 7px; }
.admin-row {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 8px 11px;
  background: var(--panel2);
  border: 1px solid var(--line-soft);
  border-radius: var(--r-sm);
  font-size: 13px;
  min-width: 0;
}
.admin-row .an-name {
  font-weight: 600;
  color: var(--fg-strong);
  white-space: nowrap;
}
.admin-row .an-meta {
  color: var(--dim);
  font-size: 12px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  min-width: 0;
}
.admin-row .spacer { flex: 1 1 auto; }
.admin-row select { width: auto; padding: 4px 8px; font-size: 12px; }
.admin-row button { padding: 4px 10px; font-size: 12px; }
.admin-row.is-disabled .an-name { color: var(--dim); text-decoration: line-through; }

/* ★ 账号级并发推流上限（2026-10-25 第二十二轮）。
   只引用既有变量（--panel2/--line-soft/--dim/--fg-strong），
   不新造色相（铁律⑧）。 */
.admin-row .an-quota {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  white-space: nowrap;
}
.admin-row .an-quota-input {
  width: 58px;
  padding: 3px 6px;
  font-size: 12px;
  background: var(--panel);
  color: var(--fg);
  border: 1px solid var(--line-soft);
  border-radius: var(--r-sm);
}
.admin-row .an-quota-input:focus {
  outline: none;
  border-color: var(--pri);
}

/* 角色徽标：用语义色底 + 深色字，不新增色相 */
.role-tag {
  font-size: 11px;
  padding: 2px 8px;
  border-radius: 10px;
  border: 1px solid var(--line);
  background: var(--panel);
  color: var(--dim);
  white-space: nowrap;
}
.role-tag.role-admin { background: var(--rose-soft); border-color: var(--rose); color: var(--err); }
.role-tag.role-operator { background: var(--pri-soft); border-color: var(--pri-line); color: var(--pri-deep); }
.role-tag.role-viewer { background: var(--panel); border-color: var(--line); color: var(--dim); }

/* ---------- 角色权限配置（2026-10-23，管理台视图 ④） ---------- */
.role-list {
  display: flex;
  flex-direction: column;
  gap: 14px;
}
.role-card {
  border: 1px solid var(--line);
  border-radius: 10px;
  background: var(--panel);
  padding: 12px 14px;
}
.role-head {
  display: flex;
  align-items: center;
  gap: 10px;
  margin-bottom: 10px;
}
.role-head .spacer { flex: 1 1 auto; }
.role-head button { padding: 4px 10px; font-size: 12px; }
.role-head button.dirty {
  background: var(--warn-soft);
  border-color: var(--warn);
  color: var(--warn);
}
.perm-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
  gap: 6px 14px;
}
.perm-chk {
  display: flex;
  align-items: center;
  gap: 6px;
  font-size: 12px;
  color: var(--dim);
  padding: 3px 6px;
  border-radius: 6px;
  cursor: pointer;
}
.perm-chk:hover { background: var(--panel2); }
.perm-chk input { accent-color: var(--pri); margin: 0; padding: 0; flex: none; }
/* user:manage = 提权风险项：警示底色 */
.perm-chk.risky {
  background: var(--warn-soft);
  color: var(--warn);
}
.perm-chk.risky:hover { filter: brightness(0.97); }
.perm-chk.dim { opacity: 0.55; cursor: default; }

/* ---------- 设备归属格子图 ---------- */
.assign-legend {
  display: flex;
  gap: 14px;
  flex-wrap: wrap;
  font-size: 12px;
  color: var(--dim);
  margin: 4px 0 12px;
}
.assign-legend i {
  display: inline-block;
  width: 11px; height: 11px;
  border-radius: 3px;
  margin-right: 5px;
  vertical-align: -1px;
}
.lg-pub { background: var(--panel2); border: 1px solid var(--line); }
.lg-own { background: var(--pri-soft); border: 1px solid var(--pri-line); }
.lg-grp { background: var(--sand-soft); border: 1px solid var(--sand); }
.lg-busy { background: var(--err-soft); border: 1px solid var(--err); }

.assign-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(112px, 1fr));
  gap: 8px;
}
.assign-cell {
  padding: 8px 9px;
  border-radius: var(--r-sm);
  border: 1px solid var(--line);
  background: var(--panel2);
  cursor: pointer;
  text-align: left;
  font-size: 12px;
  color: var(--fg);
  display: flex;
  flex-direction: column;
  gap: 3px;
  min-width: 0;
  transition: border-color .15s ease, background .15s ease;
}
.assign-cell:hover { border-color: var(--pri-line); background: var(--pri-soft); }
.assign-cell .ac-serial {
  font-weight: 600;
  color: var(--fg-strong);
  font-size: 12px;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.assign-cell .ac-owner {
  font-size: 11px;
  color: var(--dim);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
/* 归属态：用户专属 / 归属组 / 公共池 —— 三态必须一眼可分 */
.assign-cell.own {
  background: var(--pri-soft);
  border-color: var(--pri-line);
}
.assign-cell.grp {
  background: var(--sand-soft);
  border-color: var(--sand);
}
.assign-cell.pub {
  background: var(--panel2);
  border-color: var(--line);
  border-style: dashed;
}
/* 被占用（有人正在操作）—— 叠加一层错误色描边 */
.assign-cell.busy { border-color: var(--err); box-shadow: inset 0 0 0 1px var(--err-soft); }
.assign-cell .ac-busy {
  font-size: 11px;
  color: var(--err);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.assign-cell.off .ac-serial { color: var(--dim); }

/* ---------- 批量移交（2026-10-22）----------
 * 格子现在裹在 `.assign-cell-wrap` 里，勾选框与「改」按钮放在格子**上方**的一条小工具条里。
 * ★ 为什么要**工具条**而不是把控件绝对定位压在格子上：
 *   ① 绝对定位会让控件与格子的文字**互相遮挡**
 *      （格子的第一行现在是昵称，压上去就看不见了）；
 *   ② <button> 里不能塞 <input>（非法嵌套），
 *      改名输入框只能放在格子**外面** ⇒ 索性把工具条也放外面；
 *   ③ 触屏没有 hover ⇒ 控件必须**常驻可见**。 */
.assign-cell-wrap {
  position: relative;
  min-width: 0;
}
.assign-cell-wrap .assign-cell { width: 100%; }
/* 已勾选：整格加一道主色描边，一眼能看出选了哪些 */
.assign-cell-wrap.picked .assign-cell {
  border-color: var(--pri);
  box-shadow: inset 0 0 0 1px var(--pri-soft);
}
/* 每格上方的小工具条：左=勾选框，右=改名 */
.ac-bar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 4px;
  height: 18px;
  padding: 0 2px;
}
/* 勾选框本体 */
.ac-pick {
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  line-height: 0;
}
.ac-pick input {
  width: 14px;
  height: 14px;
  margin: 0;
  cursor: pointer;
  accent-color: var(--pri);
}

/* ---------- 设备改名（昵称，2026-10-22）---------- */
/* 格子里的昵称：比 serial 更醒目（那是人真正在用的标识） */
.assign-cell .ac-name {
  font-size: 13px;
  font-weight: 600;
  color: var(--pri-deep);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
/* 工具条上的小「改」按钮 */
.ac-rename {
  border: 1px solid var(--line);
  background: var(--panel);
  color: var(--dim);
  font-size: 11px;
  line-height: 1;
  padding: 2px 7px;
  border-radius: 6px;
  cursor: pointer;
}
.ac-rename:hover {
  border-color: var(--pri-line);
  color: var(--pri-deep);
  background: var(--pri-soft);
}
/* 「日志」按钮：与「改」同款（同一层级的小动作），
   只靠 title 区分用途，不引入新颜色。 */
.ac-log {
  margin-left: 4px;
}
/* 审计页脚：「加载更多」+ 统计信息一行 */
.audit-foot {
  display: flex;
  align-items: center;
  gap: 10px;
  padding-top: 8px;
}
/* 编辑态：整格换成输入框（不在 <button> 里塞 <input>） */
.ac-edit {
  display: flex;
  flex-direction: column;
  gap: 5px;
  padding: 7px 8px;
  border: 1px solid var(--pri-line);
  background: var(--pri-soft);
  border-radius: var(--r-sm);
}
.ac-edit .ac-serial {
  font-size: 11px;
  color: var(--dim);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.ac-input {
  width: 100%;
  padding: 4px 6px;
  border: 1px solid var(--pri-line);
  border-radius: 6px;
  background: var(--panel);
  color: var(--fg);
  font-size: 12px;
}
.ac-ok, .ac-cancel {
  border-radius: 6px;
  font-size: 11px;
  padding: 3px 8px;
  cursor: pointer;
}
.ac-ok {
  border: 1px solid var(--pri-deep);
  background: var(--pri-deep);
  color: #fbf9f5;
}
.ac-cancel {
  border: 1px solid var(--line);
  background: var(--panel);
  color: var(--dim);
}

/* 批量操作条 —— 只在有勾选时出现 */
.assign-batch {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 8px;
  margin-top: 10px;
  padding: 9px 11px;
  border-radius: var(--r-sm);
  border: 1px solid var(--pri-line);
  background: var(--pri-soft);
  font-size: 12px;
  color: var(--fg);
}
.assign-batch[hidden] { display: none; }
.assign-batch .ab-count { color: var(--fg-strong); font-weight: 600; }
.assign-batch .ab-count b { color: var(--pri-deep); }
.assign-batch select { max-width: 190px; }
/* 批量依次命名：分隔竖线 + 两个窄输入 */
.assign-batch .ab-sep {
  width: 1px;
  align-self: stretch;
  margin: 0 3px;
  background: var(--pri-line);
}
.assign-batch .ab-num { width: 54px; }
.assign-batch .ab-txt { width: 62px; }

/* ---------- 审计流水 ---------- */
.audit-filters {
  display: flex;
  gap: 7px;
  flex-wrap: wrap;
  margin: 4px 0 12px;
}
.audit-filters input { width: 150px; padding: 5px 9px; font-size: 12px; }
/* ★ 动作下拉（2026-10-24）：宽度给足，否则家族名被截成「设备操…」。
   复用既有变量，不新造颜色。 */
.audit-filters select#afAction {
  width: 168px;
  padding: 5px 8px;
  font-size: 12px;
  background: var(--panel);
  color: var(--fg);
  border: 1px solid var(--line);
  border-radius: 6px;
}
.audit-filters .fbtn { padding: 5px 11px; font-size: 12px; border-radius: 12px; }
.audit-filters .fbtn.on {
  background: var(--pri-soft);
  border-color: var(--pri-line);
  color: var(--pri-deep);
  font-weight: 600;
}
.audit-list { max-height: 460px; overflow: auto; }
.audit-row {
  display: grid;
  /* 说明列是中文陈述句（现在最宽）；动作列要放「中文标签 + 原码」，
     故从 152px 放宽到 178px。 */
  grid-template-columns: 128px 88px 178px 1fr 56px;
  gap: 8px;
  align-items: center;
  font-size: 12px;
  padding: 6px 9px;
  border-bottom: 1px solid var(--line-soft);
  min-width: 0;
}
.audit-row.head {
  position: sticky;
  top: 0;
  background: var(--panel);
  border-bottom: 1px solid var(--line);
  color: var(--dim);
  font-size: 11px;
  z-index: 1;
}
.audit-row .au-t { color: var(--dim-faint); font-variant-numeric: tabular-nums; }
.audit-row .au-u { color: var(--fg); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.audit-row .au-a { color: var(--info); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* ★ 动作列同时显示中文标签 + 原始码（可读性改造 2026-10-24）。
   原码用小号灰字跟在标签后面 —— 看懂的同时仍能反查原始记录。 */
.audit-row .au-a .au-code {
  font-style: normal;
  font-size: 10px;
  color: var(--dim-faint);
  margin-left: 5px;
  opacity: 0.75;
}
/* 说明列：现在是**中文陈述句**（后端译文），颜色比原来亮一档，
   之前用 --dim 灰字导致描述几乎看不见。 */
.audit-row .au-s { color: var(--fg-strong); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.audit-row .au-ok { text-align: right; }
.audit-row .au-ok.yes { color: var(--ok); }
.audit-row .au-ok.no { color: var(--err); font-weight: 600; }

/* ---- 「原始记录」展开（可追溯性：译文旁边必须能看 JSON 原文） ---- */
.audit-rawwrap { padding: 0 9px 4px; }
.audit-rawwrap .au-rawbtn {
  background: none;
  border: none;
  color: var(--dim-faint);
  font-size: 11px;
  cursor: pointer;
  padding: 1px 0;
}
.audit-rawwrap .au-rawbtn:hover { color: var(--info); }
.audit-raw {
  margin: 4px 0 6px;
  padding: 8px 10px;
  background: var(--panel2);
  border: 1px solid var(--line-soft);
  border-radius: 6px;
  font-size: 11px;
  line-height: 1.5;
  color: var(--fg-strong);
  max-height: 320px;
  overflow: auto;
  white-space: pre-wrap;
  word-break: break-all;
}
