/* ==========================================================================
   Eastudy V3 — 共享组件层（base.css）

   **只放两个 shell 都要用的东西。** 学员端 (index.html) 和控制端
   (admin/index.html) 是两个入口，但按钮、徽章、标签这些"控件"是同一种控件 ——
   控制端的主按钮和学员端的主按钮没有理由长得不一样。

   为什么单独开一个文件，而不是让 admin.html 也引 app.css：
   app.css 里还有 2000 多行是学员端页面专有的（播放器、词卡、课程栅格、
   底部导航）。让控制端把它整份吞下去，等于控制端每次调样式都要先翻一遍
   跟它无关的两千行，而且学员端的类名会顺带把控制端的元素也命中
   （`.chip` 两边都写着，一改就串）。

   载入顺序（两边一致）：
       tokens.css  →  themes-12.css  →  base.css  →  app.css / admin.css
                                        ↑
                    组件默认样式在这里；app/admin 只管自己那部分的**差异**

   所以：改按钮 = 改这一个文件，学员端和控制端同时生效。
        改某个控件在**某一端**的特殊表现 = 改 app.css 或 admin.css 里的覆盖。
        想加一个两端都要的新控件 = 加在这里。

   颜色一律走 token（--brand-accent / --danger / --fg …），本文件里
   **不出现字面色值**，除了那些刻意与主题无关的半透明高光/阴影。
   ========================================================================== */

/* ============================================================ 按钮 */

/* 底座 + 变体 + 尺寸/布局。变体只改底色、边框色、字色；内边距、圆角、
   过渡、以及材质的两层伪元素都在 .btn 里，变体不重复声明。

   `.btn` 上有一条容易被当成多余的规则：`isolation: isolate` 加 `z-index: 0`。
   这不是为了层级排序，是为了让 `::before` / `::after` 上的 `z-index: -1`
   有一个**自己的**堆叠上下文可退 —— 否则它们会一路退到页面层，被按钮的
   background-color 盖住，材质就整个消失了。 */
.btn {
  position: relative;
  isolation: isolate;
  z-index: 0;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
  min-height: var(--tap-min);
  padding: 0 16px;
  font: inherit;
  font-size: var(--text-sm);
  font-weight: 500;
  color: var(--fg);
  background: var(--surface);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-md);
  cursor: pointer;
  white-space: nowrap;
  transition: background var(--motion-fast) var(--ease), border-color var(--motion-fast) var(--ease),
              box-shadow var(--motion) var(--ease), transform var(--motion-fast) var(--ease),
              opacity var(--motion-fast) var(--ease);
}
/* 焦点圈放在这里，是因为下面的主/危险变体都用了 `border-color: transparent`，
   靠边框变色表示焦点的做法在那两个变体上根本看不见。 */
.btn:focus-visible {
  outline: 2px solid var(--focus-ring);
  outline-offset: 2px;
}
.btn:hover:not(:disabled) { background: var(--surface-hover); border-color: var(--border-hover); }
.btn:active:not(:disabled) { transform: translateY(1px); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }

/* ------------------------------------------------------------------ 实心按钮材质

   参考稿里的实心按键（主按钮、危险按钮）不是一块平色，是一层**缎面**：

     1. 横向明暗 —— 左缘偏亮、中段本色、右缘偏深，像一块布料被单侧打光。
     2. 纵向顶光 —— 上缘一道柔和的亮边，下缘一道几乎看不见的接触暗边。
     3. 斑驳纹理 —— 整面覆盖极低浓度的噪点，让它看起来是**有材料**的面，
        而不是 UI 软件画出来的矩形。
     4. 浮雕字 —— 字压在这层缎面上时带一点同色系的下沉阴影。

   这四层全部由 `--btn-*` 系列的 token 驱动，而这些 token 是 themes-12.css
   里**用 color-mix 从 --primary / --surface / --fg 现算出来的**。所以这段
   代码在 12 套主题 × 明暗两模式下都成立，加第 13 套主题不需要动这里一行 ——
   如果把渐变色值写死在这儿，就是 24 组色值乘以 4 个层次，必然有一次会漏。

   为什么用 `::before` / `::after` 而不是一层叠一层的 background-image：
   噪点那条要的是 feTurbulence 生成的**不规则**图形，CSS 渐变画不出来，只能
   靠 `mask-image` 贴一段内联 SVG。而 mask 会作用于元素上**所有**背景层，
   所以它必须待在自己的伪元素里，不能和渐变共用一层。

   两条约束，改的时候别踩：
     1. `background-image` 与 `background-color` 必须分开写。用 `background`
        简写会把上一条规则里的 `background-color` 一起覆盖掉，于是变色主题里
        按钮退回成浏览器默认灰。这是这个文件里踩过的坑。
     2. 深色模式下顶光与接触阴影的方向要整体翻过来 —— 在暗底上「加深」是看
        不见的。这件事已经在 themes-12.css 的 `html[data-mode="dark"]` 块里
        做完了（翻的是 token，不是这段规则），所以这里没有 `html[data-mode]`
        前缀的重复声明。 */

