/* ============================================================
   山农课程表
   ============================================================ */

:root {
  --row-h: 78px;
  --head-h: 46px;
  --primary: #4f7df3;
  --primary-dark: #3a68dd;

  /*
   * 文字层级
   *
   * 这三个值被专门调暗过，别改回去。原来的 --text-3 是 #9ca3af，
   * 在浅底上的对比度只有 2.5:1 —— 远低于 WCAG AA 要求的 4.5:1，
   * 页脚、节次时刻、下载说明这些小字基本等于"看得见但读不清"。
   *
   * 现在按**较深的那档背景**（光斑中心，约 #dde5f6）算最差值：
   *   --text-2 ≈ 5.0:1，--text-3 ≈ 4.5:1   → 都过 AA。
   * 注意这两个值现在很接近，这是刻意的：小字要过 4.5:1 就只能压到这个
   * 深度，两者再拉开就必然有一档不达标。层级改由**字号和字重**去交代，
   * 不要靠回调颜色 —— 调回去就是在拿可读性换层次。
   */
  --text: #1f2937;
  --text-2: #57606e;
  --text-3: #5e6776;
  --line: rgba(17, 32, 66, .10);
  --bg: #e8edf9;

  /*
   * 关于 --card / --radius / --shadow / --shadow-lg
   *
   * 这四张「不透明表面」的令牌已经被 --glass-* 那组取代，全部删掉了。
   * 现在页面上的每一块面板都是玻璃，不存在"实心卡片"这个档位 ——
   * 留着一组语义是"实心"的旧令牌，下一个人很容易顺手用它写出一块
   * 和四周材质对不上的白板，而且不会有任何报错。
   * 需要面板时一律用 --glass-bg / --glass-radius / --glass-shadow。
   */

  /* ============================================================
     液态玻璃材质（Liquid Glass）
     ------------------------------------------------------------
     苹果 2025 的设计语言。一块"玻璃"由四层叠出来，缺一层就不像玻璃：

       1. 底色层   半透明的白，透出后面的背景 → 只看它是磨砂塑料
       2. 折射层   backdrop-filter 的 blur + saturate → 真正的"玻璃感"
       3. 边缘层   顶部内高光 + 一圈外描边 → 玻璃的厚度和倒角
       4. 镜面层   斜着扫过的一道柔光（见下面的 --sheen）→ "液态"

     关于 blur 的取值：14~26px 是甜区。太小像脏了，太大背景糊成一片、
     层次全丢，而且大半径模糊在手机上非常吃 GPU。
     saturate 必须 > 100%，否则透过玻璃的颜色会发灰、像蒙了层脏东西。

     关于不透明度：正文所在的面板不能低于 .55，否则深色背景（登录页那张
     照片）透上来会把文字对比度压到不可读。所以按"内容密度"分了三档：
     thin（浮层小控件）/ 默认（卡片面板）/ strong（密集表格、弹窗）。
     ============================================================ */

  --glass-bg: rgba(255, 255, 255, .60);
  --glass-bg-thin: rgba(255, 255, 255, .50);
  --glass-bg-strong: rgba(255, 255, 255, .78);
  --glass-bg-dark: rgba(24, 32, 48, .62);      /* 深色玻璃，给 toast 用 */

  --glass-blur: 20px;
  --glass-blur-thin: 12px;
  --glass-saturate: 190%;

  --glass-ring: 0 0 0 1px rgba(255, 255, 255, .62);          /* 外描边：亮边 */
  --glass-ring-in: inset 0 1px 0 rgba(255, 255, 255, .90);   /* 顶部内高光 */
  --glass-ring-in-b: inset 0 -1px 0 rgba(255, 255, 255, .34);/* 底部内反光 */
  --glass-edge: var(--glass-ring), var(--glass-ring-in), var(--glass-ring-in-b);

  --glass-shadow: 0 1px 2px rgba(17, 32, 66, .06), 0 12px 32px rgba(17, 32, 66, .13);
  --glass-shadow-lg: 0 2px 6px rgba(17, 32, 66, .08), 0 26px 64px rgba(17, 32, 66, .24);

  --glass-radius: 20px;
}

/*
 * 没有 backdrop-filter 时的兜底。
 *
 * Firefox 老版本（<103）、部分安卓 WebView、以及"减少透明度"系统开关
 * 都会关掉这个能力。此时玻璃只剩半透明底 + 描边，内容压在花哨的
 * 背景上会花掉。所以直接把玻璃提成接近实心 —— 玻璃感没了，但能读。
 * 这条一定要留着：宁可不好看，也不能让人看不清课表。
 */
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
  :root {
    --glass-bg: rgba(255, 255, 255, .93);
    --glass-bg-thin: rgba(255, 255, 255, .88);
    --glass-bg-strong: rgba(255, 255, 255, .96);
    --glass-bg-dark: rgba(24, 32, 48, .94);
  }
  .bg-mesh { display: none; }   /* 没有模糊，背景再花也没意义，反而干扰 */
}

* { margin: 0; padding: 0; box-sizing: border-box; }

/*
 * 让 hidden 属性真正生效。
 *
 * 浏览器默认样式里的 [hidden] { display: none } 属于「UA 样式表」，
 * 而 UA 样式表的优先级低于任何作者样式表 —— 也就是说，只要我们在下面
 * 给某个元素写了 display，hidden 属性就会被无声地顶掉。
 * 之前的 .modal-mask 就是这种情况：它写了 display:flex，
 * 于是 <div id="modalMask" hidden> 照样显示，点关闭也只是把属性写回去、
 * 完全没用，页面上就永远挂着一个关不掉的空白色弹窗。
 *
 * 加 !important 把这条规则提到最高，以后再有新元素踩同样的坑也不会中招。
 */
[hidden] { display: none !important; }

html, body { height: 100%; }

/*
 * 移动端基础设置
 *
 * 1) text-size-adjust：iOS 在横屏或窄栏里会自动放大正文字号（zoom out 后再放大），
 *    看起来就是"字忽大忽小"。关掉它，让 CSS 里写的字号说了算。
 * 2) tap-highlight-color：安卓/iOS 点可点元素时默认糊一层灰蓝色高亮块，
 *    又丑又和设计冲突。统一去掉，改用各组件自己的 :active 反馈。
 * 3) overflow-x: hidden：手机窄屏下只要有一个元素超宽（长单词、宽表格），
 *    整页就能左右拖动，非常难受。这里兜底掐掉。
 */
html {
  -webkit-text-size-adjust: 100%;
  text-size-adjust: 100%;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
    'Hiragino Sans GB', 'Microsoft YaHei', 'Source Han Sans SC', sans-serif;
  color: var(--text);
  background: var(--bg);
  -webkit-font-smoothing: antialiased;
  font-size: 14px;
  line-height: 1.5;
  overflow-x: hidden;
  -webkit-tap-highlight-color: transparent;
}

button, a, input, select, textarea, label {
  -webkit-tap-highlight-color: transparent;
}

button { font-family: inherit; cursor: pointer; border: none; background: none; }
input { font-family: inherit; }

/*
 * 视口高度说明（具体声明写在各页面自己的规则里，就近维护）：
 * 100vh 在手机浏览器里是"最大高度"（包含地址栏收起后的区域），
 * 拿它做整屏布局，内容会被地址栏压住、或底部莫名留白。
 * dvh（dynamic viewport height）会跟着地址栏收放实时变化，才是对的。
 * 老浏览器不认 dvh，所以各处都留一行 vh 兜底。
 */
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }

/* ============================================================
   背景层（玻璃的"内容"）
   ------------------------------------------------------------
   液态玻璃必须透出点什么才成立。透一片纯色，看起来就是一块半透明塑料；
   透有色彩变化、有明暗起伏的东西，才有"玻璃"的读感。

   所以每个非登录页都挂一个 .bg-mesh：一层固定的渐变光斑。
   用独立 <div> 而不是 body::before，是因为登录页已经把 body 的
   ::before / ::after 占去铺背景照片了，共用伪元素会打架。

   z-index: -1 让它落到所有内容之下、但仍在 body 背景色之上。
   pointer-events: none 保证它绝不截获点击（固定层最容易踩这个坑）。
   ============================================================ */

.bg-mesh {
  position: fixed;
  inset: 0;
  z-index: -1;
  pointer-events: none;
  overflow: hidden;
  background-color: var(--bg);
  /*
   * 光斑的浓度是反复调出来的，别往淡的方向改。
   *
   * 第一版用了很淡的蓝（alpha .13~.26），结果整页接近纯白 ——
   * 玻璃底色也是白的，白玻璃透白背景，看起来就是"没有玻璃"，
   * 一整层 backdrop-filter 的 GPU 开销白花。玻璃的观感**完全来自
   * 背景和前景的色差**：背景越有色彩，玻璃才越像玻璃。
   * 所以这里给到 .24~.42，肉眼能明确看出"这块玻璃底下有蓝色"。
   *
   * 中间那层 50% 45% 的大半径浅蓝是"打底"用的：只有四角有颜色的话，
   * 屏幕正中间一大片还是白的，而课表恰好就铺在正中间。
   */
  background-image:
    radial-gradient(1200px 720px at 8% -8%, rgba(59, 108, 235, .42), transparent 60%),
    radial-gradient(1000px 640px at 98% 0%, rgba(126, 92, 224, .34), transparent 62%),
    radial-gradient(1020px 720px at 88% 104%, rgba(26, 148, 170, .32), transparent 62%),
    radial-gradient(900px 640px at -8% 92%, rgba(226, 132, 32, .24), transparent 60%),
    radial-gradient(1500px 900px at 50% 44%, rgba(79, 125, 243, .14), transparent 72%);
}

/*
 * 三颗缓慢游动的光斑。
 *
 * 静态渐变已经够"透"了，但玻璃的折射感来自**背景在动**：
 * 光斑慢慢漂，玻璃面上的明暗跟着变，才像一层会呼吸的材质。
 * 周期给到 26~34 秒，慢到不会分心，又快到能察觉。
 *
 * 只在桌面（有 GPU 富余、且一屏能看到整页）开动画；
 * 手机和"减少动态效果"偏好下直接停在初始位置 —— 见下面的媒体查询。
 *
 * 位置特意让它们**压在内容区上**（而不是都在屏幕角落）：
 * 都躲在角落的话，课表那一大片区域又是纯色的，玻璃照样看不出来。
 */
