/* 各页面特有布局。 */

/* --------------------------------------------------------------------------- 登录 / 注册 */

.auth-page {
  display: grid;
  align-content: center;
  gap: var(--gap);
  min-height: 100%;
  max-width: 420px;
  margin: 0 auto;
}

.brand {
  text-align: center;
  margin-bottom: var(--gap-lg);
}

/* 品牌标识是一张 SVG 图片，尺寸由这里控制（原来是 emoji，靠 font-size 撑开）。
   需要 display:block，否则 img 是行内元素，margin:auto 无法居中。
   该 SVG 的 viewBox 已贴合图元外接框，所以这里的宽高就是图形本身的宽高。 */
.brand .logo {
  display: block;
  width: clamp(56px, 16vw, 80px);
  height: clamp(56px, 16vw, 80px);
  margin: 0 auto;
}

.brand h2 {
  font-size: var(--fs-xl);
  margin-top: var(--gap-sm);
}

.brand p {
  color: var(--text-dim);
  font-size: var(--fs-sm);
  margin-top: var(--gap-xs);
}

.auth-switch {
  text-align: center;
  font-size: var(--fs-sm);
  color: var(--text-dim);
}

/* 登录页底部的协议提示。按惯例紧凑地跟在按钮下方，
   协议名用强调色，其余压成次要文字 */
.auth-agree {
  margin-top: var(--gap-sm);
  text-align: center;
  font-size: var(--fs-xs);
  color: var(--text-dim);
  line-height: 1.7;
}

.auth-agree .btn-link {
  font-size: var(--fs-xs);
  color: var(--primary);
  padding: 0 2px;
  vertical-align: baseline;
}

/* --------------------------------------------------------------------------- 协议类文档页 */

.doc-page {
  max-width: var(--max-w);
  margin: 0 auto;
}

.doc-section + .doc-section {
  margin-top: var(--gap-lg);
}

.doc-title {
  font-size: var(--fs-base);
  font-weight: 600;
  margin-bottom: var(--gap-xs);
}

.doc-p {
  font-size: var(--fs-sm);
  color: var(--text-dim);
  line-height: 1.75;
}

.doc-p + .doc-p {
  margin-top: var(--gap-xs);
}

.doc-foot {
  margin-top: var(--gap);
  text-align: center;
  font-size: var(--fs-xs);
  color: var(--text-dim);
  line-height: 1.8;
}

/* --------------------------------------------------------------------------- 设备列表 */

/* 设备列表：一行一台。左侧是名称与厂商型号，状态灯靠最右 */
.dev-list {
  display: flex;
  flex-direction: column;
  gap: var(--gap-sm);
}

.dev-card {
  display: flex;
  align-items: center;
  gap: var(--gap-sm);
  width: 100%;
  padding: var(--gap-sm) var(--gap);
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  text-align: left;
}

.dev-card:active {
  background: var(--surface-2);
}

/* min-width:0 是内部省略号生效的前提：flex 子项默认不会缩到内容宽度以下 */
.dev-card .dev-info {
  flex: 1;
  min-width: 0;
}

.dev-card .dev-name {
  font-size: var(--fs-base);
  font-weight: 600;
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
}

.dev-card .dev-meta {
  margin-top: 2px;
  font-size: var(--fs-xs);
  color: var(--text-dim);
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
}

/* 列表页顶部的概览条 */
.overview {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--gap-sm);
  margin-bottom: var(--gap);
}

.overview .ov-text {
  font-size: var(--fs-sm);
  color: var(--text-dim);
}

/* 搜索栏：只在搜索态出现 */
.search-bar {
  margin-bottom: var(--gap-sm);
}

/* 连不上服务器时的那条细横幅。**只要一行** —— 原来那是一整块
   「⚠ + 两行说明 + 按钮」，把整个列表挤没了，而它要说的就一句话。
   列表里已缓存的设备照常显示，网络恢复会自动刷新（见 devices.js 的 renderNetBar） */
.net-bar {
  margin-bottom: var(--gap-sm);
  padding: 8px 12px;
  border-radius: var(--radius-sm);
  background: var(--surface-2);
  border: 1px solid var(--st-error);
  color: var(--st-error);
  font-size: var(--fs-sm);
  text-align: center;
}

/* 只有"没配服务器地址"那一种需要用户动手，才给可点的样子 */
.net-bar.tappable {
  cursor: pointer;
}