/* 唯一的字面量：噪点图。它描述的是「颗粒的分布形状」，与主题色无关 ——
   颜色由它上层铺的 `--btn-mottle` 决定。feTurbulence 出的是灰阶噪声，
   最后一行 feColorMatrix 把它转成 alpha 通道（R 行取原图 alpha 通道当输出
   alpha），这样 mask 才是「哪里露出来」而不是「露什么颜色」。
   baseFrequency 取 0.075/0.095 让颗粒在按钮这种小尺寸上不至于碎成沙。 */
.btn--primary,
.btn--danger { --btn-grain: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='257' height='78' viewBox='0 0 257 78'%3E%3Cfilter id='s' x='0' y='0' width='100%25' height='100%25'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.075 .095' numOctaves='2' seed='17'/%3E%3CfeGaussianBlur stdDeviation='1.1'/%3E%3CfeColorMatrix type='matrix' values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 .638 2.145 .217 0 -1'/%3E%3C/filter%3E%3Crect width='257' height='78' filter='url(%23s)'/%3E%3C/svg%3E"); }

.btn--primary,
.btn--danger {
  position: relative;
  /* 横向明暗（90deg）+ 纵向顶光（180deg）叠在一起。
     纵向那层写在前面，所以它盖在横向明暗之上 —— 参考稿里顶光是被布料本身
     的横向明暗影响的，顺序反了会得到一块塑料。 */
  background-image:
    linear-gradient(180deg, var(--btn-crown), transparent 44%, var(--btn-foot)),
    linear-gradient(90deg, var(--btn-light), var(--btn-mid) 48%, var(--btn-dark) 92%);
  /* 三段阴影分工：内顶缘高光 → 内底缘暗边 → 外落下的一点接触影。
     外影很浅，因为参考稿里按钮是**坐在**页面上而不是浮着。 */
  box-shadow:
    inset 0 1px 1px var(--btn-rim),
    inset 0 -1px 2px var(--btn-foot),
    0 1px 3px var(--btn-contact);
  text-shadow: 0 1px 1px var(--btn-relief);
  transition: background-color var(--motion-fast) var(--ease), border-color var(--motion-fast) var(--ease),
              box-shadow var(--motion) var(--ease), transform var(--motion-fast) var(--ease);
}

/* 局部起伏。这一层容易被当成装饰省掉，省掉之后按钮会明显"变塑料" ——
   它做的不是加光，是**打破渐变本身的规律性**。

   四颗椭圆暗斑的位置（31%/21%、48%/72%、67%/28%、84%/84%）和一道 126deg 的
   斜向反光带都是从参考稿上量下来的，不是随手摆的：横向明暗是一条规则的
   90deg 斜坡，规则得能一眼看出是 CSS 渐变；这几颗斑块把斜坡切碎，眼睛读到
   的才是"这块面受光不匀"。

   注意它们是**各自独立的椭圆**，不要合并成一条贯穿的斜向高光。合并之后就是
   教科书上的"光泽条"，比不加还假。 */
.btn--primary::before,
.btn--danger::before {
  content: "";
  position: absolute;
  inset: -1px;
  z-index: -1;
  pointer-events: none;
  border-radius: inherit;
  background-image:
    radial-gradient(ellipse 8% 48% at 31% 21%, var(--btn-dimple), transparent 100%),
    radial-gradient(ellipse 6% 43% at 48% 72%, var(--btn-dimple), transparent 100%),
    radial-gradient(ellipse 7% 52% at 67% 28%, var(--btn-dimple), transparent 100%),
    radial-gradient(ellipse 9% 48% at 84% 84%, var(--btn-haze), transparent 100%),
    linear-gradient(126deg, transparent 19%, var(--btn-haze) 27%, transparent 35%,
                    transparent 61%, var(--btn-haze) 68%, transparent 76%);
}

/* 斑驳纹理。`inset: -1px` 而不是 `0`：让噪点铺满边框那一圈，
   否则按钮四周会出现一条没有材质的细缝，在深色主题下尤其明显。 */
.btn--primary::after,
.btn--danger::after {
  content: "";
  position: absolute;
  inset: -1px;
  z-index: -1;
  pointer-events: none;
  border-radius: inherit;
  background: var(--btn-mottle);
  mask-image: var(--btn-grain);
  mask-size: 100% 100%;
  mask-repeat: no-repeat;
}

.btn--primary { color: var(--accent-ink); background-color: var(--brand-accent); border-color: var(--brand-accent); }
/* 悬停只改 background-color 是**看不见**的：上面那两层渐变铺满整个盒子，
   把底色整块盖住了。所以悬停必须动渐变本身 —— 这里把缎面的三段各往
   --primary-hover 压一档，等于整块布被压暗一层，而不是换一个底色。
   （这是移植参考稿时暴露出来的老问题，不是新加的装饰。） */
.btn--primary:hover:not(:disabled) {
  background-color: var(--brand-accent-hover);
  border-color: var(--brand-accent-hover);
  background-image:
    linear-gradient(180deg, var(--btn-crown), transparent 44%, var(--btn-foot)),
    linear-gradient(90deg,
      color-mix(in srgb, var(--btn-light) 88%, var(--primary-hover)),
      color-mix(in srgb, var(--btn-mid) 92%, var(--primary-hover)) 48%,
      color-mix(in srgb, var(--btn-dark) 88%, var(--primary-hover)) 92%);
  box-shadow:
    var(--btn-hover-line),
    inset 0 1px 1px var(--btn-rim),
    inset 0 -1px 2px var(--btn-foot),
    0 2px 5px var(--btn-contact);
}

/* 按下去：顶光翻成内阴影、外影收掉、整体下沉 1px。
   三件事必须同时发生才读得出「按进去了」—— 只做下沉是一个位移，
   只做阴影变化是一块变色的矩形。 */
.btn--primary:active:not(:disabled),
.btn--danger:active:not(:disabled) {
  transform: translateY(1px);
  box-shadow:
    var(--btn-press-line),
    inset 0 2px 3px var(--btn-contact),
    inset 0 -1px 1px var(--btn-rim);
  text-shadow: none;
}
/* 按下时局部起伏与纹理一起收——凹进去的面受光更少，斑块和噪点留在上面会
   跟"按进去了"这个读数打架。 */
.btn--primary:active:not(:disabled)::before,
.btn--danger:active:not(:disabled)::before { opacity: .35; }
/* 按下时纹理也压淡 —— 凹陷的面受光更少，噪点会显得比平面时更抢眼。 */
.btn--primary:active:not(:disabled)::after,
.btn--danger:active:not(:disabled)::after { opacity: .55; }

/* 禁用时把整套缎面拆掉，只留 `.btn:disabled` 的 0.5 透明度。
   理由不是省事：缎面读作"这块材质在发光"，而禁用按钮恰恰**不该**发光。
   参考稿里禁用按钮就是一块哑色平面，照做。 */
.btn--primary:disabled,
.btn--danger:disabled {
  background-image: none;
  box-shadow: none;
  text-shadow: none;
}
.btn--primary:disabled::before, .btn--primary:disabled::after,
.btn--danger:disabled::before,  .btn--danger:disabled::after { display: none; }

/* 键盘聚焦时把缎面上再叠一道内发光，因为 focus 圈在深色主题的实心按钮
   外侧对比度不够。 */
.btn--primary:focus-visible,
.btn--danger:focus-visible {
  box-shadow:
    inset 0 1px 1px var(--btn-rim),
    inset 0 0 0 2px var(--brand-ring),
    0 1px 3px var(--btn-contact);
}

.btn--danger { color: #fff; background-color: var(--danger); border-color: var(--danger); }
/* 危险色不跟主题走（themes-12.css 里说明过理由），所以它的缎面也用不上
   --btn-* 那批从 --primary 算出来的值，就地覆盖成按 --danger 算的一套。 */
.btn--danger {
  --btn-light:   color-mix(in srgb, var(--danger) 70%, var(--surface));
  --btn-mid:     var(--danger);
  --btn-dark:    color-mix(in srgb, var(--danger) 88%, var(--bg));
  --btn-relief:  color-mix(in srgb, var(--danger) 45%, var(--bg));
}
.btn--danger:hover:not(:disabled) { background-color: color-mix(in srgb, var(--danger) 88%, var(--fg)); border-color: color-mix(in srgb, var(--danger) 88%, var(--fg)); }

.btn--ghost { background: transparent; border-color: transparent; color: var(--fg-muted); }
.btn--ghost:hover:not(:disabled) { background: var(--surface-hover); color: var(--fg); }
.btn--ghost:active:not(:disabled) { transform: translateY(1px); }
.btn--ghost:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; }

/* 减少动效时只砍**过渡**，不砍材质。
   缎面本身是静态外观，不是动画 —— 关掉它等于把按钮降级成一块平色，
   而减少动效的用户要的是「别动」，不是「变丑」。按下时的下沉位移一并取消，
   因为它同样是一段位移，语义上属于动效。 */
@media (prefers-reduced-motion: reduce) {
  .btn { transition: none; }
  .btn:active:not(:disabled),
  .btn--primary:active:not(:disabled),
  .btn--danger:active:not(:disabled),
  .btn--ghost:active:not(:disabled) { transform: none; }
}

.btn--sm { min-height: 30px; padding: 0 10px; font-size: var(--text-xs); }
.btn--block { width: 100%; }

/* ============================================================ 分段切换 .tabs / .tab

   从 app.css 迁进来。它们不是学员端专有控件 —— 管理端「学员管理」页用的是
   同一套标记，而 admin 页只加载 tokens/themes/base/admin，从不加载 app.css，
   所以那两个 tab 一直是浏览器默认的裸按钮。组件属于谁，看的是标记被谁用，
   不是看它最早写在哪个文件里。

   材质沿用按键那套 --btn-*（同 themes-12.css），理由和进度条一样：
   参考稿里它们是同一块料子，各写各的，以后调主题必然调漏一处。

   选中态是**反向**的横向渐变（暗→中→亮），和 .btn 的左亮右暗正好相反。
   这不是笔误：按钮是凸起的独立块，左边迎光；而 tab 是嵌在一条槽里的分段，
   选中那块看起来该是从槽里浮起来并微微右倾，所以亮端在右。 */

.tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--border); }
.tab {
  position: relative;
  padding: 8px 14px;
  font: inherit; font-size: var(--text-sm);
  color: var(--fg-muted);
  background: transparent; border: none;
  border-bottom: 2px solid transparent;
  cursor: pointer;
  transition: color var(--motion) var(--ease), background-color var(--motion) var(--ease);
}
.tab:hover:not(.is-active) { color: var(--fg); background: var(--surface-hover); }
.tab.is-active {
  color: var(--brand-accent);
  border-bottom-color: transparent;
  font-weight: 500;
}
/* 选中态的下划线换成一小段缎面短杠，而不是整条 2px 平色线。
   它是 ::after 而不是 border：border 会被 .tabs 的 border-bottom 在视觉上
   接成一条通长的线，而参考稿里选中的标记是**断开的一小截**。 */