.bg-mesh .blob {
  position: absolute;
  display: block;
  border-radius: 50%;
  filter: blur(64px);
  opacity: .72;
}
.bg-mesh .blob-a {
  width: 46vw; height: 46vw;
  left: -6vw; top: 2vh;
  background: radial-gradient(circle at 40% 40%, rgba(79, 125, 243, .62), rgba(79, 125, 243, 0) 70%);
  animation: blob-drift-a 30s ease-in-out infinite;
}
.bg-mesh .blob-b {
  width: 40vw; height: 40vw;
  right: -4vw; top: 22vh;
  background: radial-gradient(circle at 50% 50%, rgba(138, 110, 224, .52), rgba(138, 110, 224, 0) 70%);
  animation: blob-drift-b 26s ease-in-out infinite;
}
.bg-mesh .blob-c {
  width: 52vw; height: 52vw;
  left: 20vw; bottom: -20vw;
  background: radial-gradient(circle at 50% 50%, rgba(42, 157, 174, .46), rgba(42, 157, 174, 0) 70%);
  animation: blob-drift-c 34s ease-in-out infinite;
}

@keyframes blob-drift-a {
  0%, 100% { transform: translate3d(0, 0, 0) scale(1); }
  50%      { transform: translate3d(5vw, 4vh, 0) scale(1.08); }
}
@keyframes blob-drift-b {
  0%, 100% { transform: translate3d(0, 0, 0) scale(1.04); }
  50%      { transform: translate3d(-6vw, 6vh, 0) scale(.96); }
}
@keyframes blob-drift-c {
  0%, 100% { transform: translate3d(0, 0, 0) scale(1); }
  50%      { transform: translate3d(3vw, -5vh, 0) scale(1.06); }
}

/*
 * 镜面高光（specular sheen）
 *
 * 从左上斜着扫下来的一道柔光，落在玻璃的表面。
 * 只有"模糊 + 描边"会像磨砂塑料；加了这道斜光才像**液体** ——
 * 它模拟的是光源在光滑曲面上的反射，是会随角度移动的那一层。
 *
 * ⚠ 关键：它必须画在**背景层**（background-image），绝不能用
 *   覆盖在上面的伪元素（::after）去做。
 *
 *   这一条是实测踩出来的：一开始用 ::after + inset:0 铺满整块玻璃，
 *   白纱叠在**文字上面**——背景被提亮的同时文字也被提亮，
 *   两者的绝对差被压缩，实测把 13px 正文的对比度从 5.6:1 打到 2.2:1，
 *   直接掉到 WCAG AA 以下（肉眼就是"字发灰、看不清"）。
 *
 *   改成 background-image 之后，高光只提亮背景色，文字仍是纯色，
 *   对比度一点不受影响，而斜光的观感完全一样。
 *
 *   所以：组件要加高光，就补一行
 *     background-image: linear-gradient(135deg, rgba(255,255,255,.42), rgba(255,255,255,0) 52%);
 *   并且**用 background-color 而不是 background 简写**——
 *   简写会把 background-image 一起重置掉。
 *
 *   另外配色条（--gl 系列）在各组件里直接写死，不做成工具类：
 *   CSS 变量不能存整个 background-image 层叠，做了反而更绕。
 */
:root {
  --sheen: linear-gradient(135deg, rgba(255, 255, 255, .42) 0%, rgba(255, 255, 255, .10) 26%, rgba(255, 255, 255, 0) 52%);
  --sheen-soft: linear-gradient(135deg, rgba(255, 255, 255, .26) 0%, rgba(255, 255, 255, 0) 46%);
}

/* ============================================================
   登录页
   ============================================================ */

.login-page {
  min-height: 100vh;
  min-height: 100dvh;
  display: flex;
  /*
   * 这里**故意不写 align-items: center** —— 垂直居中交给 .login-container
   * 的 margin-block: auto 去做，差别在小屏上很关键，理由写在那边。
   */
  justify-content: center;
  /*
   * 安全区留白：刘海屏的顶部、全面屏的手势条、横屏时的挖孔侧边，
   * 都要让开，否则登录卡片会被状态栏切掉一角、或被底部横条压住。
   *
   * ⚠ 底部比其他三边大得多（84 而不是 24），是在给右下角的背景音乐控件让位：
   *   它 fixed 在 bottom 12px、高 36px，一共占掉底部约 48px。
   *   而登录卡片最底下正好是「下载」按钮 —— 屏幕一矮两者就叠上。
   *   2026-10-08 实测 360x640：点下载按钮，elementFromPoint 命中的是
   *   input.music-range（音量滑轨），按钮看得见、点不着。
   */
  padding: calc(24px + env(safe-area-inset-top, 0px))
           calc(24px + env(safe-area-inset-right, 0px))
           calc(84px + env(safe-area-inset-bottom, 0px))
           calc(24px + env(safe-area-inset-left, 0px));
  position: relative;
  background: var(--bg);   /* 照片没加载出来时的底色，别用纯白，会闪一下 */
}

/*
 * 登录页的背景照片。
 *
 * 液态玻璃最佳的背景就是一张照片 —— 有明暗、有色彩层次，
 * 玻璃透过去才看得出"厚度"。纯色背景做液态玻璃基本等于白做。
 *
 * 但照片不能压太暗：原来 brightness(.72) + 两层深蓝叠加，
 * 整体亮度只有 25% 左右，玻璃不管怎么调都是灰的，出不来通透感。
 * 现在提到 .82 并把叠加层减淡，让背景保持"深色但有光"。
 * 深色照片 + 亮玻璃，这才是苹果那套对比关系。
 */
.login-page::before {
  content: '';
  position: absolute;
  inset: 0;
  background: url('/bg.jpg') no-repeat center center;
  background-size: cover;
  filter: brightness(.82) saturate(1.15);
}

.login-page::after {
  content: '';
  position: absolute;
  inset: 0;
  background: linear-gradient(140deg, rgba(31, 65, 138, .46), rgba(12, 28, 60, .60));
}

.login-container {
  position: relative;
  z-index: 1;
  width: 100%;
  max-width: 420px;
  /*
   * 用 auto 外边距做垂直居中，而不是靠父级的 align-items: center。
   * 两者在「内容比屏幕矮」时看不出区别，区别只在内容**溢出**的时候 ——
   * 而那正是小屏上的常态：
   *   - align-items: center 会把溢出部分同时甩到上下两边，顶部那截被永久
   *     裁掉，连滚动都滚不上去（flex 的经典坑）；
   *   - auto 外边距在空间不够时自动退化成 0，内容老老实实从顶部往下排，
   *     配合 .login-page 底部的 84px 留白，滚到底就能完整看到下载按钮。
   */
  margin-block: auto;
}

/*
 * 登录卡片：整个页面最"重"的一块玻璃。
 *
 * 不透明度取 .72，是"看得见照片"和"读得清字"之间的平衡点：
 *   - 比 .78 更透：卡片压在照片上时会呈现**明显的明暗渐变**
 *     （楼体那片变暗、天空那片变亮），这正是"玻璃底下有东西"的读感；
 *   - 又不低于 .70：再透下去，照片的暗部会把灰字压到 AA 以下。
 *
 * ⚠ 这一条不要随手调低。2026-10-08 为了"更像液态玻璃"想把它降到 .62，
 *   实算了一下：卡片左下角压在最暗的那片照片上（约 #1a2030），
 *   底色 = .62 白 + .38 那句 → #a8a9af，页脚小字 #4a525f 对它的对比度
 *   只有 3.3:1，低于 AA 的 4.5:1。降到 .72 也才 4.1:1 —— 已经很勉强，
 *   所以"更透"这条路在这一页是走不通的，**要玻璃感只能从边缘和折射上做**。
 *
 * 边缘（这是本轮真正改的东西）：
 *   原来只有一圈**均匀**的 1px 白描边（--glass-ring）。均匀 = 描边，
 *   不像玻璃 —— 真实玻璃的边是"光在厚度里折了一圈"，有明暗过渡。
 *   所以在描边之内再加一层**内发光**（inset 0 2px 14px 白），
 *   让顶缘往里化开一小片亮，读起来才像"一坨有厚度的透明材料"。
 *   这层发光不占 backdrop-filter，合成成本基本为零。
 */
.login-card {
  background-color: rgba(255, 255, 255, .72);
  background-image: linear-gradient(135deg, rgba(255, 255, 255, .50) 0%, rgba(255, 255, 255, .12) 30%, rgba(255, 255, 255, 0) 58%);
  -webkit-backdrop-filter: blur(28px) saturate(190%);
  backdrop-filter: blur(28px) saturate(190%);
  border-radius: 26px;
  padding: 38px 32px 26px;
  box-shadow: var(--glass-ring),
              inset 0 1px 0 rgba(255, 255, 255, .92),
              inset 0 2px 14px rgba(255, 255, 255, .50),
              inset 0 -1px 0 rgba(255, 255, 255, .34),
              var(--glass-shadow-lg);
}

/*
 * 登录卡片里的次要文字要单独压深一档。
 *
 * 别的地方用的 --text-3 (#5e6776) 是按"均匀浅底"挑的，这里不成立：
 * 卡片背后是一张照片，玻璃透下来后同一块卡片的明暗能差两档。
 * 按最暗的那块（楼体那片）算，--text-3 只有 3.5:1，不达标。
 * 压到 #4a525f 之后，最暗处约 4.9:1、最亮处接近 8:1，两头都稳。
 *
 * 这就是"玻璃 + 照片"相对"玻璃 + 纯色"多出来的成本：
 * 背景不再是常量，文字颜色必须按**最差的那一块**来定。
 */
.login-card .login-header p,
.login-card .login-footer .tip,
.login-card .login-footer .tip.sub,
.login-card .dl-body em { color: #4a525f; }

.login-header { text-align: center; margin-bottom: 28px; }

.brand-logo {
  width: 64px;
  height: 64px;
  border-radius: 50%;
  object-fit: cover;
  margin-bottom: 14px;
  box-shadow: 0 4px 16px rgba(79, 125, 243, .28);
}

.login-header h1 { font-size: 22px; font-weight: 700; letter-spacing: .5px; }
.login-header p { color: var(--text-3); font-size: 13px; margin-top: 6px; }

.form-group { margin-bottom: 18px; }

.form-group label {
  display: block;
  font-size: 13px;
  font-weight: 600;
  color: #414b5c;
  margin-bottom: 8px;
}

/*
 * 输入框也是玻璃。
 *
 * 用 thin 档（.42）而不是默认档：输入框要能"看进去"，
 * 太厚就变成一个白色方块，和卡片糊在一起分不出边界。
 * 边界靠 --glass-ring 那圈外描边来交代，而不是靠填色深浅。
 *
 * 聚焦时把描边换成品牌蓝、并补一圈柔光 —— 不换底色，
 * 底色一换就会从"玻璃"变回"实心控件"，整块的材质感断掉。
 */
.form-group input {
  width: 100%;
  height: 46px;
  padding: 0 14px;
  border: none;
  border-radius: 12px;
  font-size: 15px;
  color: var(--text);
  background: var(--glass-bg-thin);
  -webkit-backdrop-filter: blur(var(--glass-blur-thin)) saturate(var(--glass-saturate));
  backdrop-filter: blur(var(--glass-blur-thin)) saturate(var(--glass-saturate));
  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .60),
              inset 0 1px 0 rgba(255, 255, 255, .70),
              0 1px 2px rgba(17, 32, 66, .05);
  outline: none;
  transition: box-shadow .18s, background .18s;
}