/* 服务到期横幅（设备详情页）：宽限期只读 / 过期停机。
   与网络横幅分开一套颜色 —— 一个是"服务要花钱续"，一个是"网断了"，
   用户要做的动作不一样（续费 vs 检查 Wi-Fi）。 */
.service-banner {
  margin-bottom: var(--gap-sm);
  padding: 8px 12px;
  border-radius: var(--radius-sm);
  /* 默认是宽限期：黄色 */
  background: var(--warn-bg);
  border: 1px solid var(--st-connecting);
  color: var(--st-connecting);
  font-size: var(--fs-sm);
  text-align: center;
}
/* 已停机：红色（与宽限期区分开，用户 2026-10-20 定） */
.service-banner.err {
  background: var(--err-bg);
  border-color: var(--st-error);
  color: var(--st-error);
}

/* 分组：标题可折叠，组内仍交给卡片布局 */
.group + .group {
  margin-top: var(--gap-sm);
}

.group-head {
  display: flex;
  align-items: center;
  gap: var(--gap-sm);
  /* 纯标题行用不到 --tap 那么高（那是可点按尺寸的下限），收紧后一屏能多放一组 */
  min-height: 36px;
  /* 分组名是这一屏的结构性标题，比次要说明文字大一级 */
  font-size: var(--fs-lg);
  color: var(--text-dim);
}

.group-head .group-name {
  flex: 1;
}

/* 展开的分组标题用主色，和折叠状态拉开差别 */
.group.is-open .group-name {
  color: var(--primary);
}

/* 有通知的分组：标题转告警色（复用色板里的告警黄）。
   展开态仍保持蓝色 —— "当前打开"是状态色，优先于通知色，
   否则一旦打开就反而看不出这组有通知了 */
.group.has-notice:not(.is-open) .group-name {
  color: var(--st-connecting);
}

/* 有通知的设备：名称转告警色。在线状态灯不跟着变，仍旧绿/灰 */
.dev-card.has-notice .dev-name {
  color: var(--st-connecting);
}

/* 置顶的「开发」组（产品页）：标题样式一样，但**不可折叠** ——
   不响应点击，三角位留空好让标题跟下面的行业对齐 */
.group-head.static {
  pointer-events: none;
}

/* 长文本换行，不截断。简介这类内容截断了等于没写，
   而「一行显示不下就只能左右拖」比截断还别扭 */
.row-value.text-wrap {
  max-width: none;
  white-space: normal;
  overflow: visible;
  text-overflow: clip;
  line-height: 1.6;
}

/* 折叠指示：实心三角 + 正文色，比灰色小三角更像"这里可以点"。
   字号压得比标题小不少，避免实心三角喧宾夺主 */
.group-head .group-caret {
  width: 1em;
  font-size: 0.62em;
  color: var(--text);
}

.group-head .group-count {
  font-size: var(--fs-base);
}

.group-empty {
  /* 紧贴标题下方即可，别把空分组撑成一大块 */
  padding-bottom: var(--gap-xs);
  font-size: var(--fs-sm);
  color: var(--text-dim);
}

/* 拖拽时滑入的移除区。平时 opacity:0 + pointer-events:none，
   既看不见也点不着（否则会盖住顶部的分组标题） */
.trash {
  position: fixed;
  left: 50%;
  top: calc(var(--topbar-h) + var(--gap));
  display: flex;
  align-items: center;
  gap: var(--gap-xs);
  padding: var(--gap-sm) var(--gap);
  border-radius: 999px;
  background: var(--surface-2);
  border: 1px solid var(--st-error);
  color: var(--st-error);
  font-size: var(--fs-sm);
  opacity: 0;
  transform: translateX(-50%) scale(0.6);
  pointer-events: none;
  transition: opacity 0.18s ease, transform 0.18s ease, background 0.18s ease;
  z-index: 70;
}

.trash.show {
  opacity: 1;
  transform: translateX(-50%) scale(1);
  pointer-events: auto;
}

/* 拖到它头上：再放大一档并整个填红，明确"松手就删" */
.trash.drop-target {
  background: var(--st-error);
  color: var(--text);
  transform: translateX(-50%) scale(1.12);
}

.trash-icon {
  font-size: 22px;
  line-height: 1;
}

.trash-text {
  white-space: nowrap;
}

/* 分组管理弹层里的新建行：输入框 + 新建按钮 */
.group-new {
  display: flex;
  gap: var(--gap-sm);
  margin-top: var(--gap-sm);
}

.group-new .input {
  flex: 1;
}