.tab.is-active::after {
  content: "";
  position: absolute;
  left: 10%; right: 10%; bottom: -2px;
  height: 3px;
  border-radius: var(--radius-pill);
  background-color: var(--brand-accent);
  background-image: linear-gradient(90deg, var(--btn-dark), var(--btn-mid) 48%, var(--btn-light) 92%);
  box-shadow: 0 1px 2px var(--btn-contact);
}
.tab:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: -2px; border-radius: var(--radius-sm); }

/* ============================================================ 标签 / 徽章

   三件套: .chip（浅底小标签）、.badge（状态徽章）、.pill（计数药丸）。

   控制端原先完全没有这三样 —— 队列状态、难度、分级在表格里是裸文本,
   和学员端同一份数据的呈现完全是两回事。抽到这里之后两端同一份数据
   长同一个样子, 这是"对齐"里最容易被忽略、又最容易被用户看出来的一半。 */

.chip {
  display: inline-flex;
  align-items: center;
  padding: 2px 8px;
  font-size: var(--text-xs);
  color: var(--fg-muted);
  background: var(--surface-sunken);
  border-radius: var(--radius-pill);
}

.chip--ok { color: var(--success-fg); background: var(--success-bg); }
.chip--warn { color: var(--warn-fg); background: var(--warn-bg); }
.chip--danger { color: var(--danger-fg); background: var(--danger-bg); }
.chip--muted { color: var(--fg-subtle); }
.chip--level { color: var(--level-fg); background: var(--level-bg); font-weight: 600; }
.chip--tag { color: var(--tag-fg); background: var(--tag-bg); }