/* 占位符也要过 4.5:1 —— 之前是 #b3bac6（约 2.0:1），几乎看不见 */
.form-group input::placeholder { color: #6b7484; }

.form-group input:focus {
  background: rgba(255, 255, 255, .66);
  box-shadow: inset 0 0 0 1.5px var(--primary),
              0 0 0 4px rgba(79, 125, 243, .16),
              inset 0 1px 0 rgba(255, 255, 255, .8);
}

.pwd-wrap { position: relative; }
.pwd-wrap input { padding-right: 62px; }

.pwd-toggle {
  position: absolute;
  right: 6px;
  top: 50%;
  transform: translateY(-50%);
  height: 34px;
  padding: 0 12px;
  font-size: 13px;
  color: var(--primary);
  border-radius: 8px;
}
.pwd-toggle:hover { background: rgba(79, 125, 243, .09); }

.error-msg {
  display: none;
  background: rgba(254, 226, 226, .72);
  -webkit-backdrop-filter: blur(10px) saturate(150%);
  backdrop-filter: blur(10px) saturate(150%);
  border: none;
  box-shadow: inset 0 0 0 1px rgba(220, 38, 38, .28),
              inset 0 1px 0 rgba(255, 255, 255, .6);
  color: #a51b1b;
  font-size: 13px;
  padding: 10px 12px;
  border-radius: 11px;
  margin-bottom: 14px;
  line-height: 1.5;
}

/*
 * 主按钮：品牌渐变 + 玻璃高光。
 *
 * 注意这里**没有**用半透明底 —— 主按钮是页面上唯一的"实心"元素，
 * 它要靠不透明度从满屏玻璃里跳出来，一旦也做成半透明，
 * 用户就找不到"该点哪儿"了。玻璃感由「叠在渐变上面的那层白色斜光」
 * 和上内高光提供：两层渐变写在同一个 background-image 里，
 * 白斜光在前（先声明 = 在上层），品牌渐变在后。
 */
.btn-login {
  width: 100%;
  height: 48px;
  border-radius: 13px;
  background-image: linear-gradient(135deg, rgba(255, 255, 255, .30) 0%, rgba(255, 255, 255, 0) 55%),
                    linear-gradient(135deg, #5b87f5, #3a63d8);
  color: #fff;
  font-size: 16px;
  font-weight: 600;
  letter-spacing: 2px;
  transition: transform .12s, box-shadow .2s, opacity .2s;
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, .38),
              0 6px 18px rgba(58, 99, 216, .30);
}

.btn-login:hover:not(:disabled) {
  transform: translateY(-1px);
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, .45),
              0 9px 24px rgba(58, 99, 216, .38);
}
.btn-login:active:not(:disabled) { transform: translateY(0); }
.btn-login:disabled { opacity: .68; cursor: not-allowed; letter-spacing: 1px; font-size: 15px; }
.btn-login.loading { position: relative; }

.login-footer { margin-top: 22px; text-align: center; }

.login-footer .tip {
  font-size: 12px;
  color: var(--text-3);
  line-height: 1.65;
}
.login-footer .tip.sub { margin-top: 4px; color: var(--text-3); }

/* ---- 安卓 App 下载入口 ----
   放在登录卡片底部：既够醒目，又不会抢登录按钮的焦点。
   进 App 之后这段会被脚本整个移除（见 index.html 底部）。 */
.dl-box {
  display: flex;
  align-items: center;
  gap: 11px;
  margin-top: 20px;
  padding-top: 18px;
  /* 分隔线用"白色高光"而不是灰色描边：玻璃上的刻线是亮的，不是暗的 */
  border-top: 1px solid rgba(255, 255, 255, .55);
}

.dl-icon {
  flex: none;
  width: 36px;
  height: 36px;
  border-radius: 11px;
  background: rgba(79, 125, 243, .14);
  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .6), inset 0 1px 0 rgba(255, 255, 255, .7);
  display: flex;
  align-items: center;
  justify-content: center;
}

.dl-body { flex: 1; min-width: 0; }
.dl-body b { display: block; font-size: 13px; font-weight: 600; color: #38414f; }
.dl-body em {
  display: block;
  margin-top: 3px;
  font-size: 11px;
  font-style: normal;
  color: var(--text-3);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.dl-btn {
  flex: none;
  height: 34px;
  padding: 0 17px;
  border-radius: 9px;
  background: var(--primary);
  color: #fff;
  font-size: 13px;
  font-weight: 600;
  display: inline-flex;
  align-items: center;
  transition: background .18s, transform .12s;
}
.dl-btn:hover { background: #3a63d8; }
.dl-btn:active { transform: scale(.97); }

/* ============================================================
   课表页 —— 顶栏
   ============================================================ */

.schedule-page { min-height: 100vh; min-height: 100dvh; display: flex; flex-direction: column; }

/*
 * 顶栏：粘在顶部的玻璃条。
 *
 * blur 给到 22px 而不是默认 20 —— 顶栏会盖在课表卡片上滚动，
 * 半径太小的话，底下的课程卡会透出一层可辨认的色块，
 * 看起来像"没渲染干净"而不是"玻璃"。
 *
 * 底边保留一条 1px 暗描边（不是删掉）：玻璃条需要一条清晰的
 * "结束线"，否则和下面的内容黏在一起，滚动时看不出顶栏到哪为止。
 * 厚度靠 inset 的白色内高光交代。保留 border 而不是改用 box-shadow 描边，
 * 是因为 border 参与了高度计算，换成 shadow 会让顶栏矮 1px。
 */
.appbar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  padding: 12px 22px;
  /* 让开刘海 / 挖孔：横屏时左右也要留，否则内容会被切边 */
  padding-top: calc(12px + env(safe-area-inset-top, 0px));
  padding-left: calc(22px + env(safe-area-inset-left, 0px));
  padding-right: calc(22px + env(safe-area-inset-right, 0px));
  background: var(--glass-bg);
  -webkit-backdrop-filter: blur(22px) saturate(var(--glass-saturate));
  backdrop-filter: blur(22px) saturate(var(--glass-saturate));
  border-bottom: 1px solid var(--line);
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, .75),
              0 6px 20px rgba(17, 32, 66, .05);
  position: sticky;
  top: 0;
  z-index: 30;
  flex-wrap: wrap;
}

.appbar-left { display: flex; align-items: center; gap: 12px; }

.appbar-logo {
  width: 38px; height: 38px;
  border-radius: 50%;
  object-fit: cover;
}

.appbar-title { display: flex; flex-direction: column; line-height: 1.3; }
.appbar-title strong { font-size: 16px; font-weight: 700; }
.appbar-title span { font-size: 12px; color: var(--text-3); }

.appbar-right { display: flex; align-items: center; gap: 10px; }

.user-chip {
  display: flex;
  align-items: baseline;
  gap: 8px;
  padding: 6px 14px;
  background-color: var(--glass-bg-thin);
  background-image: var(--sheen-soft);
  -webkit-backdrop-filter: blur(var(--glass-blur-thin)) saturate(var(--glass-saturate));
  backdrop-filter: blur(var(--glass-blur-thin)) saturate(var(--glass-saturate));
  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .6), inset 0 1px 0 rgba(255, 255, 255, .7);
  border-radius: 999px;
  margin-right: 4px;
}
.user-name { font-size: 13px; font-weight: 600; color: #2c4a8f; }
.user-id { font-size: 12px; color: var(--text-3); font-family: ui-monospace, monospace; }

.btn {
  height: 34px;
  padding: 0 14px;
  border-radius: 9px;
  font-size: 13px;
  font-weight: 500;
  transition: background-color .16s, color .16s, opacity .16s, box-shadow .16s;
}

/* 次级按钮 = 薄玻璃。用 thin 档，避免和顶栏那层玻璃叠成一块白 */
.btn-ghost {
  background-color: var(--glass-bg-thin);
  background-image: var(--sheen-soft);
  -webkit-backdrop-filter: blur(var(--glass-blur-thin)) saturate(var(--glass-saturate));
  backdrop-filter: blur(var(--glass-blur-thin)) saturate(var(--glass-saturate));
  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .55), inset 0 1px 0 rgba(255, 255, 255, .65);
  color: #46536b;
}
.btn-ghost:hover:not(:disabled) {
  background-color: rgba(255, 255, 255, .72);
  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .7), inset 0 1px 0 rgba(255, 255, 255, .8);
}
.btn-ghost.danger { color: #bf3b2e; }
.btn-ghost.danger:hover {
  background-color: rgba(254, 226, 226, .82);
  background-image: none;
  box-shadow: inset 0 0 0 1px rgba(220, 38, 38, .30), inset 0 1px 0 rgba(255, 255, 255, .7);
}
.btn:disabled { opacity: .55; cursor: not-allowed; }

.btn-today {
  height: 38px;
  padding: 0 16px;
  background-color: var(--glass-bg-thin);
  -webkit-backdrop-filter: blur(var(--glass-blur-thin)) saturate(var(--glass-saturate));
  backdrop-filter: blur(var(--glass-blur-thin)) saturate(var(--glass-saturate));
  box-shadow: inset 0 0 0 1.5px rgba(255, 255, 255, .75), inset 0 1px 0 rgba(255, 255, 255, .6);
  color: #46536b;
  font-weight: 600;
  margin-left: 6px;
}
.btn-today:hover {
  box-shadow: inset 0 0 0 1.5px var(--primary), inset 0 1px 0 rgba(255, 255, 255, .6);
  color: var(--primary);
}

.warning-bar {
  margin: 12px 22px 0;
  padding: 10px 14px;
  background: rgba(255, 248, 230, .78);
  -webkit-backdrop-filter: blur(12px) saturate(160%);
  backdrop-filter: blur(12px) saturate(160%);
  border: none;
  box-shadow: inset 0 0 0 1px rgba(245, 224, 168, .95),
              inset 0 1px 0 rgba(255, 255, 255, .7);
  color: #7a5600;
  border-radius: 11px;
  font-size: 13px;
}

/*
 * ⚠ 底部内边距（这里是 64px）不是随手写的，它要给右下角的背景音乐控件让位：
 *   .music-dock 固定在 bottom 12px、高 36px，占掉底部约 48px。
 *   留白少于这个数，滚到页面最底下时最后一行内容会被它压住 ——
 *   而它是个 fixed 元素，被压住的东西没法靠再滚动躲开。
 *   所以下面每个改 .wrap 底部内边距的地方，都要保持 ≥ 64px。
 */
.wrap {
  width: 100%;
  max-width: 1220px;
  margin: 0 auto;
  padding: 16px 22px 64px;
  flex: 1;
}

/* ============================================================
   周次导航
   ============================================================ */

.weeknav {
  display: flex;
  align-items: center;
  gap: 14px;
  background-color: var(--glass-bg);
  background-image: var(--sheen);
  -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
  backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
  border-radius: var(--glass-radius);
  padding: 14px 18px;
  box-shadow: var(--glass-edge), var(--glass-shadow);
  margin-bottom: 12px;
}

.nav-arrow {
  width: 38px; height: 38px;
  flex-shrink: 0;
  border-radius: 11px;
  background-color: var(--glass-bg-thin);
  -webkit-backdrop-filter: blur(var(--glass-blur-thin)) saturate(var(--glass-saturate));
  backdrop-filter: blur(var(--glass-blur-thin)) saturate(var(--glass-saturate));
  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .6), inset 0 1px 0 rgba(255, 255, 255, .7);
  color: #46536b;
  font-size: 22px;
  line-height: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  padding-bottom: 3px;
  transition: background-color .16s, color .16s, box-shadow .16s;
}
.nav-arrow:hover {
  background-color: transparent;
  background-image: linear-gradient(135deg, #5b87f5, #3a63d8);
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, .45), 0 6px 16px rgba(58, 99, 216, .32);
  color: #fff;
}