/* 「名称 + 一组动作」的行：名称占满剩余宽度、动作整体靠右，
   动作的位置就不会随名称长短左右漂移了 */
.row.row-wide {
  grid-template-columns: 1fr auto;
}

.row.row-wide .row-label {
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
}

/* `<button class="row">` 的两条修正（都是在真机上截图发现的）：
   1. **宽度**：按钮的 `width:auto` 是"收缩到内容"，即使它已经是 `display:grid` ——
      不写 100% 的话整行只有 76px 宽，值和箭头全挤在标签旁边。
   2. **对齐**：按钮默认把内容居中，而这类行要的是"标签靠左、动作靠右"。 */
button.row {
  width: 100%;
  text-align: left;
}

.row .row-actions {
  display: flex;
  gap: var(--gap);
}

/* 配方号行（画面 2）：号码列固定宽度 —— 用 auto 的话「1」和「12」
   宽窄不同，下面的配方名会逐行错位，30 行看下来像没对齐。
   四列 = 号 / 名字 / 当前徽标 / 两个按钮（徽标那格**永远在**，见 recipes.js 的
   recipeRow —— 有/没有会让后面那两个按钮在网格里移一格，右边缘参差不齐）*/
.recipe-row {
  /* 容得下两位数字加一段空隙 */
  grid-template-columns: 2.4em 1fr auto auto;
  width: 100%;
  text-align: left;
}

/* 空槽：**只把名字那一格灰下来**，别整行压暗。
   原来这里是 `opacity: .5`，连右边那两个按钮一起压暗了 —— 而空槽的「编辑」是
   **可用的**（空槽要能建，见 recipes.js 的 recipeRow），云配方那条路（画面 5）
   里空号更是**要选的目标**（见 slotRow）。「看着点不动、其实能点」是最糟的一种：
   用户会以为这个号建不了配方，于是永远建不出第一个。
   「应用」自己有 `:disabled` 的灰，不用行来代劳。配方号仍要看得清 —— 现场是按号对号的 */
.recipe-row.empty .recipe-name {
  color: var(--text-dim);
}

/* 正在跑的那个号：把名字提一下色，扫一眼就知道现在跑哪个 */
.recipe-row.is-current .recipe-name {
  color: var(--primary);
  font-weight: 600;
}

.recipe-flag {
  align-self: center;
  font-size: var(--fs-xs);
  color: var(--primary);
}

/* 右端那两个按钮：**行内小按钮**。默认的 .btn 是 44px 高的整块，
   一面列表每行两个会把页面撑得又长又重 */
.recipe-ops {
  display: flex;
  gap: var(--gap-xs);
  align-self: center;
}

.recipe-ops .btn {
  min-height: auto;
  padding: 4px 10px;
  font-size: var(--fs-sm);
}

/* 门禁灰掉的按钮（运行中：别的号不能应用、正在跑的号不能编辑）。
   见 recipes.js 的 recipeRow —— 设备端也会拦，这里只是别让人白点一次 */
.recipe-ops .btn:disabled {
  opacity: 0.4;
}

.recipe-index {
  font-size: var(--fs-base);
  color: var(--text-dim);
  /* 等宽数字：1 和 12 占一样宽，30 行竖着扫下来不会左右抖 */
  font-variant-numeric: tabular-nums;
}

.recipe-name {
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
}

/* 可点的入口行：button 默认把内容居中，这里要的是名字靠左、引导符靠右 */
.cloud-row {
  width: 100%;
  text-align: left;
}

/* 历史页「管理」模式里行首那个勾选框。
   **它只是个记号，不吃点击**（`pointer-events: none`）—— 整行才是勾选，
   点记号跟点行两条路做同一件事只会多一个出错的地方（记号比行窄得多） */
.pick-mark {
  width: 18px;
  height: 18px;
  border: 1px solid var(--border);
  border-radius: 4px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  font-size: var(--fs-xs);
  color: var(--on-accent);
  pointer-events: none;
}

.pick-mark.is-on {
  background: var(--primary);
  border-color: var(--primary);
}

/* 云配方品类行（画面 3）：名字下面一行小字摊出厚薄档位，
   进详情前就能看出这个品类分不分厚薄 */
.cloud-spec {
  margin-top: 2px;
  font-size: var(--fs-xs);
  color: var(--text-dim);
  /* 搜索结果那行会带上分类名，长起来得截断而不是把行撑破 */
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
}