.badge {
  display: inline-flex;
  align-items: center;
  padding: 2px 8px;
  font-size: var(--text-xs);
  font-weight: 500;
  border-radius: var(--radius-pill);
  white-space: nowrap;
}

.badge--ok { color: var(--success-fg); background: var(--success-bg); }
.badge--warn { color: var(--warn-fg); background: var(--warn-bg); }
.badge--danger { color: var(--danger-fg); background: var(--danger-bg); }
.badge--info { color: var(--info-fg); background: var(--info-bg); }

.pill {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 20px;
  height: 20px;
  padding: 0 6px;
  font-size: var(--text-xs);
  font-weight: 600;
  border-radius: var(--radius-pill);
  background: var(--surface-sunken);
  color: var(--fg-muted);
}

/* ============================================================ 表单控件底座

   输入框/下拉/文本域在两端是同一颗控件, 差异只在宽度和容器里。
   原先控制端是靠各自页面里的行内样式撑的。 */
.field { display: flex; flex-direction: column; gap: 6px; }
.field__label { font-size: var(--text-xs); font-weight: 500; color: var(--fg-muted); }
.field__hint { font-size: var(--text-xs); color: var(--fg-subtle); }
.field__error { font-size: var(--text-xs); color: var(--danger); }
.field__req { color: var(--danger); margin-left: 2px; }