.weeknav-center { flex: 1; text-align: center; min-width: 0; }

.week-title {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 9px;
}

.week-title #weekNum { font-size: 19px; font-weight: 700; }

.badge-current {
  font-size: 11px;
  font-weight: 600;
  color: #fff;
  background: linear-gradient(135deg, #4f7df3, #3a63d8);
  padding: 2px 9px;
  border-radius: 999px;
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, .40), 0 2px 8px rgba(58, 99, 216, .30);
}

.week-date { font-size: 12.5px; color: var(--text-3); margin-top: 2px; }

/*
 * 「当前作息」小标签。
 *
 * 学校有春秋冬季 / 夏季两套作息，下午和晚上差 30 分钟（5/6 起换夏季）。
 * 这个标签的作用是让用户一眼能看出「现在显示的时刻是哪一套」——
 * 没有它的话，换季后某天课表突然整体差半小时，用户只会以为程序坏了。
 * 放在这里而不是课表左侧的节次列，是因为 28px 宽的窄屏会把
 * .section-time 整列隐藏掉（见下面的媒体查询），标签会跟着一起消失。
 */
.season-note {
  display: inline-block;
  margin-top: 5px;
  padding: 1px 9px;
  border-radius: 999px;
  background: rgba(79, 125, 243, .12);
  -webkit-backdrop-filter: blur(8px) saturate(160%);
  backdrop-filter: blur(8px) saturate(160%);
  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .55);
  color: #4a5568;
  font-size: 11.5px;
  line-height: 1.65;
  white-space: nowrap;
}

/* ============================================================
   周次条
   ------------------------------------------------------------
   这一条的诉求是「滑动 + 布灵布灵」，做法分两层：

     1. 滑动的玻璃指示器（.week-pill）
        一块品牌蓝的玻璃，选中哪周就滑到哪周去。
        它是**常驻元素**，靠 transform 过渡滑过去 ——
        所以 JS 不能像以前那样整条 innerHTML 重建（重建 = 换了个新节点，
        transform 没有"上一个值"，只会瞬移，滑不起来）。
        JS 改成只重建药丸、保留 pill 节点，见 schedule.js 的 renderWeekStrip()。

     2. 逐颗错峰扫过的镜面流光（.week-chip::before）
        一道斜光从每颗药丸表面扫过，靠 --i 逐颗延后 0.16s，
        25 颗连起来就是一道持续流动的光带。这就是"布灵布灵"。

   ⚠ 流光为什么用 ::before + z-index:-1，而不是 ::after 覆盖层：
     ::after + inset:0 会把白光叠在**文字上面**（这一轮改造已经栽过一次），
     而 z-index:-1 的伪元素在绘制顺序里排在"元素自身背景之后、文字之前"
     （CSS 2.1 附录 E 的第 2 步 vs 第 5 步），于是光只提亮底色、不碰文字。
     代价是父元素必须自己成为层叠上下文，所以要给 .week-chip 加
     isolation: isolate —— 不加的话 z-index:-1 会跑到祖先的负层去，
     流光就被自己的底色盖住，整个效果凭空消失且不报错。
   ============================================================ */

.weekstrip {
  display: flex;
  gap: 6px;
  overflow-x: auto;
  padding: 10px 12px;
  position: relative;              /* 滑动的 pill 要相对它定位 */
  scroll-behavior: smooth;         /* 切换周次时药丸顺滑地滚到中间 */
  background-color: var(--glass-bg);
  background-image: var(--sheen);
  -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
  backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
  border-radius: var(--glass-radius);
  box-shadow: var(--glass-edge), var(--glass-shadow);
  margin-bottom: 12px;
  scrollbar-width: thin;
}
.weekstrip::-webkit-scrollbar { height: 6px; }
.weekstrip::-webkit-scrollbar-thumb { background: rgba(17, 32, 66, .16); border-radius: 3px; }

/*
 * 滑动的玻璃指示器。
 *
 * 用 translate 而不是 left 做位移：transform 走合成器，不触发重排/重绘，
 * 20 多颗药丸之间滑来滑去也不会掉帧。
 *
 * 缓动用 cubic-bezier(.34, 1.28, .48, 1)：末段有个轻微过冲再回弹，
 * 是"液态"的手感来源 —— 线性或标准 ease 都会显得是"跳"过去的，
 * 而不是"流"过去的。
 */
.week-pill {
  position: absolute;
  top: 0;
  left: 0;
  width: 0;
  height: 0;
  border-radius: 10px;
  pointer-events: none;            /* 绝不能挡住药丸的点击 */
  overflow: hidden;                /* 把流光裁在圆角里 */
  background-color: transparent;
  background-image: linear-gradient(135deg, rgba(255, 255, 255, .38) 0%, rgba(255, 255, 255, 0) 56%),
                    linear-gradient(135deg, #6f97f8, #3a63d8);
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, .50),
              0 4px 14px rgba(58, 99, 216, .38);
  transform: translate3d(0, 0, 0);
  transition: transform .34s cubic-bezier(.34, 1.28, .48, 1),
              width .22s ease,
              height .22s ease;
  will-change: transform;
}

/*
 * pill 自己的一道更亮的流光。
 * 它**没有文字**，所以可以放心用 ::after 覆盖层 —— 覆盖层压字的问题
 * 只有在"底下有文字"时才成立。
 */
.week-pill::after {
  content: '';
  position: absolute;
  inset: 0;
  background: linear-gradient(105deg,
    rgba(255, 255, 255, 0) 40%,
    rgba(255, 255, 255, .45) 47%,
    rgba(255, 255, 255, .92) 50%,
    rgba(255, 255, 255, .45) 53%,
    rgba(255, 255, 255, 0) 60%);
  transform: translateX(-135%);
  animation: pill-shine 2.8s linear infinite;
}
@keyframes pill-shine {
  0%   { transform: translateX(-135%); }
  42%  { transform: translateX(135%); }
  100% { transform: translateX(135%); }   /* 其余时间停在屏外 → 是"闪一下"而非"一直在流" */
}

/*
 * 周次药丸。
 *
 * 三种状态必须一眼分得开，因为它们是这一屏里唯一要反复点的东西：
 *   默认   —— 薄玻璃、浅色字
 *   本周   —— 玻璃 + 一圈品牌蓝描边（"你现在在这儿"，但不抢焦点）
 *   选中   —— 底下的 .week-pill 透上来（"你在看这一周"）
 *
 * 加了 isolation: isolate 是为了让下面的 ::before 流光能落在
 * "自己的底色之上、文字之下"这个夹层里，见本节开头的说明。
 */
.week-chip {
  flex: 0 0 auto;
  width: 40px; height: 34px;
  border-radius: 10px;
  position: relative;
  z-index: 1;                      /* 压在滑动的 pill 上面，否则字被盖住 */
  isolation: isolate;              /* 让 ::before 的 z-index:-1 停在夹层里 */
  overflow: hidden;                /* 把流光裁在药丸里，别溢出成横向滚动区 */
  background-color: var(--glass-bg-thin);
  background-image: var(--sheen-soft);
  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .5), inset 0 1px 0 rgba(255, 255, 255, .6);
  color: #4d5768;
  font-size: 13px;
  font-weight: 600;
  transition: background-color .16s, color .16s, transform .12s, box-shadow .16s;
}

/*
 * 逐颗扫过的镜面流光。
 *
 * --i 由 JS 写在每颗药丸上（下标），乘 0.16s 就是错峰量。
 * 25 颗 × 0.16s = 4s，和 4.8s 的周期基本咬合，看起来是一道连续的光带
 * 从左往右跑过去，而不是"所有药丸一起闪"（那样像故障灯，很吵）。
 *
 * 白光扫过时，深色文字所在的背景变亮 → 对比度**升高**，不是降低。
 * 所以给深色字的默认态用宽而亮的带子完全安全。
 */
.week-chip::before {
  content: '';
  position: absolute;
  inset: 0;
  z-index: -1;
  border-radius: inherit;
  pointer-events: none;
  background: linear-gradient(105deg,
    rgba(255, 255, 255, 0) 38%,
    rgba(255, 255, 255, .45) 46%,
    rgba(255, 255, 255, .88) 50%,
    rgba(255, 255, 255, .45) 54%,
    rgba(255, 255, 255, 0) 62%);
  transform: translateX(-135%);
  animation: chip-shine 4.8s linear infinite;
  animation-delay: calc(var(--i, 0) * .16s);
}
@keyframes chip-shine {
  0%   { transform: translateX(-135%); }
  36%  { transform: translateX(135%); }
  100% { transform: translateX(135%); }
}

.week-chip:hover { background-color: rgba(255, 255, 255, .70); }
.week-chip.current {
  box-shadow: inset 0 0 0 1.5px rgba(79, 125, 243, .55), inset 0 1px 0 rgba(255, 255, 255, .6);
  color: var(--primary);
}

/*
 * 选中态：底色交给滑动的 pill，药丸自己变成透明。
 *
 * ⚠ 这条规则挂在 .weekstrip.has-pill 下面，是被 JS 显式开启的。
 *   原因：如果 pill 因为任何原因没定位成功（JS 报错、DOM 结构变了），
 *   直接让选中态透明就等于"选中那一周彻底看不见了"，而且完全不会报错。
 *   加了 has-pill 这个门槛之后，JS 定位失败时选中态会退回成
 *   "自己的品牌蓝渐变"，功能还在，只是少了滑动动效。
 *   这是刻意的**降级路径**，别把 has-pill 去掉。
 */