/* 云配方：一个品类 = 一行 +（展开时的）厚薄选择块。
   `.rows` 是网格，分隔线原本靠 `.row + .row`，外面多包一层后得自己补 */
.cloud-item + .cloud-item {
  border-top: 1px solid var(--border);
}

/* 展开出来的选择块。往里缩一点，让人看出它是上面那一行的从属内容 */
.cloud-pick {
  display: grid;
  gap: var(--gap-sm);
  padding: 0 var(--gap) var(--gap);
}

.cloud-write {
  font-size: var(--fs-xs);
  text-align: center;
}

/* 机型号行（产品页）。与 .cloud-row 同款，但独立命名 ——
   云配方与产品型号库是两码事，互相借名会让后人改错地方 */
.model-row {
  width: 100%;
  text-align: left;
}

/* 说明文字换行，不截断。grid 里要能缩，才谈得上换行 */
.model-main {
  min-width: 0;
}

.model-spec {
  margin-top: 2px;
  font-size: var(--fs-xs);
  color: var(--text-dim);
  line-height: 1.5;
}

/* 产品页的四层骨架：行业 → 设备类型 → 厂商 → 机型。
   层级靠字号与颜色拉开，**不靠缩进堆** —— 手机上缩四层会把内容挤没。
   行业的折叠用的是设备页那套 .group（见上面），这里只管下面三层 */
.model-maker {
  display: flex;
  align-items: baseline;
  gap: var(--gap-xs);
  margin: var(--gap-sm) 0 var(--gap-xs);
}

.model-maker-name {
  font-size: var(--fs-base);
  font-weight: 600;
}

.model-maker-count {
  font-size: var(--fs-xs);
  color: var(--text-dim);
}

/* 机型详情页的「模板」卡片：一套模板一张卡，名称与当前版本徽标同一行，
   下面一行小字是模板族 id（排查问题时对得上服务端那份文件） */
.tpl-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--gap-xs);
}

.tpl-name {
  font-weight: 600;
  min-width: 0;
}

.tpl-id {
  margin-top: 2px;
  font-size: var(--fs-xs);
  color: var(--text-dim);
}

/* 选中档位的说明（画面 4）：比「将写入」那行更靠近选项，说明的是选项本身 */
.cloud-desc {
  margin-top: var(--gap-xs);
  font-size: var(--fs-sm);
  color: var(--text-dim);
  line-height: 1.5;
}

/* 「这行能点进去」的引导符，与配方名右侧那个 › 同一套做法 */
.row-go {
  color: var(--primary);
  font-size: var(--fs-lg);
  line-height: 1;
}

/* 危险操作的文字按钮 */
.btn-link.danger {
  color: var(--st-error);
}

/* 拖动分组：原位置留一个半透明占位，跟手的副本浮在上层 */
.dev-card.dragging {
  opacity: 0.35;
}

.drag-ghost {
  position: fixed;
  left: 0;
  top: 0;
  z-index: 100;
  /* 副本必须让出命中测试，否则 elementFromPoint 探到的永远是它自己，落点判断全错 */
  pointer-events: none;
  opacity: 0.95;
  box-shadow: var(--shadow-strong);
}

/* 拖到哪个分组，哪个分组高亮 */
.group.drop-target {
  outline: 2px dashed var(--primary);
  outline-offset: 4px;
  border-radius: var(--radius-lg);
}

.group.drop-target .group-head {
  color: var(--primary);
}

/* --------------------------------------------------------------------------- 扫码页 */

.scan-page {
  display: grid;
  gap: var(--gap);
  max-width: 560px;
  margin: 0 auto;
}

/* 取景框：用 aspect-ratio 保持正方形，宽高随视口自适应 */
.scan-frame {
  position: relative;
  width: 100%;
  aspect-ratio: 1 / 1;
  max-height: 58vh;
  margin: 0 auto;
  background: #000;
  border-radius: var(--radius-lg);
  overflow: hidden;
  display: flex;
  align-items: center;
  justify-content: center;
  /* 单指的滑动/滚动照常交给浏览器，**双指那套留给脚本**（变焦，见 scan.js）。
     不能写 touch-action:none —— 那会把"从取景框上起的滑动"也一起吃掉，
     取景框占了大半屏，页面就基本划不动了 */
  touch-action: pan-x pan-y;
}

/* video 出画之前保持透明。WebView 对还没有可用帧的 video 会画一个默认的
   播放占位，露出来就是"闪一下播放键"；首帧就绪（playing）后由脚本加 .ready。
   底下的黑底一直铺着，所以过渡期间看到的是干净的黑色而不是占位图 */