/* 带装饰位的输入框外壳。

   左图标和右眼睛按钮都是**绝对定位**的, 不是 flex 里的兄弟节点 —— 因为
   输入框本身要撑满整个宽度, 装饰位得压在它上面。用 flex 排列的话输入框
   会被挤窄, 那个宽度还会随图标有无而变化, 同一张表单里两行输入框的左边缘
   就对不齐了。

   内边距由外壳加到 input 上(见下面两条), 于是"有没有装饰"只影响 input
   自己的 padding, 不影响它占的盒子 —— 这正是对齐需要的不变量。 */
.field__control { position: relative; display: flex; align-items: center; }
.field__control--icon .input { padding-left: 38px; }
.field__control--reveal .input { padding-right: 42px; }

.field__icon {
  position: absolute;
  left: 12px;
  display: flex;
  color: var(--fg-subtle);
  pointer-events: none;   /* 图标压在输入框上, 不挡点击 —— 点它应该聚焦输入框 */
}
.field__control .input:focus-visible ~ .field__reveal { color: var(--fg); }

.field__reveal {
  position: absolute;
  right: 6px;
  display: flex;
  align-items: center;
  justify-content: center;
  /* 触控目标不足 44px 时用 padding 把命中区撑开, 视觉大小不变 —— 眼睛图标
     本身直径只有 18px, 直接当按钮点起来会经常点空。 */
  padding: 8px;
  color: var(--fg-subtle);
  background: none;
  border: 0;
  border-radius: var(--radius-sm);
  cursor: pointer;
  transition: color var(--motion-fast) var(--ease), background var(--motion-fast) var(--ease);
}
.field__reveal:hover { color: var(--fg); background: var(--surface-sunken); }
.field__reveal[aria-pressed='true'] { color: var(--brand-accent); }

.input,
.select,
.textarea {
  width: 100%;
  min-height: var(--tap-min);
  padding: 0 12px;
  font: inherit;
  font-size: var(--text-sm);
  color: var(--fg);
  background: var(--surface);
  border: 1px solid var(--border-base);
  border-radius: var(--radius-md);
  transition: border-color var(--motion-fast) var(--ease), box-shadow var(--motion-fast) var(--ease);
}
.textarea { padding: 10px 12px; min-height: 96px; resize: vertical; line-height: 1.5; }
.input:focus-visible,
.select:focus-visible,
.textarea:focus-visible {
  border-color: var(--border-hover);
  box-shadow: 0 0 0 3px var(--brand-ring);
  outline: none;
}
.input:disabled,
.select:disabled,
.textarea:disabled { opacity: 0.55; cursor: not-allowed; }
.input::placeholder,
.textarea::placeholder { color: var(--fg-subtle); }