.weekstrip.has-pill .week-chip.active {
  background-color: transparent;
  background-image: none;
  box-shadow: none;
  color: #fff;
}

/*
 * 选中态自己的流光要收窄。
 *
 * 选中态的文字是**白色**，压在品牌蓝上。这时候再让一条又宽又亮的白带扫
 * 过去，被扫到的那个数字会瞬间变成"白字叠白底"，有一个明显的读不清的瞬间。
 * 所以窄到 4%（≈3px）的一条细闪 —— 看起来是"镜面反光掠过"，而不是"洗白"。
 *
 * 反过来，非选中态是深色字，白带扫过只会让对比度更高，宽一点更漂亮。
 * 同一条效果，两种处理，取决于压在上面的文字是深是浅。
 */
.weekstrip.has-pill .week-chip.active::before {
  background: linear-gradient(105deg,
    rgba(255, 255, 255, 0) 45%,
    rgba(255, 255, 255, .90) 49.5%,
    rgba(255, 255, 255, 0) 54%);
  animation-duration: 2.8s;
  animation-delay: 0s;
}

/*
 * 选中态还叠一圈"内发光"，让它从一排玻璃里浮起来。
 * 用 inset shadow 而不是外发光：外发光会被 .weekstrip 的 overflow 裁掉一部分，
 * 而且药丸之间只有 6px 间隙，外发光会糊到隔壁。
 */
.weekstrip.has-pill .week-chip.active {
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, .45), inset 0 0 12px rgba(255, 255, 255, .22);
}
.weekstrip.has-pill .week-chip.active.current {
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, .45),
              inset 0 0 12px rgba(255, 255, 255, .22),
              0 0 0 2px rgba(255, 255, 255, .85);
  color: #fff;
}

/*
 * 鼠标经过某一颗时，让它的流光立刻单独走一遍 —— 这是最直接的
 * "摸一下就布灵一下"的手感反馈。放在最后声明，覆盖掉上面按 --i 的错峰。
 */
@media (hover: hover) {
  .week-chip:hover::before {
    animation: chip-shine .9s linear;
    animation-delay: 0s;
  }
}

.week-chip.active {
  background-color: transparent;
  background-image: linear-gradient(135deg, #5b87f5, #3a63d8);
  color: #fff;
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, .40), 0 4px 12px rgba(58, 99, 216, .32);
}
.week-chip.active.current {
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, .40),
              0 4px 12px rgba(58, 99, 216, .32),
              0 0 0 2px rgba(255, 255, 255, .85);
}

/* ============================================================
   课表网格
   ============================================================ */

/*
 * 课表网格大卡。
 *
 * ⚠ 这里**故意没有 backdrop-filter**，是 2026-10-08 拿掉的，别顺手加回来。
 *
 * 理由只有一个：**它模糊的东西已经糊过了，再糊一次不产生任何视觉差异。**
 * 它背后是 .bg-mesh 的三颗光斑，而光斑自己挂着 filter: blur(64px)。
 * 对一张 64px 半径糊过的图再做 12~20px 的模糊，结果和原图逐像素一致
 * （已用同视口截图对比确认：preview/perf/perf-baseline.png 与
 *   perf-only-course.png 里这块区域看不出差别）。
 *
 * 代价则很明确：它占页面 **73% 的模糊面积**（381×826 = 314,706 px²，
 * 全页 18 层加起来 430,566 px²），是页面上最大的一张需要维护背景模糊的
 * 离屏图层。实测（tools/scroll-perf-test.js）这个页面的合成器出图成本
 * 基本是**"每个模糊层一份固定开销"**，跟面积关系不大 —— 但既然是白拿的，
 * 就没必要留着一张 381×826 的离屏图。拿掉后全页模糊面积降到 115,860 px²。
 *
 * ⚠ 关于"性能"的诚实说明，免得后来人误读：
 *   一开始我测出"去掉它省 72ms 光栅化 / 288 次"，那是**测量伪影** ——
 *   页面刚 load 完的首次铺砖被算进了第一个 trace 窗口。
 *   加了热身 + 基线复测之后，这个数字变成 0，归因也随之消失。
 *   所以这里**不宣称"拿掉它变快了"**，只宣称"拿掉它没有视觉代价，
 *   且少一张大离屏图"。详见 tools/scroll-perf-test.js 文件头的长注释。
 */
.grid-card {
  background-color: var(--glass-bg);
  background-image: var(--sheen);
  border-radius: var(--glass-radius);
  box-shadow: var(--glass-edge), var(--glass-shadow);
  overflow: hidden;
  position: relative;
}

/*
 * 课表网格里的"纸"。
 *
 * 节次列和表头都只给一层很薄的白色（.34~.55），让网格看起来是
 * 一整块玻璃上刻出来的格子，而不是"玻璃上又贴了几张白纸"。
 * 分格靠 1px 的半透明描边，不靠底色差 —— 底色差一多，玻璃的
 * 通透感就被这些小白块割碎了。
 */
.grid-scroll { overflow-x: auto; }

.grid {
  display: flex;
  min-width: 880px;
  /*
   * 课程卡片相对「列宽」的内缩量，被 JS 拼进卡片的内联样式里。
   * 之所以做成变量：宽屏时 6px 的缝能让并排（同一天两门课撞在一起）的卡片分得开；
   * 但手机列宽只有 40 多像素，6px 等于白扔 12% 的宽度，连一个 4 字课名都放不下。
   * 变量可以在媒体查询里按屏宽收窄，而 JS 完全不用动。
   */
  --card-gap: 6px;
  --card-gap-half: 3px;
}

.time-col { width: 78px; flex: 0 0 78px; background: rgba(255, 255, 255, .34); border-right: 1px solid var(--line); }

/*
 * 表头：粘在网格顶部。
 *
 * 为什么需要一层背景：它滚动时会盖在课程卡上面，没有自己的背景的话，
 * 底下的课名会清晰地穿过来，表头文字和课名糊在一起，完全读不出"周一/周二"。
 *
 * ⚠ 但"背景"不等于"模糊"。手机上这一层被换成近实心白、撤掉 backdrop-filter
 *   （见 @media (max-width:720px) 那段）：它不是 1 个元素而是 6~7 个
 *   （每天一格），每格一张离屏模糊图，逐元素实测它是**单个最贵的一层**，
 *   摘掉能省 35% 的合成器出图时间，而观感在 46px 高的一条上几乎无差别。
 *   桌面保留模糊 —— 那里有 GPU 富余，模糊也是设计的一部分。
 */
.day-head {
  height: var(--head-h);
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 13px;
  font-weight: 600;
  color: #414b5c;
  background: rgba(255, 255, 255, .55);
  -webkit-backdrop-filter: blur(14px) saturate(180%);
  backdrop-filter: blur(14px) saturate(180%);
  border-bottom: 1px solid var(--line);
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, .7);
  position: sticky;
  top: 0;
  z-index: 5;
}

.section-item {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  border-bottom: 1px solid rgba(17, 32, 66, .06);
}

.section-num { font-size: 14px; font-weight: 700; color: #4d5768; }
.section-time { font-size: 9.5px; color: var(--text-3); margin-top: 3px; letter-spacing: -.2px; }

.days-row { display: flex; flex: 1; }

.day-col {
  flex: 1 1 0;
  min-width: 112px;
  border-right: 1px solid rgba(17, 32, 66, .06);
  position: relative;
}
.day-col:last-child { border-right: none; }

.day-body { position: relative; }

/* 横向分格线。用半透明深色而不是固定的 #f1f3f8：
   固定浅灰在浅色玻璃上几乎看不见，在深色背景透上来的地方又太亮。 */
.day-body::before {
  content: '';
  position: absolute;
  inset: 0;
  background: repeating-linear-gradient(
    to bottom,
    transparent 0,
    transparent calc(var(--row-h) - 1px),
    rgba(17, 32, 66, .07) calc(var(--row-h) - 1px),
    rgba(17, 32, 66, .07) var(--row-h)
  );
  pointer-events: none;
}

/*
 * 课程卡片。
 *
 * 底色由 JS 按课名哈希后写进内联样式（见 schedule.js 的 PALETTE），
 * CSS 这边只负责加上"玻璃"的那几层：模糊、上内高光、下内反光。
 * 所以调色板必须是半透明的 rgba —— 不透明的话 backdrop-filter
 * 透不出任何东西，卡片就变回一块彩色纸片。
 *
 * 内缩量和定位全部由 JS 算好后写在内联样式里（top / height / left / width
 * 都是像素或百分比），这里一个尺寸都不要碰，否则网格线会和卡片错位。
 */
.course-card {
  position: absolute;
  border-radius: 10px;
  border-left: 3px solid;
  padding: 7px 8px;
  overflow: hidden;
  cursor: pointer;
  /*
   * 底色由 JS 按课名哈希后写进内联样式的 **background-color**（见
   * schedule.js 的 PALETTE）。JS 那边必须用 background-color 而不是
   * background 简写 —— 简写会把下面这行 background-image 一起重置，
   * 斜光就没了。这条约束是双边的，改任何一边都要看另一边。
   *
   * 这里只负责玻璃的那几层：像素级的模糊、上内高光、下内反光、斜光。
   */
  background-image: var(--sheen-soft);
  -webkit-backdrop-filter: blur(12px) saturate(170%);
  backdrop-filter: blur(12px) saturate(170%);
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, .60),
              inset 0 -1px 0 rgba(255, 255, 255, .28),
              0 1px 2px rgba(17, 32, 66, .06);
  transition: transform .14s, box-shadow .18s;
  display: flex;
  flex-direction: column;
  gap: 2px;
}

/*
 * hover：抬起来 + 阴影变深 + 内侧高光加强。
 *
 * 特意去掉了原来那句 filter: saturate(1.12)。filter 和 backdrop-filter
 * 同时挂在一个元素上时，filter 会重新建一次 backdrop root，
 * 浏览器要重算两次背景，hover 的瞬间会闪一下（尤其 Chrome）。
 * 想提亮就改阴影和高光，别动 filter。
 */
.course-card:hover {
  transform: translateY(-1px) scale(1.012);
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, .85),
              inset 0 -1px 0 rgba(255, 255, 255, .35),
              0 8px 22px rgba(17, 32, 66, .18);
  z-index: 6;
}

.course-name {
  font-size: 12.5px;
  font-weight: 700;
  line-height: 1.3;
  overflow: hidden;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
}