.scan-frame video {
  opacity: 0;
}

.scan-frame video.ready {
  opacity: 1;
  transition: opacity 0.12s ease;
}

@supports not (aspect-ratio: 1 / 1) {
  /* 老 WebView 兜底：用 padding-top 百分比撑出正方形 */
  .scan-frame::before {
    content: "";
    display: block;
    padding-top: 100%;
  }
}

.scan-frame video {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* 四角取景框装饰。**框就是解码区** —— inset 由 scan.js 按当前 crop 现算
   （两者必须是同一个数：对不上的表现是"看着在框里、就是解不出来"）。
   这里这个值只是没跑脚本时的兜底，对应"没有变焦"的 SCAN_CROP_BASE=0.667 ——
   双指放大时框会跟着收小，那是脚本改的，不走这里。 */
.scan-frame .corners {
  position: absolute;
  inset: 16.67%;
  border: 2px solid var(--primary);
  border-radius: var(--radius-sm);
  box-shadow: 0 0 0 100vmax rgba(0, 0, 0, 0.35);
  pointer-events: none;
}

.scan-frame .hint {
  position: absolute;
  bottom: var(--gap);
  left: 0;
  right: 0;
  text-align: center;
  color: #fff;
  font-size: var(--fs-sm);
  text-shadow: 0 1px 4px #000;
  pointer-events: none;
}

/* 闪光灯开关。浮在取景框**右下角**（右上角那一片留给画面本身），
   且只在摄像头真的报出 torch 能力时才出现 —— 摆一个点了没反应的按钮比没有更糟。

   底色要够实、还要描一圈边：取景框在暗处就是一片黑，
   单靠 rgba(0,0,0,.55) 的底在这种画面上等于没有底，只剩几个白字飘着 */
.scan-frame .scan-torch {
  position: absolute;
  right: var(--gap-sm);
  bottom: var(--gap-sm);
  padding: 6px 14px;
  border: 1px solid rgba(255, 255, 255, 0.45);
  border-radius: 999px;
  background: rgba(0, 0, 0, 0.78);
  color: #fff;
  font-size: var(--fs-sm);
}

.scan-frame .scan-torch.on {
  background: var(--primary);
}

/* 变焦倍数。只在捏合时露一下（脚本控制 .on，900ms 后自己淡走）——
   手势没有"档位"这种可见的东西，不给个数就没有任何反馈。
   摆**顶部居中**：右下角是闪光灯、底部中间是那句提示，都不打架 */
.scan-frame .scan-zoom {
  position: absolute;
  top: var(--gap-sm);
  left: 50%;
  transform: translateX(-50%);
  padding: 4px 12px;
  border: 1px solid rgba(255, 255, 255, 0.45);
  border-radius: 999px;
  background: rgba(0, 0, 0, 0.78);
  color: #fff;
  font-size: var(--fs-sm);
  font-variant-numeric: tabular-nums;   /* 数字位数变化时宽度别跳 */
  opacity: 0;
  transition: opacity 0.15s ease;
  pointer-events: none;
}

.scan-frame .scan-zoom.on {
  opacity: 1;
}

.scan-placeholder {
  color: var(--text-dim);
  font-size: var(--fs-sm);
  text-align: center;
  padding: var(--gap);
  display: grid;
  gap: var(--gap-sm);
  justify-items: center;
}

/* 扫码之后那个弹层里的两行字，**都居中**：
   大字是「序列号：<MAC>」（让用户确认要绑的是哪台），小字是「输入匹配码」。
   不给别的说明 —— 弹层是给动作的，不是给读的 */
.scan-mac {
  font-size: var(--fs-lg);
  font-weight: 600;
  letter-spacing: 0.03em;
  text-align: center;
  word-break: break-all;
}

.scan-prompt {
  /* 比正文略大：它是这一屏**要用户去做的那件事**，原来按小字排太小了 */
  font-size: var(--fs-base);
  color: var(--text-dim);
  text-align: center;
}

.scan-actions {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: var(--gap-sm);
}

.scan-actions .btn-block {
  grid-column: auto;
}

/* 只有一个动作时**居中**摆。
   两列网格里单放一个会贴在左半边、右半边空着一块，看着像漏了一个按钮。
   宽度取与"两个并排"时同样的一列宽（半行），所以尺寸和别处一致；`.btn-block`
   的 width:100% 在这里被更具体的选择器盖掉。 */
.scan-actions.single .btn {
  grid-column: 1 / -1;
  justify-self: center;
  width: 50%;
}

/* 用图片文件解码（桌面浏览器没有摄像头时的主要测试路径） */
.scan-file-input {
  display: none;
}

/* --------------------------------------------------------------------------- 设备详情 */

.device-head {
  display: grid;
  grid-template-columns: auto 1fr auto;
  align-items: center;
  gap: var(--gap);
  margin-bottom: var(--gap);
}
/* 中间那一格要能被压缩：设备名 + 到期标签不能把网格顶宽 */
.device-head > div { min-width: 0; }

.device-head .dev-icon {
  font-size: 40px;
  line-height: 1;
}

/* 选角色弹层里被选中的那一行。光靠一个 ✓ 太不显眼 —— 而这里是"改权限"，
   选错就是把人的权限改错了，选中态必须一眼看得出来 */
.row.role-picked {
  background: var(--surface-2);
  border-radius: var(--radius-sm);
  padding-left: var(--gap-sm);
  padding-right: var(--gap-sm);
}

.row.role-picked .row-label,
.row.role-picked .row-value {
  color: var(--primary);
  font-weight: 600;
}

/* 管理员在页头的成员管理入口：徽标那一格整体可点。
   button 自带边框与居中排版，这里一并抹掉，只留下可点区域和一个引导符 */
.device-head .dev-entry {
  display: flex;
  align-items: center;
  gap: var(--gap-xs);
  padding: 0;
  border: 0;
  background: none;
  color: inherit;
  font: inherit;
}

/* 权限等级与在线状态并排放在页头右槽。窄屏上宁可占两行，也别让其中一个
   被挤到网格第二行去（那样会跟设备名错位） */
.device-head .dev-badges {
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: var(--gap-xs);
}

.device-head .dev-title-row {
  display: flex;
  align-items: baseline;
  gap: var(--gap-sm);
  min-width: 0;
}
.device-head .dev-title-row .dev-title {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* 名称后面的到期时间（点进续费页）；宽限期黄、停机红，与横幅一致 */
.service-expiry {
  flex: 0 0 auto;
  padding: 2px 8px;
  border: 1px solid var(--border);
  border-radius: 999px;
  background: var(--surface-2);
  color: var(--text-dim);
  font: inherit;
  font-size: var(--fs-xs);
  white-space: nowrap;
  cursor: pointer;
}
.service-expiry.warn {
  color: var(--st-connecting);
  border-color: var(--st-connecting);
  background: var(--warn-bg);
}
.service-expiry.err {
  color: var(--st-error);
  border-color: var(--st-error);
  background: var(--err-bg);
}

/* --------------------------------------------------------------------------- 续费服务 */
.pay-page {
  max-width: 560px;
  margin: 0 auto;
  display: grid;
  gap: var(--gap);
}
.pay-head {
  padding: var(--gap);
  display: grid;
  gap: 6px;
}
.pay-device { font-size: var(--fs-lg); font-weight: 600; }
.pay-note { color: var(--text-dim); font-size: var(--fs-xs); }
.plan-list { display: grid; gap: var(--gap-sm); }
.plan-card {
  display: flex;
  align-items: center;
  gap: var(--gap-sm);
  width: 100%;
  padding: 12px 14px;
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  background: var(--surface);
  color: var(--text);
  font: inherit;
  text-align: left;
  cursor: pointer;
}
.plan-card.active { border-color: var(--primary); background: var(--primary-dim); }
.plan-name { font-weight: 600; }
.plan-days { color: var(--text-dim); font-size: var(--fs-sm); }
.plan-price { margin-left: auto; color: var(--primary); font-weight: 600; }
.pay-status { min-height: 20px; font-size: var(--fs-sm); }
.pay-actions { padding-top: var(--gap-sm); }

.device-head .dev-sub {
  font-size: var(--fs-xs);
  color: var(--text-dim);
}

/* --------------------------------------------------------------------------- 设置页 */

.settings-page {
  max-width: 560px;
  margin: 0 auto;
  display: grid;
  /* 卡片之间挨近（用户 2026-10-18 两次提"太宽松"）：10px → 8px，
     再加上单行卡片上下各 2px 的留白，两行之间实际只隔 12px */
  gap: 8px;
}

/* 设置页（我的 / 账户 / 关于 / 语言 / 文件管理）里**不摆右侧那个 `›`**（用户 2026-10-18 定）：
   整片卡片本身就是可点的，引导符在这里只是视觉噪音。规则只写一处，各页自动一致。 */
.settings-page .row-go {
  display: none;
}

/* ⚠️ 设置页是 grid（gap 管间距），而 `.card + .card` 还有一条 `margin-top` —— 两者会**叠加**：
   上面量出来卡片间实际是 20px（8 的 gap + 14 的 margin）。这里把 margin 归零，只留 gap。 */
.settings-page .card + .card {
  margin-top: 0;
}

/* 「我的」的单行入口卡片（主题/语言/文件管理/关于）：一行一条，上下留白收窄，
   字大一号 —— 原来的卡片留白是给"一块内容"的，套在单行上就显得又高又空。 */
.entry-card {
  padding: 2px var(--gap);
}

.entry-card .row .row-label {
  font-size: var(--fs-lg);
}

.entry-card .row .row-value {
  font-size: var(--fs-base);
}

/* 行里的副标题：标签那格改成两行（主文字 + 一句小字）。
   ⚠️ `.row-wide .row-label` 本来是单行截断的，这里要放回来，否则小字会被切掉 */
.row-label.has-note {
  display: flex;
  flex-direction: column;
  gap: 2px;
  white-space: normal;
  overflow: visible;
}

.row-note {
  font-size: var(--fs-xs);
  color: var(--text-dim);
  font-weight: 400;
  line-height: 1.35;
}

/* 「退出登录」：可逆但敏感 —— 白底红字、独立一行（中文 App 里最常见的画法）。
   与"危险操作"（红底填充）分开：那个是不可恢复的，这个只是登出。 */
.btn-quiet-danger {
  color: var(--st-error);
  background: var(--surface);
  border: 1px solid var(--border);
}

/* 「商务合作」页的能力条目：标题 + 一段说明 —— 是"介绍"，不是可点的行，所以不带 › */
.biz-title {
  font-size: var(--fs-base);
  font-weight: 600;
  margin-bottom: var(--gap-xs);
}

.biz-body {
  font-size: var(--fs-sm);
  color: var(--text-dim);
  line-height: 1.55;
}

/* 「关于」页底部那行版权：小字、居中，不占一张卡片 */
.about-foot {
  text-align: center;
  font-size: var(--fs-xs);
  color: var(--text-dim);
  padding: var(--gap) 0;
}

/* 语言列表里"当前选中"那一行：标签上主色，一眼看出选的是哪个 */
.row.is-current .row-label {
  color: var(--primary);
  font-weight: 600;
}

.user-card {
  display: flex;
  align-items: center;
  gap: var(--gap);
}

.user-card .avatar {
  width: 52px;
  height: 52px;
  border-radius: 50%;
  background: var(--primary-dim);
  border: 1px solid var(--primary);
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: var(--fs-lg);
  font-weight: 600;
  color: var(--primary);
}

.version-line {
  text-align: center;
  font-size: var(--fs-xs);
  color: var(--text-dim);
  padding: var(--gap-sm);
}

/* ------------------------------------------------------ 设备不在线时整页灰掉

   判据在 pages/device.js 的 `paintConnection`。这里只管"看着不像还在读数"：
   数值本身由 `App.controls.reset()` 清回 `-`（那件事 CSS 做不了）。

   两件都要灰的道理是一样的 —— 灰 = 不知道：设备掉线时最后那一帧的数字
   不该还挂在那里装作是当前值；连不上服务器时那个绿点更是假的
   （用户报过"断网时列表还一片绿"，见 PROGRESS）。 */

.page.is-offline .status-value,
.page.is-offline .status-label,
.page.is-offline .readonly .val,
.page.is-offline .readonly .unit,
.page.is-offline .device-head .dev-title,
.page.is-offline .device-head .dev-sub {
  color: var(--text-dim);
}

/* 指示灯一律灰：`shelfCooling*` 那些隔板灯平时是红的（亮=通），
   设备不在时它们既不是亮也不是灭，是**不知道** */
.page.is-offline .dot.online,
.page.is-offline .status-dot.on {
  background: var(--st-disconnected);
  border-color: var(--st-disconnected);
}

/* ------------------------------------------------------ 配方编辑页（通用）

   表格是照模板的 `recipe` 区块渲染的（见 js/pages/recipe-edit.js），
   所以这一块只定义"表格"这个形状本身，不认识任何具体字段 ——
   换个产品（反应釜的段字段不一样）这些样式原样能用。 */

.recipe-table {
  width: 100%;
  border-collapse: collapse;
  /* 数字等宽：一列数竖着扫下来不会左右抖 */
  font-variant-numeric: tabular-nums;
  /* **列宽按固定算法分**（用户 2026-10-16：原来"模式"那列几乎没了）。
     不写它时浏览器按内容自适应：段号列被"段"这个字占住、"模式"里的
     「定时/斜率/恒温」最宽却排在最后，于是一行行挤下来那一列最先被压扁。
     固定之后：只有段号列声明了宽度，**其余几列平分剩下的** ——
     温度/时/分/模式 一样宽，改哪个字段都不用回头调样式 */
  table-layout: fixed;
}

.recipe-table th {
  padding: 6px 4px;
  font-size: var(--fs-xs);
  font-weight: 500;
  color: var(--text-dim);
  text-align: center;
  border-bottom: 1px solid var(--border);
}

.recipe-table td {
  padding: 2px;
  border-bottom: 1px solid var(--border);
}

/* 段号那一列：窄、居中、灰 —— 它是行号，不是可编的内容 */
.recipe-table .seg-index {
  width: 2.2em;
  text-align: center;
  font-size: var(--fs-sm);
  color: var(--text-dim);
}

/* 格子里的输入格。**要小** —— 一屏多看得见几行，几十行的表才滚得动 */
.cell-input {
  width: 100%;
  min-height: auto;
  padding: 6px 4px;
  border: 1px solid transparent;
  border-radius: var(--radius-sm);
  background: var(--surface-2);
  color: var(--text);
  font-family: inherit;
  font-size: var(--fs-base);
  text-align: center;
}

/* 聚焦时才给边框：一屏几十个格子，全是框会花；
   没框又看不出哪里能改，所以只在编辑中那格亮出来 */
.cell-input:focus {
  outline: none;
  border-color: var(--primary);
  background: var(--surface);
}

/* 编辑页头部（排布见 recipe-edit.js 的 renderEditor）：
   一行 = 配方号 + 配方名，一行 = 配方级参数（总段数 / 启动真空段）。
   参数那一行**字体大一号、不写标签行** —— 它是进这一页要先定下来的两件事 */
.recipe-head {
  display: flex;
  flex-direction: column;
  gap: var(--gap-sm);
}

.recipe-head-row {
  display: flex;
  align-items: center;
  gap: var(--gap);
}

/* 「配方号 9」：**只报号，不写机型**（设备页头已经有了） */
.recipe-head-no {
  flex: 0 0 auto;
  font-size: var(--fs-base);
  color: var(--text-dim);
}

/* 配方名占满剩下的宽度；输入框默认居中，名字要靠左 */
.recipe-head-name {
  flex: 1 1 auto;
  text-align: left;
}

.recipe-params {
  gap: var(--gap-lg);
}

.recipe-param {
  display: flex;
  align-items: center;
  gap: var(--gap-xs);
  flex: 1 1 0;
}

.recipe-param .recipe-param-label {
  font-size: var(--fs-lg);
  /* 「启动真空段」五个字在手机上会折成两行（实测），标签一折那一行就高矮不一 */
  white-space: nowrap;
}

.recipe-param .cell-input {
  max-width: 4.5em;
  font-size: var(--fs-lg);
}

/* 设备详情页：状态还没读到时的占位（见 device.js 的 paintReady） */
.device-loading {
  padding: var(--gap-xl) var(--gap);
  text-align: center;
  color: var(--text-dim);
  font-size: var(--fs-base);
}

/* 模板限定的固件区间不包含设备当前版本：控件不渲染，页中只说明范围（见 device.js） */
.firmware-block {
  padding: var(--gap-lg) var(--gap);
  text-align: center;
  color: var(--text-dim);
}
.firmware-block .big {
  margin-bottom: var(--gap-sm);
  color: var(--st-connecting);
  font-size: 34px;
}
.firmware-block-title {
  margin-bottom: var(--gap-sm);
  font-size: var(--fs-lg);
  font-weight: 600;
  color: var(--text);
}

.recipe-hint {
  margin-top: var(--gap);
  font-size: var(--fs-sm);
  text-align: center;
}

/* 「我的」页里那几个破坏性入口（注销 / 退出）的文字用错误色 ——
   与单台设备的「退出设备」同一个观感，点之前就能看出是哪一类动作。 */
.row.danger .row-label {
  color: var(--st-error);
}