/* 开关。两端都有"启用/停用"这类二态，用同一颗。 */
.switch {
  position: relative;
  display: inline-flex;
  align-items: center;
  flex: none;
  width: 40px;
  height: 22px;
  padding: 0;
  background: var(--surface-sunken);
  border: 1px solid var(--border-base);
  border-radius: var(--radius-pill);
  cursor: pointer;
  transition: background var(--motion-fast) var(--ease), border-color var(--motion-fast) var(--ease);
}
.switch::after {
  content: '';
  position: absolute;
  left: 2px;
  width: 16px;
  height: 16px;
  background: var(--surface);
  border-radius: 50%;
  box-shadow: 0 1px 2px rgba(0, 0, 0, .22);
  transition: transform var(--motion-fast) var(--ease);
}
.switch[aria-checked="true"] { background: var(--brand-accent); border-color: var(--brand-accent); }
.switch[aria-checked="true"]::after { transform: translateX(18px); }
.switch:disabled { opacity: 0.5; cursor: not-allowed; }

/* ============================================================ 表格

   学员端的表格已经在用这些类（生词本、学习记录），控制端更多 —— 视频管理、
   学员管理、处理队列、邀请码四页全是表格。原先它们两份都不完整：控制端
   连表头对齐都是浏览器默认的粗体左对齐，跟学员端同一张表长得不一样。

   `.is-right` / `.is-clickable` 是修饰类, 由页面在 <th>/<tr> 上手动加。 */

.table-wrap { overflow-x: auto; }
.table { width: 100%; border-collapse: collapse; font-size: var(--text-sm); }
.table th {
  text-align: left;
  padding: 10px 12px;
  font-size: var(--text-xs);
  font-weight: 500;
  color: var(--fg-muted);
  border-bottom: 1px solid var(--border);
  white-space: nowrap;
}
.table td { padding: 12px; border-bottom: 1px solid var(--border-subtle); vertical-align: middle; }
.table tr.is-clickable:hover { background: var(--surface-hover); cursor: pointer; }
/* 从「处理队列」跳进来的目标行（`?focus=<id>`）。用 ::before 画一道左侧色条
   而不是给 tr 加背景色：tr 的背景在部分浏览器里会被 td 的背景盖住，
   而 td 是透明的、改不了单行。色条画在行内，跟着主题色走，也不用动布局。 */
.table tr.is-focused { position: relative; background: var(--surface-hover); }
.table tr.is-focused > td:first-child { box-shadow: inset 3px 0 0 var(--brand-accent); }
.table tr.is-focused { transition: background var(--motion-slow) var(--ease); }
@media (prefers-reduced-motion: reduce) {
  .table tr.is-focused { transition: none; }
}
.table__empty, .table__loading { text-align: center; padding: var(--space-6); color: var(--fg-subtle); }
.table .is-right { text-align: right; }

/* 单元格里的"标题 + 副标题"两行结构与"一排徽章"结构。 */
.cell-title { display: flex; flex-direction: column; gap: 2px; }
.cell-title__main { font-weight: 500; }
.cell-title__sub { font-size: var(--text-xs); color: var(--fg-muted); }
.cell-badges { display: flex; gap: 4px; flex-wrap: wrap; }

/* ============================================================ 数据展示 */

/* 等宽数字。数字列右对齐 + 用等宽字形, 否则刷新页面时数字宽窄变化会让整列
   左右跳 —— 控制端的队列计数每秒都在变, 这个跳动尤其明显。 */
.num { font-variant-numeric: tabular-nums; text-align: right; }

/* 空态。学员端的 emptyState() 和控制端的表格空行共用同一副骨架。 */
.empty { display: flex; flex-direction: column; align-items: center; gap: 6px; padding: var(--space-7) var(--space-4); text-align: center; }
/* 字号只管字体图标那一路。icon() 出的是 SVG，宽高写死在属性上，
   所以这里用 em 把它的尺寸**跟同样的字号绑定**：两边不打架，
   以后要调大小仍然只改这一个值。 */
.empty__icon { font-size: 32px; color: var(--fg-subtle); line-height: 1; }
.empty__icon svg { width: 1em; height: 1em; display: block; }
.empty__title { font-size: var(--text-base); font-weight: 500; }
.empty__hint { font-size: var(--text-sm); color: var(--fg-muted); max-width: 380px; }

/* 空态 / 错误态在两端的外框一致，内容由各自的 emptyState()/errorState() 填。 */
.state {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--space-3);
  padding: var(--space-6) var(--space-4);
  text-align: center;
  color: var(--fg-muted);
}
.state__title { font-size: var(--text-md); font-weight: 600; color: var(--fg); }
.state__hint { font-size: var(--text-sm); max-width: 42ch; line-height: 1.6; }