.course-line {
  font-size: 11px;
  line-height: 1.35;
  opacity: .82;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.course-line.weak { opacity: .62; font-size: 10.5px; }

.empty-state {
  padding: 46px 20px;
  text-align: center;
  color: var(--text-3);
  font-size: 14px;
}

/* 「正在同步课表」的转圈：登录是秒回的，课表在后台慢慢抓，得让用户看到在动 */
.spinner {
  display: inline-block;
  width: 15px;
  height: 15px;
  margin-right: 8px;
  vertical-align: -2px;
  border: 2px solid rgba(79, 125, 243, .30);
  border-top-color: #4f7df3;
  border-radius: 50%;
  animation: spin .7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }

#syncNote { display: block; margin-top: 4px; color: var(--text-3); }

/*
 * 同步中：课表网格和空周次条都还是空的，留着只会占满屏幕显示一片白，
 * 把位置让给「正在同步课表」提示，用户一眼就能看到在动。
 */
.schedule-page.syncing .grid-scroll { display: none; }
.schedule-page.syncing .weekstrip { display: none; }
.schedule-page.syncing .empty-state { padding: 72px 20px; }
.schedule-page.syncing .empty-state p { color: #4d5768; font-size: 14.5px; }

.page-foot {
  text-align: center;
  color: var(--text-3);
  font-size: 12px;
  padding: 18px 0 4px;
}

/* ============================================================
   弹窗 & 提示
   ============================================================ */

/*
 * 遮罩：变暗 + 把背后的页面糊掉。
 *
 * 加 blur 不是为了好看，是为了"景深"：背景被糊掉之后，
 * 凸出来的玻璃弹窗才真正像浮在最上面一层，
 * 而不是像贴在页面上的一块白板。这是苹果那套做法里最关键的一步。
 */
.modal-mask {
  position: fixed;
  inset: 0;
  background: rgba(15, 23, 42, .34);
  -webkit-backdrop-filter: blur(8px) saturate(120%);
  backdrop-filter: blur(8px) saturate(120%);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 20px;
  z-index: 100;
}

.modal {
  background-color: var(--glass-bg-strong);
  background-image: var(--sheen);
  -webkit-backdrop-filter: blur(30px) saturate(190%);
  backdrop-filter: blur(30px) saturate(190%);
  border-radius: 22px;
  padding: 26px 28px 22px;
  width: 100%;
  max-width: 380px;
  position: relative;
  box-shadow: var(--glass-ring), var(--glass-ring-in), var(--glass-ring-in-b), var(--glass-shadow-lg);
}

.modal-close {
  position: absolute;
  right: 14px; top: 12px;
  width: 30px; height: 30px;
  border-radius: 9px;
  font-size: 21px;
  line-height: 1;
  color: var(--text-3);
  z-index: 2;                    /* 压在高光层上面，否则 hover 的底色出不来 */
}
.modal-close:hover { background: rgba(255, 255, 255, .6); color: #414b5c; }

.modal h3 { font-size: 17px; margin-bottom: 16px; padding-right: 30px; }

.modal dl { display: grid; grid-template-columns: 58px 1fr; gap: 9px 12px; }
.modal dt { color: var(--text-3); font-size: 13px; }
.modal dd { font-size: 13.5px; color: #333d4d; }

/*
 * Toast：深色玻璃。
 *
 * 页面上唯一用深色玻璃的地方，因为它是"浮在所有东西之上"的临时提示，
 * 需要和浅色玻璃的层次拉开。半透明 + 模糊让它不至于像一块黑补丁，
 * 底下的课表颜色会隐约透上来。
 */
.toast {
  position: fixed;
  left: 50%;
  bottom: 40px;
  transform: translateX(-50%);
  background-color: var(--glass-bg-dark);
  background-image: var(--sheen-soft);
  -webkit-backdrop-filter: blur(20px) saturate(180%);
  backdrop-filter: blur(20px) saturate(180%);
  color: #fff;
  padding: 11px 20px;
  border-radius: 12px;
  font-size: 13.5px;
  z-index: 200;
  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .18),
              inset 0 1px 0 rgba(255, 255, 255, .22),
              0 10px 30px rgba(0, 0, 0, .28);
  animation: toastIn .22s ease;
  max-width: 86vw;
  text-align: center;
}
.toast.error {
  background-color: rgba(150, 24, 24, .72);
  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .20),
              inset 0 1px 0 rgba(255, 255, 255, .24),
              0 10px 30px rgba(0, 0, 0, .28);
}

@keyframes toastIn {
  from { opacity: 0; transform: translate(-50%, 10px); }
  to { opacity: 1; transform: translate(-50%, 0); }
}

/* ============================================================
   响应式 / 移动端适配
   ------------------------------------------------------------
   断点约定：
     ≤ 900px   平板竖屏、大屏手机横屏
     ≤ 720px   主流手机竖屏（iPhone、常见安卓）
     ≤ 400px   小屏手机（iPhone SE、老机型）
   ============================================================ */

/*
 * 触摸设备上 hover 是个"假"状态。
 * 手机点一下之后 hover 样式会一直粘着不消失（卡片一直浮起、按钮一直变色），
 * 看起来很脏。所以把纯 hover 效果在无 hover 能力的设备上关掉，
 * 统一改用 :active 做"按下去"的即时反馈。
 */
@media (hover: none) {
  /*
   * 触摸设备上"按下"的反馈不能靠 filter。
   *
   * 卡片上已经有 backdrop-filter 了，再加 filter（哪怕只是 brightness）
   * 会让浏览器重建一次 backdrop root，在安卓 WebView 上表现为
   * 手指按下的瞬间卡片"闪一下白"。所以按下只做缩放和阴影，
   * 不去动任何滤镜。
   */
  .course-card:hover {
    transform: none;
    box-shadow: inset 0 1px 0 rgba(255, 255, 255, .60),
                inset 0 -1px 0 rgba(255, 255, 255, .28),
                0 1px 2px rgba(17, 32, 66, .06);
    z-index: auto;
  }
  .course-card:active { transform: scale(.97); }

  .btn-ghost:hover:not(:disabled) {
    background-color: var(--glass-bg-thin);
    box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .55), inset 0 1px 0 rgba(255, 255, 255, .65);
  }
  .btn-ghost:active:not(:disabled) { background-color: rgba(255, 255, 255, .78); }

  /*
   * 周次药丸：这里必须用 background-color 而不是 background 简写。
   * 简写会把 background-image 一起重置 —— 选中态那个品牌渐变就是
   * background-image，一重置，"当前这一周"在手机上点一下会瞬间变白。
   * 再加 :not(.active) 兜底：选中态不参与 hover/active 的底色改写。
   */
  .week-chip:hover:not(.active) {
    background-color: var(--glass-bg-thin);
    box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .5), inset 0 1px 0 rgba(255, 255, 255, .6);
  }
  .week-chip:active:not(.active) { background-color: rgba(255, 255, 255, .80); }

  .nav-arrow:hover {
    background-color: var(--glass-bg-thin);
    background-image: none;
    box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .6), inset 0 1px 0 rgba(255, 255, 255, .7);
    color: #46536b;
  }
  .nav-arrow:active {
    background-color: transparent;
    background-image: linear-gradient(135deg, #5b87f5, #3a63d8);
    box-shadow: inset 0 1px 0 rgba(255, 255, 255, .45), 0 6px 16px rgba(58, 99, 216, .32);
    color: #fff;
  }

  .btn-login:hover:not(:disabled) {
    transform: none;
    box-shadow: inset 0 1px 0 rgba(255, 255, 255, .38), 0 6px 18px rgba(58, 99, 216, .30);
  }
  .btn-login:active:not(:disabled) { transform: scale(.985); }

  .pwd-toggle:hover { background: transparent; }
  .pwd-toggle:active { background: rgba(79, 125, 243, .12); }

  .modal-close:hover { background: transparent; color: var(--text-3); }
  .modal-close:active { background: rgba(255, 255, 255, .6); }

  .btn-today:hover {
    box-shadow: inset 0 0 0 1.5px rgba(255, 255, 255, .75), inset 0 1px 0 rgba(255, 255, 255, .6);
    color: #46536b;
  }
  .btn-today:active {
    box-shadow: inset 0 0 0 1.5px var(--primary), inset 0 1px 0 rgba(255, 255, 255, .6);
    color: var(--primary);
  }
}

/* ---------- 平板竖屏 / 大屏手机 ---------- */
/*
 * 手机和平板一律「整周一屏看全」，不要横向滚动。
 *
 * 原来这里写的是 .grid { min-width: 820px }，本意是"别把列挤太窄"，
 * 结果屏幕一窄就逼出横向滚动条，周六周日直接被推出屏幕外 ——
 * 用户看到的就是"课表显示不全"。
 *
 * 980px 这个分界点是算出来的：
 *   比 980px 宽 → 容器本身就 >880px，网格自然铺满，本来也不用滚；
 *   比 980px 窄 → 把 min-width 放开，7 天等分列宽，全都塞得下。
 * 这样从 320px 到 4K，任何宽度都不会再出现横向滚动。
 */
@media (max-width: 980px) {
  .wrap { padding: 14px 16px 64px; }   /* 底部 64px：给右下角的音乐控件让位，别改小 */
  /* 窄屏把卡片内缩从 6px 收到 2px，把省下的宽度还给课名 */
  .grid { min-width: 0; --card-gap: 2px; --card-gap-half: 1px; }
  .day-col { min-width: 0; }
}

/* ---------- 手机 ---------- */
@media (max-width: 720px) {
  /*
   * 手机上把玻璃统一"调薄"。
   *
   * 两个原因：
   *   1. 手机 GPU 的模糊带宽比桌面小一个量级，20px 半径的实时模糊
   *      在滚动时经常掉到 30fps 以下，表现为顶栏和课表"发飘"；
   *   2. 手机屏幕小、背景光斑被裁得只剩一小块，大半径反而看不出效果。
   * 14px 在手机上视觉上和桌面的 20px 几乎一样，开销少一半。
   */
  :root { --glass-blur: 14px; --glass-blur-thin: 10px; }

  /*
   * 光斑静止。三个 40vw 的模糊圆在手机上做位移动画，
   * 会持续占用合成线程，和滚动抢资源。停在初始位置就够了 ——
   * 静态的渐变照样能让玻璃透出层次。
   */
  .bg-mesh .blob { animation: none; }
  .bg-mesh .blob { filter: blur(46px); }

  /* --- 顶栏 --- */
  .appbar {
    gap: 10px;
    padding: 10px 14px;
    padding-top: calc(10px + env(safe-area-inset-top, 0px));
    padding-left: calc(14px + env(safe-area-inset-left, 0px));
    padding-right: calc(14px + env(safe-area-inset-right, 0px));
  }
  .appbar-title { display: none; }
  .appbar-logo { width: 34px; height: 34px; }
  .user-chip { padding: 5px 10px; margin-right: 0; }
  .user-id { display: none; }
  .appbar-right { gap: 6px; margin-left: auto; }
  /* 手指点得准的底线大约 38~44px；原来 34px 在手机上偏小，容易误触 */
  .btn { height: 38px; padding: 0 12px; font-size: 13px; }

  .wrap {
    padding: 12px 12px 64px;   /* 底部 64px：给右下角的音乐控件让位，别改小 */
    padding-left: calc(12px + env(safe-area-inset-left, 0px));
    padding-right: calc(12px + env(safe-area-inset-right, 0px));
    padding-bottom: calc(64px + env(safe-area-inset-bottom, 0px));
  }
  .warning-bar { margin: 10px 12px 0; font-size: 12.5px; }

  /* --- 登录页 --- */
  .login-card { padding: 30px 22px 22px; border-radius: 22px; }
  .brand-logo { width: 56px; height: 56px; margin-bottom: 12px; }
  .login-header { margin-bottom: 22px; }
  .login-header h1 { font-size: 20px; }
  .login-header p { font-size: 12.5px; }

  /*
   * 关键：输入框字号必须 ≥ 16px。
   * iOS Safari 聚焦 font-size < 16px 的输入框时，会自动把整个页面放大，
   * 用户一点密码框页面就"跳"一下、还停在放大状态，非常难受。
   * 16px 正是触发这条自动缩放的分界线。
   */
  .form-group input { height: 48px; font-size: 16px; }
  .form-group label { font-size: 13.5px; }
  .btn-login { height: 50px; font-size: 16.5px; }

  /* --- 周次导航 --- */
  .weeknav { padding: 10px 12px; gap: 8px; }
  .week-title #weekNum { font-size: 17px; }
  .week-date { font-size: 12px; }
  .nav-arrow { width: 40px; height: 40px; }   /* 放大点击区域 */
  .btn-today { display: none; }

  /* --- 周次条 --- */
  .weekstrip { padding: 9px 10px; }
  .week-chip { width: 42px; height: 36px; }

  /* --- 课表：整周一屏看全（不横向滚动）---
   *
   * 手机屏窄，7 天 + 节次列要全塞下，只能从每列里抠宽度：
   *   节次列  78px → 30px：只留节次数字，"08:00-08:45" 交给详情抽屉
   *   课程卡片        ：只留课名 + 地点，老师/周次收进抽屉
   *   字体     12.5px → 10px，并允许长课名强制断行
   *
   * ⚠ 行高绝不能在这里改：ROW_H 是 JS 里写死的 78px，课程卡片的 top/height
   *   都是按它算的像素值。CSS 这边一改 --row-h，卡片就会和网格线错位。
   *   要"看全一周"是横向的事，竖向本来就可以滚，不用动。
   */
  .grid { min-width: 0; }
  .grid-scroll { overflow-x: visible; }
  .day-col { min-width: 0; flex: 1 1 0; }
  .time-col {
    width: 28px;
    flex: 0 0 28px;
    position: static;        /* 都不滚动了，粘住就没意义了 */
  }
  .day-head { font-size: 10.5px; padding: 0 2px; }
  .section-num { font-size: 11px; }
  .section-time { display: none; }   /* 28px 宽塞不下时间点 */

  /* 课表页两边留白收窄，把省下的 12px 全给 7 天 */
  .schedule-page .wrap {
    padding-left: calc(6px + env(safe-area-inset-left, 0px));
    padding-right: calc(6px + env(safe-area-inset-right, 0px));
  }
  .grid-card { border-radius: 16px; }

  /*
   * 手机上关掉课程卡片自己的 backdrop-filter。
   *
   * 一整周最多几十张卡片，每张都挂一次实时模糊，等于让手机
   * 对几十个小矩形各做一遍背景采样 —— 滚动时肉眼可见地掉帧。
   * 好在卡片底色本身就是半透明的，下面的网格线还是会透出来，
   * 观感几乎没差别，代价却是零。玻璃感由外层 .grid-card 那一层
   * 模糊负责提供，视觉上仍然成立。
   */
  .course-card {
    padding: 3px 2px;
    border-radius: 8px;
    border-left-width: 2px;
    gap: 1px;
    -webkit-backdrop-filter: none;
    backdrop-filter: none;
    box-shadow: inset 0 1px 0 rgba(255, 255, 255, .55);
  }
  .course-card:hover {
    box-shadow: inset 0 1px 0 rgba(255, 255, 255, .55);
  }
  .course-name {
    font-size: 9.5px;
    line-height: 1.2;
    letter-spacing: -.2px;     /* 抠出一点宽度，让「高等数学」四个字能挤进一行 */
    -webkit-line-clamp: 3;
    overflow-wrap: anywhere;   /* 长课名（"形势与政策专题"）强制断行，不撑破格子 */
  }
  .course-line {
    font-size: 8px;
    line-height: 1.15;
    letter-spacing: -.1px;
    white-space: normal;       /* 默认是 nowrap + 省略号，窄格子里会变成"南校…" */
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
    text-overflow: clip;
  }
  .course-line.weak { display: none; }   /* 周次信息最不重要，先砍它 */

  /*
   * 周次条仍可横滑（20 周放不下一行），保留惯性滚动。
   *
   * 药丸流光的错峰量在手机上加倍（0.32s）：手机一屏只能看到 7~8 颗，
   * 每颗间隔 0.16s 时，屏幕上同时只有 1~2 颗在闪，看起来像"零星抽搐"；
   * 拉到 0.32s 之后同一屏能同时看到 2~3 颗在跑，才连得起来。
   * 药丸本身变小了（42px vs 桌面 40px 其实差不多，但一屏颗数少很多），
   * 错峰必须按"一屏能看到的颗数"去调，不是按总数。
   */
  .week-chip::before { animation-delay: calc(var(--i, 0) * .32s); }
  .weekstrip {
    -webkit-overflow-scrolling: touch;
    overscroll-behavior-x: contain;
  }

  /* --- 课程详情：手机上改成从底部升起的抽屉 --- */
  .modal-mask { padding: 0; align-items: flex-end; }
  .modal {
    width: 100%;
    max-width: none;
    border-radius: 24px 24px 0 0;
    padding: 22px 20px calc(20px + env(safe-area-inset-bottom, 0px));
    max-height: 82vh;
    max-height: 82dvh;
    overflow-y: auto;
    animation: sheet-up .24s cubic-bezier(.22, .68, .36, 1);
  }
  /*
   * 抽屉是滚动容器（overflow-y: auto），而镜面高光是 absolute 定位的 ——
   * 它会跟着内容一起滚走，滚到下面时高光消失、只剩上半截有，
   * 看着像渲染坏了。所以手机上干脆不要这层高光。
   */
  .modal::after { background: none; }

  /* 顶部那道小横杠：暗示这是可以下拉关闭的抽屉 */
  .modal::before {
    content: '';
    position: absolute;
    top: 8px;
    left: 50%;
    width: 38px;
    height: 4px;
    margin-left: -19px;
    border-radius: 999px;
    background: rgba(17, 32, 66, .16);
    z-index: 1;
  }
  .modal h3 { margin-top: 6px; font-size: 18px; }
  .modal-close { width: 40px; height: 40px; right: 8px; top: 6px; font-size: 24px; }
  .modal dl { grid-template-columns: 56px 1fr; gap: 10px 12px; }
  .modal dt { font-size: 13.5px; }
  .modal dd { font-size: 14.5px; }

  .empty-state { padding: 40px 16px; font-size: 13.5px; }
  .page-foot { font-size: 11.5px; }
}

@keyframes sheet-up {
  from { transform: translateY(100%); }
  to   { transform: translateY(0); }
}

/* ---------- 小屏手机（320~400px，如 iPhone SE 一代）---------- */
@media (max-width: 400px) {
  .login-card { padding: 26px 18px 18px; }
  /* 依然整周看全，只是把每列再压窄一点，宁可字小也不横滑 */
  .grid { min-width: 0; }
  .day-col { min-width: 0; }
  .time-col { width: 26px; flex: 0 0 26px; }
  .day-head { font-size: 9.5px; }
  .section-num { font-size: 10px; }
  .course-card { padding: 2px 2px; }
  .course-name { font-size: 9px; }
  .course-line { font-size: 8px; }
  .appbar-logo { width: 30px; height: 30px; }
  .btn { padding: 0 10px; font-size: 12.5px; }
  .user-name { font-size: 12px; }
}

/* ============================================================
   背景音乐控制条
   ------------------------------------------------------------
   固定在右下角，登录页和课表页共用。
   注意 z-index 要小于弹窗遮罩（100），不然打开课程详情时它会浮在弹窗上面。
   ============================================================ */

.music-dock {
  position: fixed;
  right: calc(18px + env(safe-area-inset-right, 0px));
  bottom: calc(18px + env(safe-area-inset-bottom, 0px));
  z-index: 90;
  display: flex;
  align-items: center;
  gap: 9px;
  padding: 5px 13px 5px 5px;
  border-radius: 999px;
  /*
   * 液态玻璃的四层，缺一层就退回"灰色塑料胶囊"（材质说明见文件顶部）：
   *   1 底色  .44 白 —— 再高就透不出背后的颜色，再低正文就压不住
   *   2 折射  blur(22px) + saturate(200%)，saturate 不够整块会发灰
   *   3 边缘  顶缘高光 + 一圈亮描边（在 box-shadow 里）
   *   4 镜面  上半部自上而下的柔光 + 左上斜掠的窄高光
   *
   * 为什么底色之外还要再加一道**竖向**渐变：
   * 纯色 .44 白压在深色照片上，出来是一块**均匀的中灰**，没有任何明暗梯度，
   * 眼睛立刻读成"塑料片"。真实玻璃板的上表面受光、下缘背光，总是
   * 亮→暗过渡；补上这一笔，"有厚度"的感觉才立得住。
   *
   * 2026-10-08：原来这里是 --glass-bg-strong（.78 白）+ 18px 模糊，
   * 用户反馈"像灰塑料"；同时播放时按钮上会跳三条白柱子，被明确要求去掉。
   * 两处都改了：底色降到 .44，三条柱子整块删掉（见下面 .music-toggle 那段）。
   */
  background-color: rgba(255, 255, 255, .44);
  background-image:
    linear-gradient(180deg, rgba(255, 255, 255, .34) 0%, rgba(255, 255, 255, .08) 38%, rgba(255, 255, 255, 0) 70%),
    linear-gradient(135deg, rgba(255, 255, 255, .20) 0%, rgba(255, 255, 255, 0) 44%);
  -webkit-backdrop-filter: blur(22px) saturate(200%);
  backdrop-filter: blur(22px) saturate(200%);
  border: none;
  box-shadow: var(--glass-ring),
              inset 0 1.5px 0 rgba(255, 255, 255, .95),
              inset 0 3px 12px rgba(255, 255, 255, .45),
              inset 0 -1px 0 rgba(255, 255, 255, .28),
              0 8px 26px rgba(17, 32, 66, .20);
  font-size: 12px;
  color: var(--text-2);
  user-select: none;
  transition: box-shadow .22s ease;
}
.music-dock:hover {
  box-shadow: var(--glass-ring),
              inset 0 1.5px 0 rgba(255, 255, 255, .98),
              inset 0 -1px 0 rgba(255, 255, 255, .32),
              0 12px 34px rgba(17, 32, 66, .26);
}

/* 自动播放被浏览器拦下时，冒出来的那条提示 —— 点它就开播 */
.music-tip {
  position: relative;   /* 必须的：下面 ::after 的小箭头是绝对定位，
                           少了这句会去找最近的定位祖先 .music-dock（position: fixed），
                           箭头就会飞到整个控件的最右边去 */
  flex: none;
  padding: 5px 12px;
  border-radius: 999px;
  font-size: 12px;
  color: #fff;
  background: linear-gradient(135deg, var(--primary), var(--primary-dark));
  white-space: nowrap;
  transition: filter .18s ease;
}
.music-tip:hover { filter: brightness(1.07); }
.music-tip:active { filter: brightness(.94); }
.music-tip[hidden] { display: none !important; }
.music-tip::after {
  content: '';
  position: absolute;
  right: -5px;
  top: 50%;
  width: 9px;
  height: 9px;
  margin-top: -4.5px;
  background: var(--primary-dark);
  transform: rotate(45deg);
  border-radius: 1px;
}

/* 自动播放被浏览器拦下时，轻微呼吸提示「点一下就能响」 */
.music-dock--hint { animation: music-hint 1.8s ease-in-out 3; }
@keyframes music-hint {
  0%, 100% { box-shadow: 0 6px 22px rgba(17, 32, 66, .13); }
  50%      { box-shadow: 0 0 0 7px rgba(79, 125, 243, .16), 0 6px 22px rgba(17, 32, 66, .13); }
}

.music-toggle {
  position: relative;
  flex: none;
  width: 30px;
  height: 30px;
  display: grid;
  place-items: center;
  border-radius: 50%;
  color: #fff;
  background: linear-gradient(135deg, var(--primary), var(--primary-dark));
  transition: transform .15s ease;
}
.music-toggle:active { transform: scale(.92); }
.music-toggle:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }

.music-icon { display: grid; place-items: center; }

/*
 * 播放 / 暂停的状态区分。
 *
 * ⚠ 这里原来放的是三条上下跳动的白柱子（.music-bars + @keyframes music-bar）。
 *   2026-10-08 用户明确说不要 —— 蓝底上三条白杠一直在闪，既不像"液态玻璃"，
 *   在一屏玻璃里也格外扎眼。
 *   现在改成**图标不动，只换按钮底色的深浅**：
 *     暂停 = 灰蓝（"可以点"的暗示）
 *     播放 = 亮蓝 + 更明确的顶部内高光（像一颗被点亮的玻璃珠）
 *   顺带省掉一个 0.92s 的无限动画 —— 它一直在占合成器，滚动时也是负担。
 */
.music-dock[data-state="paused"] .music-toggle {
  background: linear-gradient(150deg, #93acea, #7b95dd 52%, #6580cf);
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, .50), 0 1px 4px rgba(17, 32, 66, .18);
}
.music-dock[data-state="playing"] .music-toggle {
  background: linear-gradient(150deg, #7fa3f9, var(--primary) 52%, var(--primary-dark));
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, .62), 0 2px 10px rgba(79, 125, 243, .38);
}

/* 音量 */
.music-meter {
  display: flex;
  align-items: center;
  gap: 8px;
  cursor: pointer;
}
.music-range {
  -webkit-appearance: none;
  appearance: none;
  width: 76px;
  height: 4px;
  border-radius: 999px;
  outline: none;
  cursor: pointer;
  background: linear-gradient(
    to right,
    var(--primary) 0,
    var(--primary) var(--fill, 55%),
    #e3e8f0 var(--fill, 55%),
    #e3e8f0 100%
  );
}
.music-range::-webkit-slider-thumb {
  -webkit-appearance: none;
  width: 13px;
  height: 13px;
  border-radius: 50%;
  background: #fff;
  border: 2px solid var(--primary);
  box-shadow: 0 1px 4px rgba(17, 32, 66, .22);
  cursor: pointer;
}
.music-range::-moz-range-thumb {
  width: 13px;
  height: 13px;
  border-radius: 50%;
  background: #fff;
  border: 2px solid var(--primary);
  box-shadow: 0 1px 4px rgba(17, 32, 66, .22);
  cursor: pointer;
}
.music-range:focus-visible { outline: 2px solid var(--primary); outline-offset: 3px; }

.music-pct {
  min-width: 31px;
  text-align: right;
  font-variant-numeric: tabular-nums;
  color: var(--text-3);
}

/* 小屏：只留最必要的，别挡着课表 */
/* ============================================================
   手机上的动效降级 —— 不是审美问题，是流畅度问题
   ------------------------------------------------------------
   下面这几个都是**无限循环**动画，页面开着就一直在跑：
     bg-mesh 的三颗光斑（30s / 26s / 34s 的缓慢位移）
     周次药丸的高光带（2.8s 扫一遍）
     周次徽章的高光带（4.8s 扫一遍）

   手机上它们会持续触发合成，滚动时和滚动本身抢 GPU ——
   用户 2026-10-08 反馈的「滑动有点卡」主要来自这一类。

   停掉之后观感并不损失：光斑还在，只是不动；药丸的玻璃感也还在 ——
   玻璃的质感来自**模糊 + 描边**，不来自它在动。

   顺手把顶栏的模糊收一档（22px → 12px）：它在滚动区域顶端固定着，
   每帧都要重新算。（.grid-card 原来也在这里收档，2026-10-08 直接整体
   拿掉了 backdrop-filter，见 .grid-card 那段注释 —— 所以这个媒体查询里
   不该再有它，留着会让人以为它还有模糊。）
   ============================================================ */
@media (max-width: 720px) {
  .bg-mesh .blob { animation: none; }
  .week-pill::after { animation: none; }
  .week-chip::before { animation: none; }
  .appbar {
    -webkit-backdrop-filter: blur(12px) saturate(170%);
    backdrop-filter: blur(12px) saturate(170%);
  }

  /*
   * 表头：手机上撤掉 backdrop-filter，换成接近实心。
   *
   * 这是**单个最贵的一层** —— tools/scroll-perf-test.js 逐元素摘除法实测：
   * 只摘掉它，合成器出图从 0.621 掉到 0.401 ms/帧（省 35%），
   * 比摘掉顶栏、周次条、音乐控件任何一项都多。
   *
   * 为什么它这么贵：它不是一个元素，是**6 个**（周一到周六各一格），
   * 每一格都要各自维护一张带背景模糊的离屏图。这类成本基本是
   * "每个模糊层一份固定开销"，跟面积关系不大 —— 也就是为什么
   * 6 个 46px 高的小格子，比 393×59 的整条顶栏还贵。
   *
   * 代价很小：表头只有 46px 高的一条，底下本来就是浅色玻璃，
   * 把"半透明白 + 模糊"换成 .94 的近实心白，肉眼几乎看不出差别，
   * 但一次省掉 6 个图层。
   *
   * ⚠ 只在手机上这么做。桌面有 GPU 富余，而且桌面上模糊是设计的一部分。
   */
  .day-head {
    background: rgba(247, 249, 253, .94);
    -webkit-backdrop-filter: none;
    backdrop-filter: none;
  }
}

@media (max-width: 560px) {
  .music-dock {
    right: calc(12px + env(safe-area-inset-right, 0px));
    bottom: calc(12px + env(safe-area-inset-bottom, 0px));
    gap: 7px;
    padding: 4px 10px 4px 4px;
    /*
     * 模糊半径砍到 12px（全局是 22px）。这是个 position: fixed 的元素，
     * 滚动时浏览器**每一帧**都得对它背后的内容重做一次模糊，半径越大越贵。
     * 22px 在小屏上肉眼几乎看不出和 12px 的差别，却是掉帧的常见来源。
     * saturate 只降一档（200% → 190%）：降太多底色会发灰，
     * 而"发灰"正是这次要修的问题，不能顺手把它带回来。
     */
    -webkit-backdrop-filter: blur(12px) saturate(190%);
    backdrop-filter: blur(12px) saturate(190%);
  }
  .music-toggle { width: 28px; height: 28px; }
  .music-range { width: 54px; }
  .music-pct { display: none; }
}

/* ============================================================
   矮屏（iPhone SE 667 / 小屏安卓 640 这一档，高度 ≤ 700px）
   ------------------------------------------------------------
   这一档上登录卡片会比屏幕还高，卡片最底下的「下载」按钮正好落进
   右下角背景音乐控件占的位置 —— 实测 360x640 上点下载按钮，
   elementFromPoint 命中的是 input.music-range（音量滑轨）：
   按钮看得见、点不着。

   两件事一起做：
     1) 纵向间距整体收紧，让按钮在初始视图里就避开控件（省下约 90px）；
     2) 万一手头的设备还是放不下，.login-page 底部那 84px 留白
        保证滚到底时按钮也在控件之上。
   ============================================================ */
@media (max-height: 700px) {
  .login-page { padding-top: calc(12px + env(safe-area-inset-top, 0px)); }
  .login-card { padding: 20px 20px 14px; }
  .brand-logo { width: 44px; height: 44px; margin-bottom: 8px; }
  .login-header { margin-bottom: 14px; }
  .login-header h1 { font-size: 18px; }
  .login-header p { font-size: 12px; margin-top: 4px; }
  .form-group { margin-bottom: 12px; }
  .login-footer { margin-top: 14px; }
  .dl-box { margin-top: 12px; padding-top: 12px; }
}

@media (prefers-reduced-motion: reduce) {
  .music-dock--hint { animation: none; }
  .music-dock, .music-toggle { transition: none; }
  /* 背景光斑停在原地。它们本来就是纯装饰，动起来只会让人分心 */
  .bg-mesh .blob { animation: none; }
  /*
   * 流光和滑动一起停掉。
   * 注意不是"不显示" —— 停在动画的第 0 帧就是"光带还在屏幕外"，
   * 药丸该什么样还什么样，只是不动。滑动的 pill 同理：把过渡去掉，
   * 它仍然会准确地停在选中周次上，只是直接跳过去。
   * 前庭功能敏感的用户对持续位移动画反应很大，这条是硬要求，别删。
   */
  .week-chip::before,
  .week-pill::after { animation: none; }
  .week-pill { transition: none; }
}
