/* ============================================
   style.css —— 英语学习工具 全站样式
   改进原则：只动「视觉层」，不动 HTML 结构、不动 JS 逻辑。
   基调不变：仍是「工具感」，深绿强调色 #0f6e56 保留；Day 31 晚：中性色系整体转暖
   （冷白 → 象牙暖白），背景光斑同步换成高饱和暖色系，动效本身一行未动。
   ⚠️ Day 32（斌哥指示）：默认色调改回【冷白】—— 冷调更贴品牌深蓝 #1a2c73。
      暖白不作废，降级成右栏「配色」里的可选档（原值一个没动，只在块之间搬位置）。
   字重仍守 400 / 500 两档（作者约定），层级靠字号·颜色·间距·装饰建立。
   ============================================ */

:root {
  /* ---- 原生控件配色（Day 32：手机深色模式的护栏）----
     ⚠️ 这一行是「站内选浅色，手机深色也不许变」的【唯一技术手段】：
        页面没声明 color-scheme 时，安卓 Chrome 的「深色模式强制反色」
        （Auto Dark Theme）会把整页算法压暗 —— 用户站内明明选的是浅色，
        手机上看到的却是被篡改过的深色，正是要杜绝的那种「变化」。
        声明 light 等于告诉浏览器：这页只支持浅色，别自作主张。
     ⚠️ 别改成 <meta name="color-scheme" content="light dark">：
        那是让浏览器【按系统】选，恰好和「站内选浅色就锁浅色」相反。
     ⚠️ 深色主题那边会覆盖成 dark，见 html[data-theme='dark'] 块。 */
  color-scheme: light;

  /* ---- 颜色（原有，名字不动，保证旧规则不失联）---- */
  --bg: #f6f2ea;            /* 页面底色：象牙暖白（⚠️ Day 32 起这套是【暖】档的兜底值；默认档走下方 data-tint='cool' 块） */
  --surface: #fffdf8;       /* 卡片底色：暖白（原纯白） */
  --border: #e7e0d3;        /* 分隔线与卡片描边（暖灰米） */
  --text: #16181d;          /* 正文：接近纯黑 */
  /* Day 9：这三档是「信息层级」的骨架。
     原来 muted(#56606b) 与 faint(#64707c) 在白底上只差 1.26×（6.40 vs 5.06），
     肉眼几乎读不出主次 —— 等于两档并成了一档。
     字重只有 400/500（作者约定），字号阶梯前面已经修过，所以层级唯一的抓手就是颜色。
     改后白底 17.76 / 7.67 / 5.37，两段比值 2.31× 与 1.43×，三档各站得住。
     ⚠️ faint 一并从 #64707c 提到 #606c77，不是为了好看 —— 是因为它在绿底
        --ok-bg(#e1f5ee) 上原本只有 4.46:1，**低于 AA**；提到 4.73:1 才达标。
        这一步顺带解掉了「当前行 12px 小字压过 16px 标题」的倒挂，
        所以下面 .file-btn[aria-current] .file-meta 与 .seg.is-playing .seg-time
        那两条补救规则已删除。 */
  --text-muted: #4c545e;    /* 次要正文（原 #56606b） */
  --text-faint: #606c77;    /* 说明、页脚（原 #64707c，为绿底达标而加深） */
  --accent: #1a2c73;        /* 强调色：品牌深蓝（2026-10-20 由深绿 #0f6e56 更换，原值见 Day 31 注释） */
  --ok-bg: #e3ecfa;         /* 「可用」标签底色（原薄荷绿 #e1f5ee，随强调色转蓝） */
  --ok-text: #1a2c73;       /* 「可用」标签文字 */
  --todo-bg: #f1ecdf;       /* 「开发中」标签底色（暖米） */
  --todo-text: #5f6b76;     /* 「开发中」标签文字 */

  /* ---- 品牌色（Day 8 换新 logo 后，按新图重新实测）----
     来源：assets/img/logo.png（四瓣风车）。像素统计显示琥珀橙与深蓝
     几乎各占一半（2663 : 2646），这两个色就是品牌的两根柱子。
     ⚠️ 旧值 #0d2136（深海军蓝）/#973f3b（砖红）属于上一版「星禾双语」logo，
        已作废。砖红那个变量原本全项目无人使用，就地改造成琥珀橙。 */
  --brand-navy: #1a2c73;       /* logo 的深蓝（新图实测主色） */
  --brand-amber: #edaf32;      /* logo 的琥珀橙（新图实测主色）—— 悬停时四个字吃到这个色 */
  --brand-navy-lift: #2943a6;  /* 品牌名渐变起点：深蓝同色系提亮一档（HLS 算出来的） */
  --brand-navy-deep: #152254;  /* 品牌名渐变终点：深蓝同色系压深一档（HLS 算出来的） */

  /* 悬停时用的最深一档阴影（新增） */
  --shadow-lg: 0 10px 22px -8px rgba(16, 24, 40, .16),
               0 22px 44px -20px rgba(16, 24, 40, .18);

  /* ---- 颜色（新增：层次与质感，全部由上面那套推导而来）---- */
  --accent-strong: #14225a;               /* 悬停/按下时更深的蓝（原更深的绿 #0b5a46） */
  --border-strong: #d8cfbe;               /* 悬停时的描边（暖） */
  --surface-sunken: #faf6ee;              /* 「开发中」卡片的降权底色（暖） */
  --rule: #eee8dc;                        /* 页头装饰线（暖） */
  --focus-ring: #1a2c73;                  /* 键盘焦点环 */

  /* ---- 明暗主题补充位（Day 31）----
     下面这几个原来是直接写死在规则里的，暗色要能用就得先收成 token：
       --surface-hover  悬停时的浅底
       --bg-glow        页面正中那道白光
       --blob-a..d      四个流动色块的颜色
       --accent-fill    填充按钮的底色（⚠️ 与 --accent 分开：暗色下强调色要提亮才看得清，
                        可填充按钮一提亮，白字就压不住了，两者不能共用一个值）*/
  --surface-hover: #f6f1e7;
  /* Day 31 晚：光斑转暖色系并提亮度饱和度（琥珀/陶土/金/橄榄绿），
     位置动效一行未动 —— 「保留背景动态效果」只换颜色不换动画 */
  --bg-glow: rgba(255, 244, 214, .9);
  --blob-a: rgba(232, 168, 68, .42);
  --blob-b: rgba(224, 122, 80, .38);
  --blob-c: rgba(238, 172, 74, .34);
  --blob-d: rgba(146, 168, 66, .30);
  --accent-fill: #1a2c73;
  --accent-fill-strong: #14225a;
  --text-ghost: #a39c8f;      /* 占位符 / 极弱说明字（暖灰） */
  --surface-inset: #f1ecdf;   /* 内嵌块（比卡片再深一档，暖米） */

  /* ---- 悬停配色（Day 31 晚）----
     .item:hover / .icon-btn:hover 原来把底色写死成浅薄荷 #f2faf6，
     暗色下没有覆盖 → 悬停变白（斌哥指出）。收进变量后四种配方各给协调值：
       浅暖（这里）/ 浅冷（cool 块沿用同一值，不重定义）
       暗冷（暗色块 #26303d）/ 暗暖（暗暖块 #332d24）*/
  --item-hover-bg: #f2faf6;             /* 悬停底色：淡薄荷（原写死值） */
  --hover-ring: rgba(26, 44, 115, .12); /* 悬停外环：强调蓝（原强调绿 rgba(15,110,86,.12)） */

  /* ---- 尺寸（原有）---- */
  --radius-card: 16px;
  --radius-pill: 999px;
  /* ⚠️ Day 10：720 是【跟读页】那种长文页的宽度（设计规则里这一档叫 .container-narrow）。
     首页是「三张卡片」的入口页，属于另一种页面类型 —— 它在大屏下被单独放宽到 1280，
     见文件下方 @media (min-width: 1000px) 首页那一段的说明。这里保持 720 不动，
     因为它是【跟读页】要用的值，且是窄屏（<1000px）下首页也要用的值。 */
  --content-width: 720px;
  --tap-size: 44px;                       /* 触控目标 ≥ 44x44 */

  /* ---- 间距：4px 网格（新增，统一原先硬编码的 4/6/12/18/20）---- */
  --space-1: 4px;
  --space-2: 8px;
  --space-3: 12px;
  --space-4: 16px;
  --space-5: 20px;
  --space-6: 24px;
  --space-8: 32px;

  /* ---- 字号层级 ----
     Day 9：原来这里是 12 / 13 / 15 / 16 / 17 / 18 —— 每档只差 1px。
     1px 的差别肉眼分辨不出来，"标题"和"正文"看着一样大，层级等于白排。
     现在对齐设计规则的 1.25 字阶（Major Third）：12 / 14 / 16 / 18 / 20，
     相邻至少差 2px，层级才读得出来。
     ⚠️ 顺手删掉了 --text-md：规则里 16px 这一档就叫 --text-base（正文），
        没有 md 这一档。而项目里 base 却是 15px —— 名字和值对不上，
        以后照规则写 var(--text-base) 期待 16px，实际会拿到 15px，是个隐患。
     ⚠️ 原来用 --text-md 的两处（.file-name / .seg-text）已改回 --text-base，值不变。 */
  --text-xs: 12px;
  --text-sm: 14px;
  --text-base: 16px;
  --text-lg: 18px;
  --text-xl: 20px;
  --text-2xl: 28px;                       /* ⚠️ 有意偏离规则（规则 2xl=24）：页头标题撑首屏，Day 7 定的，保留 */

  /* ---- 阴影：冷灰调、低透明度（新增）---- */
  --shadow-xs: 0 1px 2px rgba(16, 24, 40, .04);
  --shadow-sm: 0 1px 2px rgba(16, 24, 40, .05), 0 2px 6px -2px rgba(16, 24, 40, .06);
  --shadow-md: 0 4px 10px -3px rgba(16, 24, 40, .10), 0 10px 24px -10px rgba(16, 24, 40, .12);

  /* ---- 动效（新增）---- */
  --duration-fast: 150ms;
  --duration-normal: 220ms;
  /* Day 11：首页卡片那套"圆"专用的两档。
     ⚠️ duration-dissolve 要和 home.js 里的 DISSOLVE_MS 对上 ——
        CSS 只管每次变化【用多久】，"什么时候跳走"由 JS 定时。 */
  --duration-slow: 280ms;      /* 光标处那个「透视窗」：浮出 / 收回 */
  --duration-dissolve: 200ms;  /* 点击后卡片溶解：圆扩大盖满 + 文字淡出（他说要"快一点"） */
  --ease-out: cubic-bezier(0, 0, .2, 1);
}

/* ============================================
   冷白配色（默认档）：html[data-tint="cool"]
   ------------------------------------------------------------
   Day 32 起这一块就是【默认】长相：冷白底 + 纯白卡片 + 冷色光斑。
   为什么改回来：冷调才配得上品牌深蓝 #1a2c73（logo 的主色之一），
   暖白那版和品牌两色放在一起，冷暖打架。
   :root 里那套暖白（象牙底 + 橙色光斑）保留成【暖】档兜底值，
   用户点右栏「配色 · 暖」时贴 data-tint="warm"，没有 cool 块压着就露出来。
   取值全部来自 Day 31 转暖之前的 :root 原值，一个没改。

   ⚠️ 本块必须放在 html[data-theme='dark'] 块【之前】：
      两者特异性相同（0,1,1），靠文件顺序让暗色在「暗色 + 冷白」时赢 ——
      否则暗色用户会漏出一堆冷白变量把暗底冲花。
      （已核对：dark 块重定义了本块全部变量，覆盖是完整的。）
   ============================================ */
html[data-tint='cool'] {
  --bg: #f4f5f7;              /* 页面底色：冷白 */
  --surface: #ffffff;         /* 卡片底色：纯白 */
  --border: #dfe1e6;
  --todo-bg: #eceef1;
  --border-strong: #cfd3da;
  --surface-sunken: #fafbfc;
  --rule: #e6e8ec;
  --surface-hover: #f7f8fa;
  --bg-glow: rgba(255, 255, 255, .8);
  --blob-a: rgba(45, 92, 196, .40);   /* 冷色光斑：蓝 */
  --blob-b: rgba(56, 168, 196, .36);  /* 青 */
  --blob-c: rgba(124, 96, 224, .34);  /* 紫 */
  --blob-d: rgba(22, 138, 108, .28);  /* 绿 */
  --text-ghost: #a0a8b2;
  --surface-inset: #f1f2f4;
}

/* ============================================
   浅色·暖 —— 强调色跟随（Day 31，斌哥拍板）
   ------------------------------------------------------------
   根因：浅色·暖档原本没有自己的强调色，靠 :root 兜底；
   而 :root 的 --accent 在换品牌深蓝时把暖档一起「染蓝」——
   药丸 / 徽标 / 开始→ 切到暖档仍是冷的深蓝（斌哥三张截图指认）。
   修法：与暗色·暖块同一根思路，给浅色·暖一套琥珀系强调色，
   全局 token 一处改，全站吃 --accent 的元素一起跟随。
   取值：#8f5e1c = 品牌琥珀 #edaf32 加深，象牙底上对比 ≈5.1:1 过 AA。

   ⚠️ 本块特异性 (0,1,1)，与 cool 块同档但属性值不同、互不干扰；
      暗色块在其后声明（同特异性靠文件顺序），暗色下仍由暗色块接管；
      暗+暖另有 (0,2,1) 专属块，稳赢 —— 三种暗色组合均不受本块影响。
   ============================================ */
html[data-tint='warm'] {
  --accent: #8f5e1c;                       /* 强调色：琥珀深金 */
  --accent-strong: #7a4e14;                /* 悬停/按下更深一档 */
  --accent-fill: #8f5e1c;                  /* 填充按钮底（白字对比 ≈5.5:1） */
  --accent-fill-strong: #7a4e14;
  --focus-ring: #b8791f;                   /* 焦点环：亮一档琥珀 */
  --ok-bg: rgba(224, 164, 78, .18);        /* 标签底：琥珀半透明（同暗色·暖配方） */
  --ok-text: #8f5e1c;
  --hover-ring: rgba(224, 164, 78, .16);   /* 悬停外环跟着转暖 */
}

/* ============================================
   暗色主题（Day 31）
   ------------------------------------------------------------
   触发方式只有一种：<html data-theme="dark">。
   整套设计 token 换一遍，下面两千多行规则里【一行都不用改】——
   这也是前几轮把颜色往变量里收的回报。

   ⚠️ 强调色与填充色必须分开：
      暗底上 --accent 要提亮（#5b9dff，深海蓝）才有对比；可填充按钮一提亮，
      压在上面的白字就糊了。所以按钮底色用 --accent-fill（#2258c9），停在中等深度。
   ⚠️ 阴影在暗色下不能再用"淡灰投影"（看不见），改成纯黑更重用低透明度。
   ============================================ */
html[data-theme='dark'] {
  /* 深色主题下才允许浏览器把原生控件（滚动条/输入框/下拉菜单）也画成暗的。
     浅色时不声明 dark —— 那正是「站内浅色、手机深色」不该变色的一半原因。 */
  color-scheme: dark;

  /* Day 31 晚二改（斌哥指示）：暗底从深海蓝 #101319 提亮成石墨蓝灰 ——
     「灰色调、不需要太暗、柔和舒适」。色相不变，只抬亮度、降饱和。
     强调色仍是深海蓝系（冷调暗色的魂）。 */
  --bg: #1b202a;
  --surface: #242a35;
  --surface-sunken: #1e232d;
  --surface-hover: #2d3442;
  --border: #343c4d;
  --border-strong: #465064;
  --rule: #303746;
  --text: #e9edf4;
  --text-muted: #a7b0be;
  --text-faint: #8b95a4;
  --accent: #5b9dff;
  --accent-strong: #82b6ff;
  --accent-fill: #2258c9;
  --accent-fill-strong: #2f6ae0;
  --text-ghost: #6f7885;
  --surface-inset: #29303d;
  --focus-ring: #5b9dff;
  --ok-bg: rgba(91, 157, 255, .14);
  --ok-text: #8fc0ff;
  --todo-bg: #2f3745;
  --todo-text: #98a2b1;
  --brand-navy: #8fa8f0;
  --brand-navy-lift: #a9bcff;
  --brand-navy-deep: #6d86d6;
  --brand-amber: #edaf32;
  --bg-glow: rgba(120, 170, 255, .14);
  --blob-a: rgba(45, 92, 196, .36);
  --blob-b: rgba(56, 168, 196, .28);
  --blob-c: rgba(124, 96, 224, .30);
  --blob-d: rgba(91, 157, 255, .24);
  --shadow-xs: 0 1px 2px rgba(0, 0, 0, .42);
  --shadow-sm: 0 1px 2px rgba(0, 0, 0, .42), 0 2px 6px -2px rgba(0, 0, 0, .38);
  --shadow-md: 0 4px 10px -3px rgba(0, 0, 0, .46), 0 10px 24px -10px rgba(0, 0, 0, .42);

  /* 悬停配色（Day 31 晚）：底色/外环收进变量 ——
     原来这两处写死浅薄荷 #f2faf6，暗色下悬停直接变白（斌哥指出）。
     暗冷 = 蓝灰悬停底 + 深海蓝外环。 */
  --item-hover-bg: #26303d;
  --hover-ring: rgba(91, 157, 255, .16);
}

/* Day 31 晚：暗色·暖 —— 暖炭灰底 + 琥珀强调色。
   跟浅色的暖白同一根思路（tint='warm'），冷暖语义全模式一致：
   cool = 冷白/石墨蓝灰，warm = 象牙暖白/暖炭灰。
   特异性 (0,2,1) 高于单属性的暗色块，稳赢。 */
html[data-theme='dark'][data-tint='warm'] {
  --bg: #201d18;
  --surface: #292520;
  --surface-sunken: #232019;
  --surface-hover: #332e26;
  --border: #3a342b;
  --border-strong: #4c4437;
  --rule: #2e2a22;
  --text: #f0ece4;
  --text-muted: #b0a99a;
  --text-faint: #8f8878;
  --accent: #e0a44e;
  --accent-strong: #edb96a;
  --accent-fill: #a6722a;
  --accent-fill-strong: #b8812f;
  --text-ghost: #756e60;
  --surface-inset: #2e2a23;
  --focus-ring: #e0a44e;
  --ok-bg: rgba(224, 164, 78, .16);
  --ok-text: #ecc27c;
  --todo-bg: #302b23;
  --todo-text: #a89f8e;
  --bg-glow: rgba(255, 214, 140, .12);
  --blob-a: rgba(232, 168, 68, .30);
  --blob-b: rgba(224, 122, 80, .26);
  --blob-c: rgba(238, 172, 74, .24);
  --blob-d: rgba(146, 168, 66, .20);
  --item-hover-bg: #332d24;
  --hover-ring: rgba(224, 164, 78, .16);
}

/* ⚠️ Day 11：--r 必须【注册】成 <length>，那个圆形空洞才能平滑开合。
   为什么：圆的使用者是 .board::after，它靠 mask-image 里的 radial-gradient 挖洞，
   而渐变本身【不可插值】—— --r 一变，mask 直接跳到新值，看着就是"啪"一下糊上去。
   注册成真正的时间/长度型属性之后，浏览器才把它当可动画的值来补间，
   每一帧算出一张新渐变 —— 视觉上就是圆在慢慢长开（实测能取到 50 多个中间帧）。
   inherits: true 是必须的：--r 写在 .board 上，用它的是它的 ::after。 */
@property --r {
  syntax: "<length>";
  inherits: true;
  initial-value: 0px;
}

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

/* ⚠️ 竖向滚动条【常驻】（2026-09-30 修「点开音频后整列平移」）

   症状（量出来的，不是感觉）：
     窗口 1170px 宽时，点开音频前居中列左边缘 = 225.00px；
     点开后 = 217.50px —— 整列**左移了 7.5px**。
     窄窗口（504px）下不左移，但内容宽度从 472px 掉到 457px，右侧整块缩窄 15px。

   根因：
     main { max-width: 720px; margin: 0 auto } 是在「可用宽度」里居中的。
     一点开音频，16 句铺出来 → 页面变高 → 竖向滚动条冒出来占 15px
     → 可用宽度少 15px → 居中列跟着挪半条滚动条（15 ÷ 2 = 7.5px）。

   修法：
     让滚动条【一直存在】，可用宽度就恒定，居中列也就钉死不动。
     滚动条本身是跟随内容自动禁用的（页面短的时候滑块满格且不可拖），
     但它占的那 15px 位置一直在 —— 恰恰是我们要的。

   代价（这是有意识的取舍，不是没看见）：
     页面很短、根本不需要滚动时，右边也会挂着一条滚动条轨道。
     这是为了换取「点进去不跳」付出的代价。

   为什么没用 scrollbar-gutter: stable：
     那个更"现代"、页短时不留轨道，但它是 Chrome 94 / Firefox 97 才有的；
     且 safari 支持更晚。斌哥选了 overflow-y: scroll，兼容性最好、行为最可预期。

   ⚠️ 必须写在 html（根元素）上，不能写 body：
     根元素上的 overflow 会传播到视口，才管得住整页的滚动条。
     写到 body 上在某些情况下会被浏览器忽略掉这条传播规则。 */
html {
  overflow-y: scroll;
  /* Day 9：删掉手机端点按链接/按钮时浏览器自带的那层蓝色高亮。
     它默认是 rgba(51,181,229,.4)，是浏览器合成器直接画的覆盖层 ——
     全站 CSS 里搜不到它、也改不了它的颜色，只能整个关掉。
     ⚠️ 必须写在 html（根元素）上：这条属性是可以继承的，
        写在根上，所有后代元素一次性生效，不用逐个元素补。
     代价（这是有意识的取舍）：关掉后所有元素都没了这层默认反馈。
     全站另外 5 处（.board-link / .back-link / .file-btn / .icon-btn /
     .btn）都写了自己的 :active，只有句子行 .seg 漏着 —— 所以下面补了一条。 */
  -webkit-tap-highlight-color: transparent;
}

body {
  min-height: 100vh;
  padding: 24px 16px 40px;
  /* 背景原本写在这里：底色（--bg）＋ 页顶一层极淡的绿光晕。
     ⚠️ Day 10 加全站动态背景后，底色改挂到 html、光晕撤掉了 —— 见文件底部
        「全站动态背景」那一段。所以这里**不再声明 background**，免得后人
        以为这层背景还生效，去改一个已经被覆盖掉的值。 */
  color: var(--text);
  font-family: system-ui, -apple-system, "Segoe UI", "PingFang SC",
    "Microsoft YaHei", sans-serif;
  line-height: 1.7;
  -webkit-text-size-adjust: 100%;
  -webkit-font-smoothing: antialiased;    /* 新增：冷白底上文字更利落 */
}

h1,
h2,
h3 {
  font-weight: 500;                       /* 保持作者约定：只用 400 / 500 */
  letter-spacing: -0.01em;
}

/* Day 9：规则要求【标题行高 1.2–1.4、正文 1.6–1.8】—— 两套值，不能共用。
   但 body 上那条 line-height: 1.7 会被所有后代继承，标题也在继承它，
   等于把标题当正文排了。实测 h1 在 32px 下是 32 × 1.7 = 54.4px 行高，排得发散。
   这里只把标题收回来；正文继续用 body 的 1.7（在规则区间内）。
   ⚠️ 没并进上面那条 font-weight 规则：那条带 letter-spacing: -0.01em，
      合并会把标题字间距也一起改掉，那是另一件事，不该顺手做。
   ⚠️ 素材卡标题 .mat-title 就是个 <h2>（见 app.js 第 143 行），已被覆盖，不必单列。 */
h1,
h2,
h3 {
  line-height: 1.3;
}

/* 新增：统一键盘焦点可视化 —— 只对键盘用户显示，鼠标点击不冒圈 */
:focus-visible {
  outline: 2px solid var(--focus-ring);
  outline-offset: 3px;
  border-radius: 4px;
}

/* ---- 页头 ---- */

.site-header {
  max-width: var(--content-width);
  margin: 0 auto 24px;
  padding-bottom: var(--space-4);         /* 新增 */
  position: relative;                     /* 新增：给装饰线定位 */
}

/* 新增：标题下方的渐隐线 —— 把"标题区"和"卡片区"分开，首屏不再是一块散沙 */
.site-header::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 1px;
  background: linear-gradient(90deg, var(--rule) 0%, var(--rule) 45%, transparent 100%);
}

.site-header h1 {
  margin-bottom: 4px;
  font-size: var(--text-2xl);             /* 24 → 28 */
  letter-spacing: -0.02em;                /* 大字号收紧一点，更稳重 */
}

/* ---- 品牌区（新增）：logo + 站名 ---- */

.brand {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  margin-bottom: var(--space-2);
}

.brand-logo {
  flex: none;
  display: block;
  width: 40px;
  height: 40px;
  transition: transform .4s var(--ease-out);
}

/* 鼠标移到品牌区，logo 轻轻歪一下 —— 一点俏皮，不夸张 */
@media (hover: hover) {
  .brand:hover .brand-logo {
    transform: rotate(-8deg) scale(1.06);
  }
}

/* 站名：不用系统字体，改用【原创 logo 图里取出来的字形】本身。
   为什么这么干：
     ① 图里那种「方块几何黑」系统里没有。随便找个近似的替代 = 把品牌字改掉了；
     ② 这是公开站点 —— 系统字体只在本机有效，别人的设备没装就掉回默认字体。
   于是把字形做成 assets/img/wordmark.png（白底键成 alpha 的蒙版图），用 CSS 蒙版显示：
   形状由蒙版锁死，颜色交给 CSS 的 background-color —— 形状不变，颜色却能平滑过渡。
   ⚠️ 蒙版图只用 alpha 通道，颜色由 CSS 给。所以同一张图能是深蓝，也能是琥珀橙。
   ⚠️ 这是同一天里第二次改这里：上一版是「系统字体 + 渐变裁字」
      （-webkit-background-clip: text），因为字体跟原图对不上，已废弃。 */
.brand-name {
  --wm-h: 20px;                 /* 字形显示高度；随断点放大，见下方媒体查询 */
  display: inline-flex;         /* flex 会吃掉 span 之间的空白，四个字不会裂缝 */
  align-items: center;
  /* 极淡的深色投影托一下，让字从底色上"站"起来（色取自新 logo 的深蓝） */
  filter: drop-shadow(0 1px 1px rgba(26, 44, 115, .18));
}

/* 四个字的「窗」。
   四扇窗共用同一张字形图：每扇窗只有约 1/4 宽，靠 mask-position 把整行字
   平移到窗后面，每扇窗就只露出属于自己那个字的一段。
   ⚠️ 为什么开窗、而不是把图切成四块：开窗时四个字共享同一张图，
      笔画在接缝处天然连续；切成四块的话，接缝处会露茬。
   ⚠️ 尺寸用 calc 按 --wm-h 等比换算。字形原图 84 宽 × 20 高（已按墨迹收紧，
      上下左右不留多余空白），所以整行宽 = 高度 × (84/20)，窗宽与偏移同理。 */
.brand-char {
  display: block;               /* 必须是块级：transform 对 inline 元素不生效 */
  height: var(--wm-h);
  background-color: var(--brand-navy);
  -webkit-mask-image: url("../img/wordmark.png");
          mask-image: url("../img/wordmark.png");
  -webkit-mask-repeat: no-repeat;
          mask-repeat: no-repeat;
  -webkit-mask-size: calc(var(--wm-h) * 84 / 20) var(--wm-h);
          mask-size: calc(var(--wm-h) * 84 / 20) var(--wm-h);
  transition: transform .32s var(--ease-out),
              background-color .32s var(--ease-out);
}

/* 开窗边界 19 / 41 / 62 是量出来的：那三列的像素密度为 0，是真空隙，
   所以「抬起」时绝不会把一个字的笔画拦腰截断。
   ⚠️ 每扇窗把自己的那格空隙也算进去，四格首尾相接正好拼回整行，间距与原图一致。 */
.brand-char:nth-child(1) {
  width: calc(var(--wm-h) * 19 / 20);
  -webkit-mask-position: 0 0;
          mask-position: 0 0;
  transition-delay: 0s;
}
.brand-char:nth-child(2) {
  width: calc(var(--wm-h) * 22 / 20);
  -webkit-mask-position: calc(var(--wm-h) * -19 / 20) 0;
          mask-position: calc(var(--wm-h) * -19 / 20) 0;
  transition-delay: .07s;
}
.brand-char:nth-child(3) {
  width: calc(var(--wm-h) * 21 / 20);
  -webkit-mask-position: calc(var(--wm-h) * -41 / 20) 0;
          mask-position: calc(var(--wm-h) * -41 / 20) 0;
  transition-delay: .14s;
}
.brand-char:nth-child(4) {
  width: calc(var(--wm-h) * 22 / 20);
  -webkit-mask-position: calc(var(--wm-h) * -62 / 20) 0;
          mask-position: calc(var(--wm-h) * -62 / 20) 0;
  transition-delay: .21s;
}

/* ---- 逐字互动 ----
   鼠标移到品牌区时，四个字从「星」到「穗」依次变成 logo 的琥珀橙，各自抬起 2px。
   不设延迟的话四个字齐刷刷一起变，很生硬；依次扫过去才合得上风车转的节奏。
   ⚠️ 延迟写在上面各条基础规则里，不能写在 :hover 里 ——
      写在 :hover 里的话，鼠标一移开延迟立刻失效，四个字会一起弹回去，白做。
   ⚠️ 这里能用 transform 了（元素是块级）；上一版因为要保持 inline 只能用 top。 */
@media (hover: hover) {
  .brand:hover .brand-char {
    transform: translateY(-2px);
    background-color: var(--brand-amber);
  }
}

@media (min-width: 600px) {
  .brand-name { --wm-h: 22px; }
}

@media (min-width: 900px) {
  .brand-logo { width: 46px; height: 46px; }
  .brand-name { --wm-h: 24px; }
}

/* ---- 主体 ---- */

main {
  max-width: var(--content-width);
  margin: 0 auto;
}

/* ---- 页脚（Day 26 新增）----

   目前只承载一个东西：版本标记（见 index.html 里的 .site-foot）。
   为什么值得为它单开一节：发布是【覆盖式】的、回滚要能"肉眼验证"——
   页面上没有版本号，"退回上一版"这一步前后没有任何可见差别，
   截图就证明不了任何事。
   刻意保持低调：--text-faint 本来就是"说明、页脚"那一档，字号用最小档。
   不设 max-width —— 它是居中文字，而 main 本身就是居中的，中心线天然对齐，
   宽屏（≥1000px，首页放宽到 1280）下也一样对得上，不必再写一条媒体查询。 */
.site-foot {
  margin-top: var(--space-8);
  text-align: center;
  font-size: var(--text-xs);
  color: var(--text-faint);
  letter-spacing: .08em;                  /* 小字号全小写字母挨太紧，拉开一点 */
}

/* ============================================
   可复用组件：点击条目（.item）（Day 8 余力加练）
   ------------------------------------------------------------
   给元素加一个 class="item"，它就拿到「一个点得动的方框」该有的那身皮。
   目前两个消费者：
     .board     首页的板块卡片（<li>，网格里的一格）
     .file-btn  跟读页的文件行（<button>，面板里的一条）
   以后新页面要做"可点的卡片 / 列表行"，加这个类就行，不必把那几行再抄一遍。

   ★ 它【负责】什么（改这里一处，两个消费者一起变）：
       ① 皮：底色 / 描边 / 圆角 / 那层浅浅的浮起阴影
       ② 过渡：上面这些属性和位移怎么变过去
       ③ 选中态：被选中时长什么样

   ★ 它【不负责】什么（各家自己写，是故意不合并的）：
       ① 尺寸与排布（padding、宽度、一行还是两行）
       ② 悬停手感 —— 卡片是「抬 3px ＋ 描边染色 ＋ 阴影加深」，
          文件行只是「描边变深 ＋ 抬 1px」，这两者力度本来就不同
       ③ 按下手感 —— 卡片是「往下压 ＋ 微微收缩」，文件行只是「变淡」
     为什么②③不塞进来：一是特异性会打架（基类的 :not() 反而会盖掉卡片自己的悬停），
     二是会出实打实的视觉 bug —— 卡片按下时，.board-link 自己淡到 .78，
     基类再淡到 .7，一叠就是 .55，比现在明显更暗。
     所以基类只管「静止时什么样」和「选中时什么样」，动起来各家自己演。

   过渡时长的取舍：颜色类用 fast（150ms，跟手），位移和阴影用 normal
   （220ms）—— 位移幅度大一点，慢一点才显得稳，不会"啪"地跳过去。 */
.item {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-card);
  box-shadow: var(--shadow-xs);
  transition: border-color var(--duration-fast) var(--ease-out),
              background-color var(--duration-fast) var(--ease-out),
              color var(--duration-fast) var(--ease-out),
              box-shadow var(--duration-normal) var(--ease-out),
              transform var(--duration-normal) var(--ease-out);
}

/* 选中态。⚠️ 视觉只有一套，语义仍是两个，别混为一谈：
     aria-pressed="true" —— 互斥开关被按下了（等级 L1/L2/L3 那种）
     aria-current="true" —— 当前打开的就是它（文件列表那种）
   两个属性读屏软件念出来的话不一样，含义不同；但"看起来被选中"的样子
   确实一模一样，所以这一层皮可以共用。 */
.item[aria-pressed='true'],
.item[aria-current='true'] {
  border-color: var(--accent);
  background: var(--ok-bg);
}

/* ---- 板块入口列表 ---- */

.boards {
  display: grid;
  grid-template-columns: 1fr;
  gap: 12px;
  list-style: none;
}

/* 皮（底色 / 描边 / 圆角 / 浮起阴影 / 过渡）全部来自上一节的 .item ——
   页面上每张卡片都带 class="board item"（由 home.js 生成）。
   这里只留「卡片自己的尺寸与排布」，不再重复那五行 */
.board {
  position: relative;                     /* 给左侧强调条定位 */
  /* Day 9：原来是 18px 20px —— 同一个盒子上下 18、左右 20，自己就不一致。
     统一成 20（--space-5），回到 4px 网格。大屏同理，见文末媒体查询。 */
  /* Day 11：内边距从这一层【搬到了 .board-link 身上】，这里只留一个变量。
     起因：卡片的内边距区一直是【点击死区】—— 链接层只盖住内容，
     那圈 padding（手机 20px / 大屏 32px）点上去毫无反应。
     让链接自己带内边距，它就从"内容区"长成了"整张卡片"，热区自然铺满。
     ⚠️ 大屏那档（≥1000px）在文末改的是这个【变量】，不是 padding 属性 ——
        以后谁要调卡片内边距，改这一处、以及文末那一处，改的是同一个词。 */
  --board-pad: var(--space-5);
  /* Day 11：卡片的「皮」（白底 + 描边）搬走了 —— 搬到了下面的 .board::after 身上。
     起因：他要求"圆里的颜色不外加，而是直接透出背景原来的颜色"，
     也就是要在皮上【挖一个洞】。而一个元素没法只挖掉自己背景的一块，
     只能把皮单独画在一层上，再去挖那一层（手段见 .board::after 那段）。
     所以这里把 .item 给的那两样就地抹成透明。
     ⚠️ 只改颜色、不改宽度：描边那 1px 仍然占位，卡片尺寸与改动前【一模一样】。 */
  background: transparent;
  border-color: transparent;
  /* 卡片仍是【独立的堆叠上下文】—— 这里现在是【必需】的，有两个原因：
     ① 给下面 .board::after 的 z-index: -1 一个落脚点。皮要压在卡片自己的背景之上、
        又要垫在左侧绿条和内容之下，只能靠负 z-index；这层堆叠上下文一撤，
        负层会一路掉到页面背景那边去（"皮不见了"或"盖住别的东西"）。
     ② 给 .board > * 的 z-index: 1 一个稳定的参照系：
        卡片悬停时有 transform、入场时有 translate，本来就会临时形成堆叠上下文 ——
        显式声明，是为了让"有没有 hover"不改变这层内容的上下关系。 */
  isolation: isolate;
}

/* 没有链接层的那两张「开发中」卡片：内容直接长在 .board 里，
   内边距必须自己留着，否则会顶到卡片边缘。
   外观与改动之前【完全一致】—— 值、位置都没动，只是换了主人。 */
.board:not(:has(.board-link)) {
  padding: var(--board-pad);
}

/* 新增：左侧强调条 —— 可进入的卡片才长这根绿条 */
.board::before {
  content: "";
  position: absolute;
  left: 0;
  top: 16px;
  bottom: 16px;
  width: 3px;
  border-radius: 0 3px 3px 0;
  background: var(--accent);
  opacity: 0;
  transition: opacity var(--duration-normal) var(--ease-out);
}

/* 新增：可进入的卡片 = 卡内含有链接。鼠标可悬停的设备才给上浮反馈 */
@media (hover: hover) {
  .board:has(.board-link):hover {
    transform: translateY(-3px);
    box-shadow: var(--shadow-lg);
  }

  /* 描边染色落在【皮】上 —— 皮现在长在 ::after 身上，改 .board 自己的没用
     （那一条已经是 transparent）。 */
  .board:has(.board-link):hover::after {
    border-color: var(--accent);
  }

  /* 左侧绿条悬停时"长出来"：上下各收 10px → 撑满，像一条进度条被点亮 */
  .board:has(.board-link):hover::before {
    top: 10px;
    bottom: 10px;
    opacity: 1;
  }

  /* 说明文字从灰提到正文黑，信息权重跟着抬起来 */
  .board:has(.board-link):hover .board-desc {
    color: var(--text);
  }
}

/* 新增：「开发中」的卡片整体降权 —— 虚线描边 + 下沉底色 + 灰化，
   一眼就能看出"这张现在不能进"，不必去读那行小字（呼应 PRD 6.3 H2）。
   ⚠️ Day 11：底色与虚线描边这两样也要跟着皮走，落到 ::after 上 —— 
      留在 .board 自己身上的话，圆内就挖不掉它们了（那圈虚线会一直在）。 */
.board:has(.board-hint) {
  box-shadow: none;
}

.board:has(.board-hint)::after {
  background: var(--surface-sunken);
  border-style: dashed;
}

.board:has(.board-hint) .board-head h2 {
  color: var(--text-muted);
}

.board-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  margin-bottom: var(--space-2);
}

.board-head h2 {
  font-size: var(--text-lg);
}

/* 卡片里的一句说明 */
.board-desc {
  margin-top: var(--space-2);
  font-size: var(--text-sm);
  line-height: 1.5;
  color: var(--text-muted);
}

/* 新增：只有"可进入"的卡片右侧有箭头，所以只给它下面的说明文字让位 */
.board:has(.board-link) .board-desc {
  padding-right: 20px;
}

.board-hint {
  margin-top: 4px;
  font-size: var(--text-sm);
  color: var(--text-faint);
}

/* ---- 状态标签 ---- */

.badge {
  display: inline-flex;                   /* 改 flex：为圆点留位 */
  align-items: center;
  gap: var(--space-1);
  min-height: 24px;
  /* Day 9：垂直 2px 保留 —— 它是 (min-height 24 − line-height 20) / 2 算出来的，
     不是随手写在网格外的数。只把水平 10px 收进网格。 */
  padding: 2px var(--space-2);
  border-radius: var(--radius-pill);
  font-size: var(--text-xs);
  font-weight: 500;
  line-height: 20px;
  letter-spacing: .01em;
  white-space: nowrap;
}

/* 新增：标签前的小圆点，状态从"读文字"变成"看颜色" */
.badge::before {
  content: "";
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: currentColor;
  flex: none;
}

.badge-ok {
  background: var(--ok-bg);
  color: var(--ok-text);
}

.badge-ok::before {
  box-shadow: 0 0 0 3px rgba(26, 44, 115, .14);  /* 新增：可用态一点柔和光晕 */
}

.badge-todo {
  background: var(--todo-bg);
  color: var(--todo-text);
}

.badge-todo::before {
  opacity: .55;                           /* 新增：开发中的圆点不抢眼 */
}

/* ---- 可进入的入口：整张卡片都是点击区域 ---- */

.board-link {
  display: block;
  /* Day 11：内边距落在【链接自己】身上，而不是父级卡片上 ——
     这正是"整张卡片都能点"的关键：链接带内边距，它的盒子就等于整张卡，
     热区跟着铺满（原先那圈 padding 是死区，点卡片边缘不跳转）。
     ⚠️ 为什么箭头不用跟着挪：绝对定位的包含块是【内边距盒】，
        padding box 的位置和大小前后一致，所以 ::after 的 right / top 一个数都不用改。 */
  padding: var(--board-pad);
  min-height: var(--tap-size);
  color: inherit;
  text-decoration: none;
  position: relative;                     /* 新增：给箭头定位 */
}
/* ⚠️ 这里【不给单独的 padding-right】—— 那会在整层内边距之外再推一段，
   右上角的状态标签就被推得贴不到右边缘。
   （原来写这条时内边距还在父级 .board 上，道理一样：额外加的一层会叠成两倍。）
   要让位的只有最下面那句说明文字，见下面的 .board-desc 规则。 */

/* 新增：右侧箭头 —— 纯装饰，明确告诉人"这里点得进去"。
   top 往下偏 5px：让它落在说明文字那一行的右侧，不要跟右上角的状态标签挤在一起。 */
.board-link::after {
  content: "";
  position: absolute;
  right: 0;
  top: calc(50% + 5px);
  width: 8px;
  height: 8px;
  margin-top: -4px;
  border-right: 2px solid var(--accent);
  border-bottom: 2px solid var(--accent);
  transform: rotate(-45deg);
  opacity: .6;
  transition: opacity var(--duration-normal) var(--ease-out),
              right var(--duration-normal) var(--ease-out);
}

@media (hover: hover) {
  .board-link:hover::after {
    opacity: 1;
    right: -4px;                          /* 悬停时箭头再往右探出去一点 */
  }

  .board-link:hover .board-head h2 {
    color: var(--accent);                 /* 悬停时标题染色，反馈更明确 */
  }
}

.board-link:active {
  opacity: .78;
}

/* 按下时整张卡片往下压一点并轻微收缩 —— 手指/鼠标立刻感到"被接住了"。
   放在这条 hover 规则之后，是为了让"按下"能盖过"悬停"（同特异性看顺序）。 */
.board:has(.board-link):active {
  transform: translateY(-1px) scale(.988);
  box-shadow: var(--shadow-sm);
}

.board:has(.board-link):active::after {
  border-color: var(--accent);
}

.board-head h2,
.board-link::after {
  transition: color var(--duration-fast) var(--ease-out),
              opacity var(--duration-normal) var(--ease-out),
              right var(--duration-normal) var(--ease-out);
}

/* ---- Day 11：光标处的「透视窗」＋ 点击溶解 ---- */

/* 这一层就是卡片的【皮】：白底 + 1px 描边 —— 原先这两样长在 .board 自己身上（.item 给的）。
   为什么搬来这里：他要求"圆里的颜色不外加，而是直接透出背景原来的颜色"，
   等于要在皮上挖一个洞；而元素没法只挖掉自己背景的一块，只能把皮画在单独一层再挖。

   挖洞的手段是 mask-image 里的 radial-gradient：
   圆心 (--mx, --my)、半径 --r 的那块【不画皮】（transparent），
   于是那一块直接露出背后那层 —— html 底色 + 四块缓缓漂移的彩色光斑。
   这就是他说的"直接透视到背景后对应地方的颜色"。
   ⚠️ 只有真的透出来、而不是"填一个近似的背景色"，它才会跟着光斑一起动 ——
      背景是四块 20s 漂移的径向渐变，位置随时间变，没有哪个固定颜色能假装得出来。

   ⚠️ 为什么不用 mix-blend-mode: destination-out（这版之前就是这么写的，是错的）：
      destination-out 是 Canvas 2D 的 globalCompositeOperation 取值，
      **不是 mix-blend-mode 的合法值**（那边只有 normal / multiply / screen / … / luminosity 那一组）。
      CSS 解析器遇到非法值会把整条声明丢掉 → 实际回退成 normal
      → 这层就变成"一块不透明纯色的圆"，圆里是 #000 而不是背景。
      最坑的是它【不报任何错】，只静静地不干活：
      实测 getComputedStyle(el, '::after').mixBlendMode 返回的是 normal。

   ⚠️ inset: -1px 而不是 0 —— 绝对定位的包含块是【内边距盒】，
      inset:0 只到内边距盒边界，那 1px 描边正好在它外面一圈，圆内会留一圈细线。
      （同时这也让 --mx/--my 的坐标系与 JS 量的 border box 完全对齐。）
   ⚠️ pointer-events: none 是【必须】的：这层压在整张卡上面，
      不关掉指针事件它会把所有点击都吃掉，链接就永远点不到了。
   ⚠️ 圆心为什么写成 var(--mx, 50%) 而不是 var(--mx)：给个兜底 ——
      万一 JS 还没写过（触屏点按那一刻），圆就落在卡片正中。
      没兜底的话整个 radial-gradient 会失效，皮会整块消失。 */
.board::after {
  content: "";
  position: absolute;
  inset: -1px;                            /* 盖住描边那一圈，见上 */
  border-radius: var(--radius-card);      /* 跟着卡片的圆角走，四角不会露出直角 */
  background: var(--surface);             /* ★ 卡片的皮：白底 */
  border: 1px solid var(--border);        /* ★ 卡片的皮：描边 */
  /* ⚠️ 皮必须垫在【左侧那根绿条（.board::before）和内容】的下面。
     原因是绘制顺序：::before 和 ::after 都按"第一个/最后一个子元素"参与排序，
     ::after 排在 ::before 之后 —— 而皮是一块【不透明】的白，
     不给它一个负 z-index 的话，悬停时长出来的那根绿条会被它整个盖住。
     （这版之前皮是"橡皮擦"，圆外是透明的，所以撞不上这个问题 —— 是这次改动的连带项。） */
  z-index: -1;
  pointer-events: none;
  /* 圆内不画皮，圆外照画 —— 半径跟着 --r 走 */
  -webkit-mask-image: radial-gradient(circle at var(--mx, 50%) var(--my, 50%),
                        transparent var(--r, 0px), #000 calc(var(--r, 0px) + .5px));
          mask-image: radial-gradient(circle at var(--mx, 50%) var(--my, 50%),
                        transparent var(--r, 0px), #000 calc(var(--r, 0px) + .5px));
  /* 过渡的是 --r 本身，不是 mask-image ——
     mask-image 里的渐变不可插值，只有 --r 被注册成 <length>（见文件顶部 @property）
     才能逐帧补间，圆才是"渐开"而不是"啪地糊上去"。 */
  transition: --r var(--duration-slow) var(--ease-out),
              border-color var(--duration-fast) var(--ease-out);
}

/* 卡片里的内容要浮在【皮】上面 —— 否则皮会把文字盖住。
   ::after 在绘制顺序里排在最后，默认就是它盖住内容 —— 得把内容提起来一层。
   （.board-link 本来就是 position: relative，加 z-index 不改变它的布局。）
   ⚠️ 内容【不】参与挖洞：圆里透出的是背景，文字照旧留在原地 ——
      这正是他要的"白色被背景替代，文字同时在慢慢淡掉"。 */
.board > * {
  position: relative;
  z-index: 1;
}

/* ---- 点击 → 溶解 ----
   圆从光标处扩大、直到盖满整张卡（白色被背后的背景替代）；
   同时文字整体淡出；两者【同一时间结束】—— 他说"颜色和文字同时消失"。
   圆的半径由 JS 写成"盖满整卡"的值，这里不用管大小，只管速度。 */

/* 溶解这一步要【快一点】：用 200ms（--duration-dissolve），
   而不是悬停那档 280ms。他原话是"这个消失的过程快一点更好"。
   ⚠️ 必须单独写一条：.board::after 那条默认挂的是 --duration-slow。 */
.board.is-dissolving::after {
  transition: --r var(--duration-dissolve) var(--ease-out),
              border-color var(--duration-dissolve) var(--ease-out);
}

/* 文字与状态标签一起淡出。
   ⚠️ 用 .board > * 一层就够：可进入的卡片内容全包在 .board-link 里（一个子元素），
      「开发中」卡片是 head / desc / hint 三个直接子元素 —— 两种结构都覆盖到。 */
.board.is-dissolving > * {
  opacity: 0;
  transition: opacity var(--duration-dissolve) var(--ease-out);
}

/* ⚠️ 投影画在皮的【外面】，mask 管不到它 —— 它只能【整体】淡出。
   所以这里换成同等形状但全透明的阴影，让它跟着卡片一起消失。
   ⚠️ 选择器要压过悬停那条（特异性 0,3,0），否则鼠标还停在卡上时
      悬停的 --shadow-lg 会把这条盖掉，投影会留在原地不散。 */
.board.is-dissolving,
.board:has(.board-link):hover.is-dissolving {
  box-shadow: 0 0 0 0 rgba(0, 0, 0, 0);
  transition: box-shadow var(--duration-dissolve) var(--ease-out);
}

/* 动效敏感的用户：圆不开合（不做补间），皮一直完整。
   ⚠️ 这里【千万不能】写 display: none —— ::after 现在是卡片的皮本身，
      藏掉它整张卡就空了。JS 那边也不写 --r，半径停在初始值 0，于是没有洞。
   （跳转也不等那 260ms，由 JS 处理。） */
@media (prefers-reduced-motion: reduce) {
  .board::after {
    transition: none;
  }
}

/* ---- Day 14 · 窄屏（手机）：绕开 mask 那条昂贵路径 ----
   起因：真人测试反馈「手机上点卡片 / 切页面卡顿」。

   病根：这块皮靠 mask-image 里的 radial-gradient 挖洞，洞的半径 --r 是【注册过的】
   自定义属性 —— 注册属性补间【不走合成器】，每一帧都要在主线程上重算一张径向渐变遮罩
   （上面注释里写着"实测能取到 50 多个中间帧"）。桌面 GPU 快、看不出来；
   手机连算几十帧就是"点一下等半拍"。

   做法：手机上一律【不挖洞】，改成整张皮 opacity 淡出。
   观感仍然是"白皮没了、字没了、背后的光斑透出来"，而且是一段看得见的渐变；
   但全程只动 opacity —— 合成器一把过，主线程不掺和。

   配套（缺一不可）：home.js 的 setRadius() 在窄屏直接 return，一个 --r 都不写。
   不写 ⇒ 洞永远停在半径 0 ⇒ 皮始终完整，淡出才看得见。
   要是 JS 照写 --r，洞会【瞬时】开满（窄屏这边没有 --r 过渡），
   皮当场全透、后面那条淡出反而白做，又变回"啪一下空白"。

   ⚠️ 599px 与文件里其它窄屏规则（.list-item 那条）同一档。
      手机横屏（宽 > 599）仍走原来的 mask 路径，这是已知取舍，没顺手扩大范围。
   ⚠️ 桌面（≥ 600px）一行都不受影响：下面全部包在媒体查询里。 */
@media (max-width: 599px) {
  /* 皮不再补间 --r —— 窄屏上它只负责"淡出" */
  .board::after {
    transition: border-color var(--duration-fast) var(--ease-out);
  }

  /* 溶解：整张皮淡出，和文字淡出同一时刻开始、同一时刻结束 */
  .board.is-dissolving::after {
    opacity: 0;
    transition: opacity var(--duration-dissolve) var(--ease-out),
                border-color var(--duration-dissolve) var(--ease-out);
  }
}

/* ---- 内页：返回首页与内容面板 ---- */

.back-link {
  display: inline-flex;
  align-items: center;
  min-height: var(--tap-size);
  margin-bottom: 4px;
  /* 新增：给自己留出左右内边距 —— 悬停那圈光得有地方长出来。
     不留的话光环会紧贴文字，看着像给字加描边，而不是"这个按钮被点亮了"。
     ⚠️ 为什么是 8px 而不是更宽松的 12px —— 见下面 :hover 那段的说明，这是个硬预算 */
  padding: 0 8px;
  /* 新增：把留出来的 8px 抵消掉，让「←」的左边缘仍然和下面的大标题对齐。
     不减这一下，整行字会右移 8px，页头看着就歪了 */
  margin-left: -8px;
  border-radius: var(--radius-pill);
  /* Day 10：原来是 --accent(#0f6e56)，它直接压在【页面背景】上 ——
     背景一加就露馅：实测在背景最暗处只有 3.72:1，低于 AA 的 4.5。
     ⇒ 先试 --accent-strong(#0b5a46)，还是不够（加密后最窄视口只剩 4.37）。
       原因不是"挑错了绿色"，而是**绿色系天生亮度低**：要它达 AA，
       底色亮度得 ≥0.527；而这个背景加密后最暗处已经到 0.510 —— 数字上绕不过去。
     ⇒ 最后改成正文色 --text（≈9.5:1，余量充足）。
       "← 返回首页" 左侧那个箭头已经足够说明它是链接，悬停时仍会吃到强调色。
       ⚠️ 如果更想保住绿色：可以给它加一层半透明白色"药丸底"（底色被抬到约 0.81，
         绿色就有 6.7:1），但那会在页头多出一块白底，动的是设计不是颜色 —— 等斌哥定。 */
  color: var(--text);
  font-size: var(--text-base);
  text-decoration: none;
  /* Day 28 晚：它从 <a> 换成了 <button>（当时的 #back-btn，已随"删掉返回键"退场）——
     button 自带一层 UA 灰边框，再叠上键盘焦点环（绿，:focus-visible），
     两圈颜色不同、圆角也不同，就是当时"描边有两种颜色"的来源。
     这里把 UA 那层剥掉：无边框、无底色，回到纯文字药丸的样子。
     ⚠️ 焦点环保留 —— 那是无障碍（键盘用户必须看得见焦点）；
        鼠标点击不会触发 :focus-visible，所以平时看不到那圈绿。 */
  border: 0;
  background: none;
  cursor: pointer;
  transition: background-color var(--duration-fast) var(--ease-out),
              box-shadow var(--duration-fast) var(--ease-out),
              transform var(--duration-fast) var(--ease-out);
}

/* ⚠️ Day 28 晚：`.back-link .back-arrow` 的规则已随「← 返回」键一起删除
   （见本文件「等级目录」节后的墓碑说明）。 */

.back-link:active {
  opacity: .6;
}

/* ⚠️ 悬停反馈从「加下划线」换成「点亮一圈光」（2026-09-30 斌哥要求）。
   两层 box-shadow 而不是一层：
     只给外面那圈柔光 → 浅色底上边界发虚，看不出这个"东西"的边界在哪；
     只给 1px 实心描边 → 太硬，不像"发光"，像被选中。
     1px 实心描边托住形状 ＋ 外面 5px 柔光负责"亮" —— 两个一起才对。

   ⚠️⚠️ 这里有一个【硬预算 16px】，改数字前先看这段：
     页面左右留白来自 body 的 padding: 24px 16px（style.css 第 86 行），
     而且窄屏媒体查询【没有】再缩小它 → 左边到底只有 16px 可用空间。
     光环是往盒子【外面】扩的，所以必须满足：
          左右内边距 + 光圈外扩 ≤ 16
     一开始写的是 12 + 6 = 18，超了 2px —— 量图发现光环最左像素落在 x = -2，
     也就是左半边被视口边缘【切平了】，看着像没渲染完。
     现在 8 + 5 = 13，光环最左落在 x = 3，不切。
     要更宽松的内边距，就得同时收窄光圈，或者把 body 的左右留白加大 ——
     但加大留白会连标题和卡片一起动，那是另一个决定，别顺手改。

   ⚠️ 为什么保留 @media (hover: hover)：
     触屏设备上点一下会留下"粘住的悬停态"，一圈光赖在那儿不走，很脏；
     触屏本来也没有"光标浮在上面"这回事。
     键盘用户的反馈改由下面的 :focus-visible 负责（同一圈光），不能只照顾鼠标。

   ⚠️ translateY(-1px) 不影响入场动画：入场动的是父元素 .site-header，
     这里动的是它自己，各管各的（和 .board 那个 transform/translate 的坑不是一回事）。 */
@media (hover: hover) {
  .back-link:hover {
    background: var(--ok-bg);
    box-shadow: 0 0 0 1px var(--accent), 0 0 0 5px rgba(26, 44, 115, .14);
    transform: translateY(-1px);
  }
}

/* 键盘 Tab 走到这里，给同一圈光 —— 不能只有鼠标用户看得见反馈。
   额外留 outline 是因为光晕在「高对比度模式」下会被系统抹掉，outline 不会。 */
.back-link:focus-visible {
  background: var(--ok-bg);
  box-shadow: 0 0 0 1px var(--accent), 0 0 0 5px rgba(26, 44, 115, .14);
  outline: 2px solid var(--accent);
  outline-offset: 4px;
}

.panel {
  padding: var(--space-5);
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-card);
  box-shadow: var(--shadow-sm);           /* 新增：面板也浮起一层 */
}

.empty {
  font-size: var(--text-base);
  color: var(--text-faint);
}

/* ---- 等级目录：L1 / L2 / L3 ----
   Day 28 晚搬家（斌哥指示）：从主区顶部搬进【左栏】，紧跟三个板块条目之下，
   尺寸与它们逐项对齐 —— 同一个栏里做"另一类控件"是自找的割裂。
   ⚠️ 容器仍叫 .levels：app.js 按 id 找它，搬家对它是透明的。
   ⚠️ 主区那套布局（.reader-top 的左右分栏、.levels 的横向换行）连同
      「← 返回」键一起退场 —— 见下方墓碑说明。 */

.levels {
  display: flex;
  flex-direction: column;
  gap: 6px;
  margin-top: var(--space-4);       /* 与上面三个板块条目拉开一档，看得出是另一组 */
}

/* 没渲染出等级时（别的页面没有这个容器，或数据里没 levels）整层不占位置 */
.levels:empty {
  display: none;
}

/* 一条等级按钮。⚠️ 这里是在【覆盖】.item 那身皮（白底 / 描边 / 浮起阴影）：
   侧栏条目是"贴着底"的一行，浮起的小卡片摆在栏里会显脏。
   高度 48 / 圆角 14 / 左右 12 内边距 / 字号 18 —— 与上面三个板块逐项对齐。 */
.side-levels .level-btn {
  justify-content: flex-start;
  width: 100%;
  min-width: 0;
  min-height: 48px;
  padding: 0 var(--space-3);
  border: 0;
  border-radius: 14px;
  background: transparent;
  box-shadow: none;
  color: var(--text-muted);
  font-size: var(--text-lg);
  text-align: left;
}

@media (hover: hover) {
  .side-levels .level-btn:hover {
    background: var(--nav-hover-bg);
    color: var(--accent);
  }
}

/* 当前等级：亮薄荷底 + 强调绿 —— 与 .sidenav__item[aria-current='page'] 同一套观感 */
.side-levels .level-btn[aria-pressed='true'] {
  background: var(--nav-active-bg);
  color: var(--accent);
  font-weight: 500;
}

/* ============================================
   Day 28 晚（重做版）：主区的「课程列表页」与回路
   ------------------------------------------------------------
   与上一版的关键差别：上一版把「课程列表」和「音频列表」【同屏叠着】铺，
   两条都长一样 ⇒ 读起来像同一个页面出现了两遍（已被斌哥打回并删除）。
   这一版按他明确的要求做成【两页互斥、点一下换一页】：
     点 L1 → 主区整页换成 Lesson1~Lesson23（每条与音频行同尺寸同结构）
     点 Lesson1 → 课程列表整页收起，换成"← Lesson 列表 + 音频列表"
   ⚠️ 「尺寸都一样」不靠对齐参数堆——条目直接挂 .file-btn 那身皮，
      与音频行是同一套盒子（白底卡片 / 名称+小字两行 / 整宽 / 同内边距）。
   历史那版（药丸式 lesson-btn + .lessons 横排）已删，教训记在上面。
   ============================================ */

/* 课程列表容器：与 .files 同一套排布（纵列、同间距），因为条目同源 */
.lessons {
  display: flex;
  flex-direction: column;
  gap: 8px;
  margin-bottom: 12px;
}

/* 不在课程列表页时整层收起，不占位置（同 .files:empty 的约定） */
.lessons:empty {
  display: none;
}

/* 回路所在的容器。hidden 属性由 app.js 控制显隐；
   平时它是"音频页的第一行"，所以要给一点下边距，别贴着音频条目 */
.lesson-back {
  margin-bottom: 12px;
}
.lesson-back[hidden] {
  display: none;
}

/* Day 28 晚（斌哥指示）：把「返回」做成一张【真按钮】——
   加大尺寸 ＋ 白底，与下面的板块（课程条目 / 音频条目）同一身皮：
   白底、浅描边、16px 圆角、一层极浅的浮起阴影。三处都对齐 .item，观感才连得上。
   ⚠️ 只在这一个上下文里加皮，不动 .back-link 的基础定义 ——
      那条是 Day 7 的「内页返回」通用组件（透明底 + 悬停一圈绿光），
      别处将来可能还要用那种"纯文字药丸"的样子。
   ⚠️ margin-left 归零：基础样式里那个 -8px 是为了让箭头和大标题左对齐设计的，
      这里上面没有大标题，留着会让按钮比下面的卡片往左凸出 8px。 */
.lesson-back .back-link {
  min-height: 48px;
  padding: 0 18px;
  margin-left: 0;
  border: 1px solid var(--border);
  border-radius: var(--radius-card);
  background: var(--surface);
  box-shadow: var(--shadow-xs);
  font-size: var(--text-base);
}

/* 悬停 / 键盘焦点：换成"卡片被点亮"的反馈（描边转绿、阴影加深），
   而不是基础样式那圈薄荷底 + 绿光晕 —— 白底卡片上再糊一层薄荷会脏。 */
@media (hover: hover) {
  .lesson-back .back-link:hover {
    background: var(--surface);
    border-color: var(--accent);
    color: var(--accent);
    box-shadow: var(--shadow-sm);
    transform: none;
  }
}
.lesson-back .back-link:focus-visible {
  background: var(--surface);
  border-color: var(--accent);
  box-shadow: var(--shadow-sm);
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

/* 回路的箭头：SVG 图形（字符「←」跟汉字基线不齐，这个坑踩过一次）。
   尺寸跟着按钮一起放大一档（16 → 18）。 */
.back-link .back-arrow {
  flex: none;
  width: 18px;
  height: 18px;
  margin-right: 8px;
}

/* 入场：课程列表页也是主区的一页，跟着同一套节奏上浮 */
.page-reader #lessons {
  animation: riseIn .45s var(--ease-out) both;
  animation-delay: .08s;
}
.page-reader .lesson-back {
  animation: riseIn .45s var(--ease-out) both;
  animation-delay: .08s;
}

/* ---- 跟读页第二层的文件列表（Day 8 新增）：每条音频一行 ----
   ⚠️ 为什么 .file-btn 不套 .btn 那个类：
      .btn 是「药丸形小按钮」（圆角 999px、white-space: nowrap、inline-flex、左右 16px 内边距），
      而这里要的是「整宽的一条列表行」（能换行、上下两行字、随面板宽度撑满）。
      把 .btn 的 8 条属性挨个覆盖回来，比直接写清楚更难读，还容易漏一条就串味。
      所以这里自成一套。至于底色、描边、圆角那一层"长得像"的部分，
      现在由共用组件 .item 统一保证（.btn 和 .file-btn 都挂着它），
      不再靠"刻意跟 .btn 对齐"来维持 —— 靠人记着的对齐，迟早会走样。 */

.files {
  display: flex;
  flex-direction: column;
  gap: 8px;
  margin-bottom: 12px;
}

/* 和 .levels 一样：没内容就不占位置（app.js 在清单空时会塞一句提示，那种不算空） */
.files:empty {
  display: none;
}

/* 皮（描边 / 圆角 / 底色 / 浮起阴影 / 过渡）全部来自 .item ——
   页面上每一行都带 class="file-btn item"（由 app.js 生成）。
   这里只留「这一行自己的尺寸与排布」 */
.file-btn {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 2px;
  width: 100%;
  min-height: var(--tap-size);
  padding: 12px 16px;
  color: var(--text);
  font-family: inherit;
  font-size: var(--text-base);            /* Day 9：原 --text-md，16px 这档规则里叫 base，值不变 */
  text-align: left;
  cursor: pointer;
}

/* 当前正打开的那一条。描边与底色已由 .item[aria-current='true'] 统一给出，
   这一节只管「文件名染色」这一点是文件行独有的。
   ⚠️ 语义提醒：等级按钮是「被按下的开关」→ aria-pressed；
   文件是「当前打开的是它」→ aria-current。两者语义不同、不能互换，
   共用的只是"看起来被选中"的那张皮（见上面 .item 那一节）。 */
.file-btn[aria-current='true'] .file-name {
  color: var(--accent);
}

/* Day 9：这里原本有一条「把当前行小字提到 --text-muted」的规则，是为了过 AA。
   后来把 --text-faint 本身提到 #606c77（绿底 4.46 → 4.73，已达标）之后，这条就多余了，
   而且它会让 12px 的小字比同一行 16px 的标题还深（标题是 --accent，绿底 5.46）。
   现已删除 —— .file-meta 回到自己的默认色 --text-faint，层级关系恢复正常。 */

.file-btn:active {
  opacity: .7;
}

@media (hover: hover) {
  /* 悬停：【还没打开的】那一行才抬 1px ＋ 描边变深。
     已经打开的那条不给位移反馈 —— 它已经是"当前"了，再动反而晃。
     （原来这里还有一条 .file-btn[aria-current='true']:hover 把描边染回
       accent，那是重复的：.item[aria-current='true'] 已经把它染好了） */
  .file-btn:not([aria-current='true']):hover {
    border-color: var(--border-strong);
    transform: translateY(-1px);
  }
}

.file-name {
  font-weight: 500;
  overflow-wrap: break-word;
}

/* 第二行小字：共几句、多长。tabular-nums 让秒数的数字等宽，上下两行不会跳 */
.file-meta {
  font-size: var(--text-xs);
  color: var(--text-faint);
  font-variant-numeric: tabular-nums;
}

/* Day 31：「上次学到」卡 —— 和普通 Lesson 卡一眼区分：
   薄荷底 + 强调描边 + 左侧时钟徽标（深色模式下随 token 自动变蓝系）。
   尺寸/圆角/内边距仍走 .file-btn 那套皮，只是从竖排改横排（图标占左） */
.lesson-btn.resume {
  flex-direction: row;
  align-items: center;
  gap: 12px;
  background: var(--ok-bg);
  border-color: var(--accent);
}
.lesson-btn.resume .file-name {
  color: var(--accent);
}
.lesson-btn.resume .file-meta {
  color: var(--text-muted);
}
.lesson-btn.resume .resume-ico {
  flex: none;
  width: 38px;
  height: 38px;
  border-radius: 12px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--accent-fill);
  color: #fff;
}
.lesson-btn.resume .resume-ico svg {
  width: 20px;
  height: 20px;
}
.lesson-btn.resume .resume-txt {
  display: flex;
  flex-direction: column;
  gap: 2px;
  min-width: 0;                            /* 标题过长时省略号收进去，别把卡片撑破 */
}
.lesson-btn.resume .resume-txt .file-meta {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* 悬停描边别被 .file-btn 的通用 hover 染回灰色——书签卡描边恒为强调色 */
@media (hover: hover) {
  .lesson-btn.resume:hover {
    border-color: var(--accent);
  }
}

/* 音频区在【没打开任何文件】时是空的（见 app.js 的 renderMaterialView）——
   整块白面板连同阴影一起收掉，只留下文件列表，别在页面上挂一块空卡片 */
#reader:empty {
  display: none;
}

/* ---- 跟读页：素材标题与句子列表 ---- */

.mat-title {
  font-size: var(--text-xl);
  margin-bottom: 4px;
  overflow-wrap: break-word;
}

/* Day 31：标题行 = 标题（左）+ 倍速下拉（钉最右，斌哥要求）。
   .mat-title 的 margin-bottom 挪到行容器上，免得下拉和标题基线对不齐 */
.mat-head {
  display: flex;
  align-items: center;
  gap: 14px;
  margin-bottom: 4px;
}
.mat-head .mat-title {
  flex: 1;
  margin-bottom: 0;
}
.speed-select {
  flex: none;
  appearance: none;
  -webkit-appearance: none;
  border: 1px solid var(--border-strong);
  background: var(--surface) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23606c77' stroke-width='2' stroke-linecap='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E") no-repeat right 10px center / 12px;
  color: var(--text);
  border-radius: 999px;
  padding: 6px 30px 6px 14px;
  font-size: 13px;
  font-weight: 500;
  font-family: inherit;
  cursor: pointer;
  font-variant-numeric: tabular-nums;
}
.speed-select:focus-visible {
  outline: 2px solid var(--focus-ring);
  outline-offset: 2px;
}

.mat-meta {
  margin-bottom: 8px;
  font-size: var(--text-xs);
  color: var(--text-faint);
  font-variant-numeric: tabular-nums;
  letter-spacing: .01em;                  /* 新增：秒数小字略松，更清爽 */
}

/* ---- 坏路径 B2：这条素材的音频读不到 ---- */

.material.is-unavailable .seg-list {
  opacity: .5;
  pointer-events: none;
}

.material.is-unavailable .icon-btn {
  opacity: .4;
}

.seg-list {
  list-style: none;
}

.seg {
  display: flex;
  align-items: flex-start;
  gap: var(--space-2);
  padding: 12px 0;
  border-bottom: 1px solid var(--border);
  min-height: var(--tap-size);
  cursor: pointer;
  border-radius: 10px;                    /* 新增：让悬停底色不顶到直角 */
  transition: background-color var(--duration-fast) var(--ease-out);
}

.seg:last-child {
  padding-bottom: 0;
  border-bottom: none;
}

/* 新增：悬停轻微提亮，指明"这一句能点"（只在有鼠标的设备上） */
@media (hover: hover) {
  .seg:not(.is-playing):hover {
    background: var(--surface-hover);
  }
}

.seg-idx {
  flex: none;
  width: 20px;
  font-size: var(--text-xs);
  line-height: 1.6;
  color: var(--text-faint);
  font-variant-numeric: tabular-nums;
}

.seg-body {
  display: block;
  flex: 1 1 auto;
  min-width: 0;
}

.seg-text {
  display: block;
  font-size: var(--text-base);            /* Day 9：原 --text-md，值不变，只把名字收回规则里有的那一档 */
  line-height: 1.6;
  /* Day 9：正文改用主色。规则 5.1 写的是「正文用深色，辅助信息用灰色」——
     而这一页学生就是来看这些句子的，之前却排在「次要正文」档上（6.40:1），看着发灰。
     ⚠️ 播放中那一句不受影响（仍是 --accent）：下面 .seg.is-playing .seg-text
        特异性更高（0,2,0 > 0,1,0），会赢过这里。"绿色 = 正在读"这个信号是刻意保留的。 */
  color: var(--text);
  overflow-wrap: break-word;
}

.seg-time {
  display: block;
  margin-top: 2px;
  font-size: var(--text-xs);
  color: var(--text-faint);
  font-variant-numeric: tabular-nums;
}

/* ---- 正在播的那一句 ---- */

.seg.is-playing {
  margin: 0 -10px;
  padding-right: 10px;
  padding-left: 10px;
  background: var(--ok-bg);
  border-color: transparent;
  border-radius: 10px;
  box-shadow: inset 3px 0 0 var(--accent); /* 新增：左侧实心绿条，比纯底色更"锁定" */
}

.seg.is-playing .seg-text,
.seg.is-playing .seg-idx {
  color: var(--accent);
}

/* Day 9：同理，这里原本有一条「把播放行时间小字提到 --text-muted」的规则。
   --text-faint 达标后已删除 —— 小字回到辅助档（绿底 4.73），不再压过同一行的正文。
   （播放行的正文是 --accent，绿底 5.46，仍高于小字，层级不倒挂。） */

.seg.is-playing .seg-idx {
  font-weight: 500;                       /* 新增：序号跟上，视线先落在序号上 */
}

/* Day 9：上面关掉浏览器自带的蓝色点按高亮后，
   .seg 就成了全站唯一「能点、按下时却毫无反馈」的元素。
   手机点一下屏幕毫无变化，会让人怀疑"到底点没点上"。
   这里补一条自己的按下底色，把原来那层刺眼的蓝，换成本句微微变灰底。
   ⚠️ 位置故意放在 .seg.is-playing 之后：
      .seg:active 与 .seg.is-playing 的特异性相同（都是 0,2,0），
      谁写在后面谁生效。放后面 = 连「正在播的那一句」也能看到按下反馈；
      左侧那条绿色实心条是 box-shadow，不受影响，播放位置不会丢。
   ⚠️ 底色用 --todo-bg(#eceef1)，比 hover 的 #f7f8fa 更深一档，
      所以"按下"读起来比"悬停"更重，两者不会糊在一起。
   ⚠️ .seg 本身已有 background-color 150ms 过渡，这条会自然淡入淡出。
   ⚠️ 不加 @media (hover: hover) 包裹：按下反馈在触屏上才最需要。 */
.seg:active {
  background: var(--todo-bg);
}

/* ---- 播放控制条 ---- */

.player {
  margin: 12px 0 4px;
}

.player-controls {
  display: grid;
  /* 三列：左右两条空白 + 中间一列。三组各自归位，播放键永远停在正中 */
  grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
  align-items: center;
  gap: 12px;
  margin-top: 2px;
}

/* 中间那组：上一个 / 播放 / 下一个 —— Day 28 晚把播放键从"单打独斗"改成三件套，
   左右箭头贴着它（斌哥要的"方便切到别的音频"）。
   ⚠️ 原来这里有两条 `.player-controls .icon-btn { grid-column: 2 }` 和
      `.player-controls .btn { grid-column: 3 }` —— 现在按钮被包进两个组里，
      那两条会让组内的每个按钮各自去抢网格列，布局当场散架，所以一并删掉。 */
.player-transport {
  grid-column: 2;
  /* ⚠️ grid-row: 1 不能省 —— Day 28 晚踩到的坑，实测数据摆在这儿：
     三组都只写了 grid-column，没写行号，而网格的【自动排布不会往回走】。
     标记里中间那组写在最前 → 它先占第 1 行第 2 列；接着左组要回到第 1 列，
     于是被推到第 2 行（实测：容器 100px 高 = 两行 44px，中间组 top=358.4、
     左右两组 top=414.4 —— 上下错开 56px，看着就是"没在同一排"）。
     钉死行号之后，DOM 顺序怎么写都不影响排布。 */
  grid-row: 1;
  display: flex;
  align-items: center;
  gap: 10px;
}

/* 模式开关（单句重复 / A-B 复读）
   Day 28 晚二改（斌哥指示）：单句重复从右侧移到【左侧】——
   原来两个模式键挤在同一格，和中间那组（切歌＋播放）挤到重合。
   现在一格一组：左（单句重复）/ 中（切歌＋播放）/ 右（A-B 复读），
   三组各占一格，中间的播放键依旧钉在正中。 */
.player-modes {
  display: flex;
  align-items: center;
  gap: 8px;
}

.player-modes--left {
  grid-column: 1;
  grid-row: 1;              /* 见 .player-transport 上那条注释：行号必须显式钉死 */
  justify-self: start;
}

.player-modes--right {
  grid-column: 3;
  grid-row: 1;
  justify-self: end;
}

/* A/B 两个点的时刻读出（比塞进按钮文字好读）。
   没设 A 时是空的 —— 空元素不占版面的老规矩（同 .player-status:empty） */
.ab-readout {
  margin-top: 6px;
  font-size: var(--text-xs);
  color: var(--text-faint);
  font-variant-numeric: tabular-nums;      /* 时刻等宽，读数不跳 */
}

.ab-readout:empty {
  display: none;
}

.ico {
  display: block;
  width: 20px;
  height: 20px;
  fill: currentColor;
}

.progress {
  display: block;
  width: 100%;
  height: var(--tap-size);
  margin: 0;
  -webkit-appearance: none;
  appearance: none;
  background: transparent;
  cursor: pointer;
}

.progress::-webkit-slider-runnable-track {
  height: 6px;
  border-radius: 3px;
  background: var(--todo-bg);
}

.progress::-moz-range-track {
  height: 6px;
  border-radius: 3px;
  background: var(--todo-bg);
}

.progress::-webkit-slider-thumb {
  -webkit-appearance: none;
  appearance: none;
  width: 24px;
  height: 24px;
  margin-top: -9px;
  border: 2px solid var(--accent);
  border-radius: 50%;
  background: var(--surface);
  box-shadow: var(--shadow-xs);           /* 新增：滑块浮起，拖拽时更"抓得住" */
  transition: transform var(--duration-fast) var(--ease-out);
}

.progress::-moz-range-thumb {
  width: 24px;
  height: 24px;
  border: 2px solid var(--accent);
  border-radius: 50%;
  background: var(--surface);
  box-shadow: var(--shadow-xs);
  /* Day 9：上面 -webkit 那条写了 transition: transform，Firefox 这条却漏了 ——
     补上，否则下面新加的"拖住放大"在 Firefox 里是硬跳、没有过渡。 */
  transition: transform var(--duration-fast) var(--ease-out);
}

/* Day 9：和 .icon-btn 同一类毛病 —— -webkit-slider-thumb 上写着
   transition: transform，但全站没有任何规则【设置】滑块的 transform，
   所以那条过渡一次都不会发生（死的）。
   而且 .progress 本体一个 :hover / :active 都没有：实测「静置」与「悬停」
   两张截图【逐字节相同】（都是 1102 字节）—— 拖住滑块时手上毫无反馈。
   这里补上"被抓住"的缩放，那条声明了却没生效的过渡立刻变成活的。
   ⚠️ 只做 :active（拖住）不做 :hover：滑块本来就跟着指针走，
      再加悬停放大就是多余的视觉动静（今日要求：不加不需要的视觉元素）。
   ⚠️ 两个内核都写，表现才一致。 */
.progress:active::-webkit-slider-thumb {
  transform: scale(1.15);
}

.progress:active::-moz-range-thumb {
  transform: scale(1.15);
}

.icon-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: var(--tap-size);
  height: var(--tap-size);
  padding: 0;
  border: 1px solid var(--accent);
  border-radius: 50%;
  background: var(--surface);
  color: var(--accent);
  cursor: pointer;
  box-shadow: var(--shadow-xs);           /* 新增 */
  transition: background-color var(--duration-fast) var(--ease-out),
              color var(--duration-fast) var(--ease-out),
              transform var(--duration-fast) var(--ease-out),
              box-shadow var(--duration-fast) var(--ease-out);
}

.icon-btn .ico-stop {
  display: none;
}

.icon-btn[aria-pressed='true'] {
  background: var(--accent);
  color: var(--surface);
  box-shadow: 0 0 0 4px rgba(26, 44, 115, .12);  /* 新增：播放中一圈光晕 */
}

.icon-btn[aria-pressed='true'] .ico-play {
  display: none;
}

.icon-btn[aria-pressed='true'] .ico-stop {
  display: block;
}

/* Day 9：这里原来只有一条 opacity: .7。
   而上面对 .icon-btn 声明的 transition 里写着 transform —— 全站却没有任何规则
   给它设过 transform。实测四个状态（默认 / 悬停 / 按住 / 播放中）transform 全程
   都是 none，那条过渡永远是【死的】，一次都不会发生。
   作者本意显然是「按下时缩一下」，只写了一半。这里把另一半补上。
   ⚠️ 并进这条 :active 而不是另开一条：少一条规则，也保证按下时
      「变淡」和「缩小」是同一下、同时发生。
   ⚠️ 不必担心 .seg 那种坑（riseIn 动画 fill-mode: both 把属性锁死）——
      .icon-btn 身上没有任何 animation，transform 没人跟它抢。 */
.icon-btn:active {
  opacity: .7;
  transform: scale(.94);
}

@media (hover: hover) {
  .icon-btn:hover {
    background: var(--ok-bg);             /* 新增：悬停预亮，暗示"按下去会变绿" */
  }

  .icon-btn[aria-pressed='true']:hover {
    background: var(--accent-strong);
  }
}

/* ---- 禁用态（坏路径 B2）----
   Day 9：音频读不到时 JS 会给播放键加 disabled（app.js 的 markAudioMissing
   与 player.js 的 markUnavailable，各一处）。CSS 这边【已经】把整块降到
   40% 透明度（.material.is-unavailable .icon-btn）—— 禁用「外观」本来是对的。
   但漏了一点：:hover 照常触发。实测被禁用的播放键，鼠标一悬停
   底色仍从 rgb(255,255,255) 变成 rgb(225,245,238)，光标也仍是 pointer。
   于是一个点不动的按钮，长得跟能点的一模一样，还给你"可以点"的手势 ——
   用户点下去毫无反应，会以为页面卡了。这两条把它按住。
   ⚠️ 必须写在上面那条 @media (hover: hover) 之后：
      .icon-btn[disabled]:hover 的特异性 (0,3,0) 本来就压得过 .icon-btn:hover (0,2,0)，
      但 .icon-btn[disabled] 与它同为 (0,2,0)，只能靠顺序取胜。
   ⚠️ 底色写 --surface（= 未悬停时的原色），不是"再叠一层灰" ——
      目标是让禁用键对外界刺激【完全没反应】，而不是造一个新的hover态。 */
.icon-btn[disabled] {
  background: var(--surface);
  cursor: default;
}

.icon-btn[disabled]:hover {
  background: var(--surface);
}

/* 皮（描边 / 底色 / 浮起阴影 / 过渡）来自 .item —— 页面上每个按钮都带 class="btn item"
   （等级 L1/L2/L3、加载失败的重试、播放器的单句重复，四处都由 JS 生成）。
   ⚠️ 圆角必须留在这一节，不能指望基类：
      .item 给的是 --radius-card（16px 方角卡），而按钮是药丸形 --radius-pill（999px）。
      两者特异性相同 (0,1,0)，只能靠"谁写在后面"取胜 —— 所以 .btn 必须排在 .item 之后。
      以后谁要把 .btn 这段往前挪，按钮就会集体变成方角，先看这条注释。
   这里只留「按钮自己的形状与排布」 */
.btn {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  min-width: var(--tap-size);
  min-height: var(--tap-size);
  padding: 0 16px;
  border-radius: var(--radius-pill);
  color: var(--text);
  font-family: inherit;
  font-size: var(--text-sm);              /* Day 9：原硬编码 14px，值不变 —— 全站唯一不走 token 的字号，收回 */
  white-space: nowrap;
  cursor: pointer;
}

.btn:active {
  opacity: .7;
}

/* 描边与底色已由 .item[aria-pressed='true'] 统一给出。
   这里只剩按钮比卡片多出来的一点点：被按下时连文字也一起染成主色 */
.btn[aria-pressed='true'] {
  color: var(--accent);
}

@media (hover: hover) {
  .btn:not([aria-pressed='true']):hover {
    border-color: var(--border-strong);   /* 新增：悬停描边加深 */
    color: var(--accent);
  }
}

/* 窄屏（手机）：模式开关只留图标，文字收起来。
   Day 28 晚：多了 A-B 复读键 + 播放键左右各一个箭头 —— 一行放不下五件套，
   所以两个模式键都缩成圆圈图标，箭头本来就是圆圈（.icon-btn），不用额外处理。 */
@media (max-width: 599px) {
  .player-controls .btn {
    width: var(--tap-size);
    padding: 0;
    justify-content: center;
  }

  .player-modes {
    gap: 6px;
  }

  .player-transport {
    gap: 6px;
  }

  .player-controls .btn [data-role='repeat-label'],
  .player-controls .btn [data-role='ab-label'] {
    display: none;
  }
}

.player-status {
  margin-top: 8px;
  font-size: var(--text-sm);
  color: var(--text-faint);
}

.player-status:empty {
  display: none;
}

/* ---- 平板：两列 ---- */

@media (min-width: 600px) {
  .boards {
    grid-template-columns: repeat(2, 1fr);
  }
}

/* ---- 大屏：卡片内边距放宽一档，呼吸更足（新增）----
   ⚠️ Day 10 收尾：这一小段是【全站通用】的，跟首页布局无关，门槛留在 900px 不动。
   里面两条：.site-header h1（所有页面的大标题）、.panel（跟读页的内容面板）。
   为什么专门拆出来：实测过"连它们一起提到 1000px"会怎样 ——
   跟读页在 900–999px 宽下会出现 4 万多像素的连带改动（大标题字号回落、面板内边距跟着变）。
   本次只想解决首页横屏，不该顺手动别人的页面，所以拆成两个块。 */
@media (min-width: 900px) {
  /* 大屏上留白多，标题可以再大一号 —— 仍只用 500 字重，层级靠字号拿 */
  .site-header h1 {
    font-size: 32px;
  }

  /* ⚠️ 这一条原来是 .board, .panel 合并写的，Day 10 第二步把两者拆开了：
     .panel 是【跟读页】的内容面板，跟首页没关系，保持 --space-6 不动；
     卡片要纵向拉长，得吃到 --space-8。拆开只是为了让「只改首页」站得住。 */
  .panel {
    padding: var(--space-6);
  }
}

/* ⚠️ Day 10 收尾：首页这套桌面布局的门槛，从 900px 提到 1000px（只动这一块）。
   起因：手机【横屏】宽度轻松过 900（例如 932×430），会套上这套「三列 ＋ 整屏居中」的桌面布局，
   可它的竖向尺寸根本不够 —— 真浏览器实测：纵向多出 78px 滚动、卡片被压到 273px。
   提到 1000px 后的实测结果：932×430 变两列、溢出降到 26px、卡片反而变宽到 354px；
   ≥1000px 的尺寸完全不变；连带范围只有 900–999px 这一档。 */
@media (min-width: 1000px) {
  /* ---- Day 10：首页内容列放宽 ＋ 三列间距放宽 ----

     症状（真浏览器量出来的，不是感觉）：
       窗口 1278 → 1920，内容列一直 720、单张卡一直 232，
       两侧空白从 279 涨到 600。1920 下内容只占屏宽 37.5%。
       —— 内容不跟着屏幕长大，全长成了白边，就是斌哥说的「三个板块太过集中」。

     原因：
       --content-width 是「长文阅读」的宽度，而 main 和 .site-header 各只有
       一条 max-width、没有桌面端的升级规则；.boards 的断点也只改列数、不改间距
       （600 → 2 列、900 → 3 列，gap 从手机到大屏一直 12）。

     修法：
       ① 大屏下把首页的内容列放宽到 1280
          （设计规则给的桌面容器档是 1200 / 1280，取宽的那一档；
            斌哥看过实测对比后说「再加宽一点，不显得空洞」）。
       ② 三列间距 12 → 24。三列在宽容器里挨太近会"抱团"，
          间距得跟着容器一起放大。
          ⚠️ 规则里三列网格写的是 --space-8(32)，这里取 24 是保守一档，
             不够再往上调一个 token 就行。

     ⚠️ 为什么套 body:has(.boards)：
        main 与 .site-header 是【首页和跟读页共用】的规则。
        直接放宽它们，跟读页（长文页）也会被一起撑到 1280 —— 那是 720 才对的地方，
        改了就是"顺手扩大范围"。首页独有的特征是那张 .boards 列表，所以拿它当开关。
        项目里已经在用 :has()（.board:has(.board-link)），不新增兼容负担；
        万一浏览器不支持 :has()，整条选择器作废 → 退回 720，是安全的降级。

     ⚠️ 故意【没动】的（一次只改一个问题）：
        · 卡片内边距 24（规则区间 24–32 的下沿）→ 留着，要动单独说
        · 纵向留白：1920×800 下内容只到 y=294，下方 505 全空 → 另算一个问题
        · 窄屏（<900）与跟读页 → 一个字都不改 */
  body:has(.boards) {
    --content-width: 1280px;
    /* 顺手把左右内边距从 16 提到 24。
       ⚠️ 这一条不是"好看一点"，是补一个副作用：内容列放到 1280 之后，
          在 1278 这种窗口里可用宽度只剩 1246，max-width 根本压不住，
          内容就会**贴到离屏幕边 16px** 的地方。量出来的数字是：
          改后 1278 窗口两侧各 16px（改前是 279px）—— 太贴边了。
          设计规则里的容器模式是 .container { padding: 0 24px }，
          即左右该留 24。所以补到 --space-6。
       ⚠️ 1920 那种宽窗口不受影响：内容仍是 1280，两侧 320 —— 内边距只在
          "窗口不够宽、内容被撑满"时才起作用。 */
    padding-left: var(--space-6);
    padding-right: var(--space-6);
    /* 下内边距 40 → 24，和上边距拉平。
       ⚠️ 原来 24(上) / 40(下) 是不对称的：一旦开始算垂直居中，
          它会让整块比真中心低 8px。既然要求「确保居中」，先把基准摆正，
          后面的数才算得准（页头块 ≈ 140 这个值也是基于对称的上下边距）。 */
    padding-bottom: var(--space-6);
  }

  /* ---- Day 10 第二步 · ④ 把三张卡片挪到【整屏】垂直中间（页头留在顶部）----
     改前 1920×800 实测：内容底部只到 y=294，视口 800 —— 下方 505px 全空，
     整页吊在顶上。

     ⚠️ 这一步改了两轮，两轮错法不同，都记下来：
        第一版：把【页头＋卡片】当一整个块居中 → 页头也被拽到屏幕中间。
                他退回来：「这部分（页头）移到原来的位置，只需要把三个板块移到中间」
        第二版：只让卡片居中，但居中的是「页头下方那一片」→ 整屏看还是偏下。
                他又说：「把三个板块的位置上移一点，确保他们居中」
        ⇒ 教训：说"居中"必须先问清楚是【哪一块区域】的居中。
            "内容区居中"和"整屏居中"差半个页头，肉眼看得出来。

     ⚠️ 用 margin: auto 居中，不用 justify-content: center ——
        后者在"内容比视口还高"时（窗口拉得很矮，比如 400px 高），
        顶部会被切掉而且滚不回去，是 flex 居中的经典坑。
        auto 外边距在没有剩余空间时会自动塌成 0，不会出这个问题。
     ⚠️ 只挂在首页（:has(.boards)）：跟读页内容长、要滚动，居中毫无意义。 */
  body:has(.boards) {
    display: flex;
    flex-direction: column;

    /* 页头自己占掉的纵向高度：页头自身高（实测 115.59，1278/1440/1920
       三个宽度下都一样）＋ 它与卡片之间那条 24px 间隔 ≈ 140。
       ⚠️ 单独拎成变量，是因为下面要靠它把卡片整体抬上去 ——
          页头一改（加一行字、换 logo 尺寸），这个数就得跟着改。 */
    --header-block: 140px;
  }

  body:has(.boards) > .site-header,
  body:has(.boards) > main {
    /* ⚠️ 这一条是【量出来才发现的】，不是照着规范抄的 ——
       body 变成 flex 容器之后，页头和 main 就成了 flex item。
       它们在交叉轴上本该是 align-self: stretch（横向撑满），
       但只要同时写了 margin: 0 auto（用来横向居中），auto 外边距会【顶掉】stretch，
       宽度退回 fit-content —— 实测卡片宽从 405.33 掉到 310，三列整体缩水。
       width: 100% 把它撑回来，封顶交给 max-width。
       ⇒ 教训：给 flex item 加 auto 外边距做居中，必须显式交代宽度，别指望 stretch。 */
    width: 100%;
  }

  body:has(.boards) > main {
    /* 上下都设 auto → 剩余空间平分。但平分的只是「页头下方剩下的那片」，
       卡片会落在页头与页底之间 —— 整屏看偏下半屏。

       要落到【整屏中间】，把页头占掉的那一块补进 main 的下内边距：
       main 的盒子因此长高一块 → 可分配空间少一块 → 上下各少半块
       → 卡片整体上移半块（≈70px），正好补掉页头把重心压下去的那 70px。

       实测（1278×800）：卡片 309.8~489.8，上留白 309.8 / 下留白 310.2 —— 居中了。

       ⚠️ 这里用 padding 而不是 translate：padding 参与布局。
          窗口矮到没有剩余空间时 auto 塌成 0，卡片就老实贴在页头下方（不重叠）；
          换成 translate 不改布局，矮窗口会把卡片推到页头身上去。

       ⚠️ 页头那条 24px 下边距【保留不动】，它是保底间隔：
          窗口矮时 auto 塌成 0（不溢出），这 24px 就是页头与卡片最后的间隔。
          flex 里相邻外边距不会合并，所以是 24 + auto，那 24px 不会被 auto 吃掉。 */
    margin-top: auto;
    margin-bottom: auto;
    padding-bottom: var(--header-block);
  }

  /* ---- Day 10 第二步 · ② 卡片纵向拉长 ---- */
  .board {
    display: flex;
    flex-direction: column;
    min-height: 180px;
    /* Day 11：这一行原本写的是 padding: var(--space-8)。
       内边距搬到 .board-link 之后，这里改成【只更新那个变量】——
       32px 会自动同时传到链接层和那两张「开发中」的卡片上，一处改两处生效。 */
    --board-pad: var(--space-8);          /* 24 → 32（设计规则卡片内边距的上限） */
  }

  /* ⚠️ 内容【顶齐】，不居中 —— 这一条是量出来的，不是我偏好：
     卡片 1「文本跟读」只有 2 行（标题＋说明），卡片 2/3 有 3 行
     （多一句「开发中 · 暂不可进入」）。内容一居中，每张卡各按自己内容的高度找中点，
     三张卡的标题就错开了 —— 实测【错位 17.9px】。
     三张卡并排一行，标题不在一条水平线上，是一眼就看得出来的"歪"。
     改成顶齐：实测【错位 0px】。
     代价是卡片 1 底部多留一块空白 —— 但那块空白读起来是"卡片的内边距"（留白），
     而错位的标题读起来是"没对齐"（缺陷）。宁可选留白。
     ⇒ 教训：一组并排的卡片里，只要各卡内容行数不同，"垂直居中"就会制造错位；
        想对齐就用顶齐（或底齐），别用居中。 */

  /* ⚠️ 顺手堵一个老坑：.board-link 是 display:block，高度只够裹住文字（约 61px），
     所以"整张卡片都是点击区域"其实一直没做到 —— 卡片比链接高出一截，
     那一截点上去没反应（133px 的卡片里死区就有 ~70px）。卡片拉长后死区只会更大，
     再装看不见就说不过去了。flex: 1 让它撑满卡片，点击区才是真的整张卡。 */
  .board-link {
    flex: 1;

    /* ⚠️ display:flex 这一条是【斌哥指出来的错位】逼出来的，不是顺手加的 ——
       他在卡片1 的说明文字上划了一句：「这一句的位置与右边两句话等高」。
       量出来：卡片1 说明 top = 382.8，卡片2/3 = 394.8，**差 12px**。

       根因（这是我把 .board 改成 flex 之后【自己造出来的】）：
         · 卡片2/3 的 .board-head / .board-desc 是 .board 的直接子元素，
           .board 是 flex 容器 → 相邻外边距【不合并】→ 标题下边距 12 ＋ 说明上边距 16 = **28px**
         · 卡片1 的内容全包在 .board-link 里，而 .board-link 还是 display:block
           → 块级盒子里相邻外边距【会合并】→ 12 和 16 取大的 = **16px**
         ⇒ 同一份边距值，一个合并不合并，就差出 12px。
       验证：原始版（.board 还是块级）三张卡说明 top 差 = **0px**，是这个改动引入的。

       修法：把 .board-link 也变成 flex 容器，合并规则就和另外两张卡一致了，
       三张卡的说明文字 top 全部落在 394.8。
       ⇒ 教训：给一个盒子换 display（block ↔ flex/grid）会悄悄改掉【外边距合并】行为，
          凡是靠"相邻外边距"控制间距的地方都要重算 —— 这类 bug 不量尺寸看不出来。 */
    display: flex;
    flex-direction: column;
  }

  /* 卡片内部的纵向间距各放宽一档：标题 8→12、说明 8→16、提示 4→16。
     多出来的高度让"真的间距"承担一部分，而不是全靠底部堆空白。 */
  .board-head {
    margin-bottom: var(--space-3);
  }

  .board-desc {
    margin-top: var(--space-4);
  }

  .board-hint {
    margin-top: var(--space-4);
  }

  .boards {
    grid-template-columns: repeat(3, 1fr);
    gap: var(--space-8);                  /* Day 10 ③ 24 → 32：三列再拉开一档，不挤在一起 */
  }
}

/* ---- 新增：无障碍 —— 用户系统设了「减少动态效果」就全部关掉 ---- */


/* ============================================
   交互增强（新增）
   ⚠️ 关键取舍：入场动画用 CSS 的 translate 属性，不用 transform。
      因为 .board 悬停也用 transform，而动画的 fill-mode: both 会一直
      压着 transform 不放，导致悬停上浮失效。translate 和 transform
      是两个独立属性，互不打架 —— 动画归动画，悬停归悬停。
   ============================================ */

@keyframes riseIn {
  from { opacity: 0; translate: 0 10px; }
  to   { opacity: 1; translate: 0 0; }
}

.brand {
  animation: riseIn .45s var(--ease-out) both;
}

.board {
  animation: riseIn .5s var(--ease-out) both;
}

.board:nth-child(1) { animation-delay: .06s; }
.board:nth-child(2) { animation-delay: .13s; }
.board:nth-child(3) { animation-delay: .20s; }

/* ---- 跟读页：四块依次上浮（Day 8 追加；Day 8 后又把第三、四块拆开）----
   主界面那套动效的「翻译版」：主界面动的是品牌区 ＋ 三张卡片；
   跟读页没有品牌区，就对应到它自己的四块 —— 页头 / 等级目录 / 文件列表 / 音频文本区。
   ⚠️ 为什么要 .page-reader 限定：.site-header 两个页面都有。
      不限定的话主界面会被动两次（外层页头动一回、里面的品牌区再动一回），
      而且第二回是套在第一回里的，看着像卡了一下。
   ⚠️ 为什么动 #reader > * 而不是只动 .material：
      素材区有三种状态 —— 素材卡片 / 该级无素材的 .empty / 整个清单读不到。
      写死 .material 的话，空状态和加载中那个「正在加载素材…」都不会动，
      切换等级时就会出现"卡片浮入、空状态硬闪"的不一致。
      同理文件列表也要动 —— 它的空状态（「L2 暂无素材」）同样在这层里。
   ⚠️ 四块的延迟依次 0 → .08 → .16 → .24，每档差 80ms：
      比主界面三卡片的 60/70ms 略慢，因为这里块数更多、每块内容更重，
      差太密看着像一起弹出来，就白排了。
   ⚠️ 切换等级时，只有【文件列表和素材区】会重播：
      renderFiles / renderMaterialView 重建了它们的 innerHTML，新节点自然重跑动画。
      .levels 那排按钮【不重播】—— pickLevel 只改 aria-pressed、不重铺按钮
      （为了不丢键盘焦点），所以它稳住不动，正好合意。
   ⚠️ 依旧用 riseIn（translate），不用 transform，理由见本节开头的取舍说明。
   ⚠️ Day 28 晚：`.page-reader .levels` 那一条【已删除】—— 等级目录搬进左栏，
      它不再属于主区的"依次上浮"队列；侧栏是常驻骨架，跟着内容一起动会显得散。
      延迟链随之收成 0 → .16 → .24（剩下的三块）。 */
.page-reader .site-header { animation: riseIn .45s var(--ease-out) both; }
.page-reader .files       { animation: riseIn .45s var(--ease-out) both; animation-delay: .16s; }
.page-reader #reader > *  { animation: riseIn .5s  var(--ease-out) both; animation-delay: .24s; }

/* ---- 素材卡：换成「一句一句独立浮现」（Day 8 追加）----
   斌哥要的效果：音频文本这一页不再整块浮入，改成一句一句自己出现。
   ⚠️ 为什么必须把素材卡整块的 riseIn 关掉：
      .seg 是卡的子元素。整块淡入（.24s 起，.5s 长）＋ 每句各自淡入，
      两层 opacity 相乘，前面的句子会"半透明地浮两遍"，看着发糊、也看不出先后。
      所以卡【不整块动】，让里面每一句自己动 —— 这是这次改动的关键一步。
   ⚠️ 卡上的标题 / 统计 / 播放条还是轻轻浮一下，而且【不带延迟】：
      让"框架先就位、句子再填进来"。不然句子在动、框架却"啪"地硬闪出来，像两套东西。
      .player-status 也要带上，否则播放报错那行字会孤立地硬闪。
   ⚠️ 用 --i（第几句，写在元素的 style 里）算延迟，不用 nth-child：
      nth-child 要写死条数，素材多一句就漏一句；--i 则多少句都成立。
      min(--i, 15) 封顶 → 最长的素材也就等到 0.6s，不会越往下等越久（16 句的素材
      不等封顶的话最后一句要等到 .64s 之后，长素材更是等不完）。 */
.page-reader #reader > .material { animation: none; }

.page-reader #reader > .material > .mat-head,
.page-reader #reader > .material > .mat-meta,
.page-reader #reader > .material > .player,
.page-reader #reader > .material > .player-status {
  animation: riseIn .35s var(--ease-out) both;
}

.seg {
  animation: riseIn .4s var(--ease-out) both;
  animation-delay: calc(min(var(--i), 15) * .04s);
}


/* 「可用」标签的小绿点缓缓呼吸 —— 提醒"这里能进"。
   压到 2.6s 一个循环，几乎不打扰，只是让静态页面有一点活气。 */
@keyframes dotPulse {
  0%, 100% { box-shadow: 0 0 0 3px rgba(26, 44, 115, .14); }
  50%      { box-shadow: 0 0 0 6px rgba(26, 44, 115, .05); }
}

.badge-ok::before {
  animation: dotPulse 2.6s var(--ease-out) infinite;
}

/* 键盘用户按 Tab 走到卡片时，除了焦点环再给一层底色，焦点位置更明确 */
.board-link:focus-visible {
  outline-offset: 2px;
  border-radius: 8px;
}

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    transition-duration: .01ms !important;
    animation-duration: .01ms !important;
    /* ⚠️ 这一行是必需的，别删：入场动画的 fill-mode 是 both，
       意思是在「延迟期间」元素就已经停在动画起点 —— 也就是 opacity: 0。
       只压 duration 不压 delay 的话，内容仍会一个一个蹦出来白等，
       跟「减弱动效」的本意正好相反。
       Day 8 之前主界面只有 3 张卡、延迟最多 0.2s，看不出来；
       给跟读页加上多块依次入场后才暴露。 */
    animation-delay: 0s !important;
  }

  .board:has(.board-link):hover {
    transform: none;
  }
}

/* ============================================================
   Day 10 · 全站动态背景（变体 A「品牌绿雾」）
   ------------------------------------------------------------
   ⚠️⚠️ Day 28 晚整段改版：升级为「方案 B · 高斯模糊交融」——
      斌哥看了预览页（WorkBuddy/day28/bg-preview.html）后拍板 B 方案，
      并要求加快色块流速。下面「性能三条约束」里那条
      「柔边用 radial-gradient 自带，不用 filter: blur()」【已被有意推翻】，
      新实现与性能取舍见下方 1744 行起的说明。历史推理保留不删（留尸规矩）。
   ------------------------------------------------------------
   参考：QQ 电脑端登录界面那种"大面积柔光缓慢流动"的观感。
   要求（斌哥 10:16）：「不管手机还是电脑，要在网站内的所有页面都有效」
                     ＋「缩短电脑端的空白距离」＝把背景里没铺到色的白区压下去。

   ★ 为什么写在 style.css 里就等于"所有页面都有效"：
      index.html 和 reader.html 引的是【同一张 style.css】——
      改这一处，两个页面同时生效，一个 HTML 标签都不用加。
      以后新增页面只要引这张表，背景自动带上，不会漏。

   ★ 为什么用 html / body 的伪元素，而不往 HTML 里加 <div>：
      一共 3 个光斑，正好 html::before / html::after / body::before 装得下。
      好处：① 零 HTML 改动，不碰结构；② 伪元素是装饰，读屏软件本来就不念。
      ⚠️ 已核对：这三个伪元素在本文件里【原本没人用】
         （其余 ::before / ::after 都挂在 .site-header / .board / .badge / .board-link 上）。

   ★ 为什么 position: fixed 在这里是安全的：
      首页的 body 是 flex 容器（Day 10 第二步为了垂直居中）。
      **绝对 / 固定定位的子元素不是 flex item**，不参与 flex 排布，
      所以这两个伪元素不会把居中算乱。
      另外 html / body 上都没有 transform / filter / will-change，
      不会给 fixed 元素造出新的包含块 —— 它老老实实钉在视口上。
      ⇒ 但这一条必须验，不能推理完就信：改完要量首页卡片位置与改前是否一致。

   ★ 性能三条约束：
      ① 柔边用 radial-gradient 自带，**不用 filter: blur()** ——
         blur 是逐像素卷积，几个 100vw 的大光斑一起动会明显掉帧；
      ② 只动 transform（translate3d / scale）→ 走合成器线程，不触发重排重绘；
      ③ 四个光斑【同周期 20s · 同相位】，靠**振幅各不相同**错开位置 ——
         周期相同 ⇒ 任意两个光斑的相对位移被"振幅之差"限死，**永远不会撞在一起**。
         （Day 10 更正：这里早先写的是"三个光斑时长各不相同 64 / 79 / 93 秒"，
           那套做法已被弃用 —— 时长不同步的观感像呼吸机，而且撞不撞车控制不住。） */

/* 全站底色 ＋ 中央那道"融化的高光"。
   ⚠️ 底色只能有一个来源：挂在 html 上，z-index:-1 的 4 个色块才透得上来。
   Day 10 之前它是写在 body 上的（radial-gradient 光晕 ＋ --bg 两层），
   加动态背景时整段搬到 html —— body 一旦有自己的背景，就会把光斑盖住。
   搬走之后 body 不再声明任何 background，所以也不再需要
   一条 body{background:none} 去"覆盖"它（那条已随本次清理删除）。
   Day 28 晚：中央叠一层静态白光 —— 参考图（QQ 登录页）正中那道亮带。
     它住进 html 的 background 里而不是占一个色块槽位：伪元素只有 4 个，
     白光几乎不动，占一个彩色槽位不划算；静态白心 + 四色漂移，观感等价。 */
html {
  background:
    radial-gradient(circle at 50% 42%,
      var(--bg-glow) 0%, transparent 36%),
    var(--bg);
}

/* ---- 流速总开关（Day 28 晚新增）----
   原来四块的周期写死在各自的 animation 里（20s）。改流速得改四处、
   漏一处就"四块不同周期"—— 那是 Day 10 用血泪换来的大忌（见关键帧段的教训①）。
   收成一个变量：想再快再慢，只改这一行，四块天然同步。 */
html {
  --bg-flow: 14s;      /* 原版 20s → 14s（斌哥要求"增加色块流速"）；想调就改这里 */
}

/* 四个色块共用的骨架
   ⚠️ html 与 body 各有两个伪元素槽位，一共 4 个，正好一个色块占一个 ——
      所以才能做到"零 HTML 改动"。
   ⚠️⚠️ Day 28 晚核心改动：加 filter: blur(90px) —— 液体交融的灵魂就在这一行。
      硬边实色块被 90px 的高斯模糊熔开，色与色之间不再有"圆斑边界"，
      这才是参考图那种"液体流动"的观感；原版只有径向渐变软边，色块之间是"相邻"，不是"相融"。
   ⚠️ 性能取舍（有意识的，不是没看见）：Day 10 当年避开 blur 正是因为它逐帧重绘。
      这次用它换真液感，代价已向斌哥言明。降级路径也埋在结构里 ——
      色块填的不是实色，而是【先 plateau 后软落】的径向渐变（0~38% 全色，80% 透明）：
      真机上若掉帧，把下面那行 filter 删掉即可优雅退回"软边光斑"观感，不用重做。
      prefers-reduced-motion 的用户本来就静止，那群人不受 blur 动画影响。 */
html::before,
html::after,
body::before,
body::after {
  content: "";
  position: fixed;
  z-index: -1;                             /* 压在内容之下、html 底色之上 */
  display: block;
  border-radius: 50%;
  pointer-events: none;                    /* 绝不挡点击 */
  transform: translate3d(0, 0, 0);
  will-change: transform;
  background: radial-gradient(circle at 50% 50%, var(--c) 0%, var(--c) 38%, transparent 80%);
  filter: blur(90px);
}

/* ---- 四个色块：尺寸 / 位置 / 颜色 / 流速 ----
   Day 28 晚配色按参考图（QQ 登录页）重排：蓝 → 青 → 紫，品牌绿留一角，
   琥珀橙退役（它和紫/青叠在一起会脏）；中心白光已并入 html 底色（见上）。
   中心仍钉在四个象限（Day 10 量出来的教训：偏移写法会让色块挤成一团），
   直径 55~68vmax：blur 会把视觉尺寸撑大约一个模糊半径，故比原版略收。 */
html::before {
  width: 68vmax; height: 68vmax;
  left: calc(30% - 34vmax); top: calc(22% - 34vmax);      /* 左上：深蓝 */
  --c: var(--blob-a);
  animation: bgDriftA var(--bg-flow) linear infinite;
}
html::after {
  width: 60vmax; height: 60vmax;
  left: calc(74% - 30vmax); top: calc(16% - 30vmax);      /* 右上：青 */
  --c: var(--blob-b);
  animation: bgDriftB var(--bg-flow) linear infinite;
}
body::before {
  width: 62vmax; height: 62vmax;
  left: calc(22% - 31vmax); top: calc(86% - 31vmax);      /* 左下：紫 */
  --c: var(--blob-c);
  animation: bgDriftC var(--bg-flow) linear infinite;
}
body::after {
  width: 55vmax; height: 55vmax;
  left: calc(84% - 27vmax); top: calc(82% - 27vmax);      /* 右下：品牌绿 */
  --c: var(--blob-d);
  animation: bgDriftD var(--bg-flow) linear infinite;
}

/* ---- 关键帧：矩形轨道，四块【同周期 · 同相位 · 同方向】----
   ⚠️ 这一版是"距离 + 均匀度"逼出来的，三个决定都要说清楚：

   ① 为什么四块必须【同周期同相位】（之前是 18/22/26/30 各不相同的）：
      原来四块的漂移方向是【互相朝着】的（A 往右下、B 往左上、C 往左下…），
      加上周期各不相同 ⇒ 总会在某个时刻撞到一起。实测扫 0~30s：
        桌面最小中心距 392（t=0 最好）→ **152（t=9s 最挤）**
        手机 185 → **51（t=10s）**，几乎叠死。
      改成同周期同相位、只让**振幅**略有差异之后，"相对位移"被严格压在
      **振幅差**之内（这几块差 ≤4vw / 2vh）⇒ **永远不可能撞到一起**，而且不用赌。
      代价是四块不再"各自为政"—— 换来的是"分布永远均匀"这个硬保证。
      ⚠️ 别再为了"看起来更自然"把周期改回去，除非接受它在某个时刻挤成一团。

   ② 为什么用矩形轨道（4 段）而不是两端往返：
      两端往返配 ease-in-out，在两端速度趋近于 0 —— 一半时间看着像静止。
      矩形轨道 + linear ⇒ **全程匀速，永不刹停**，"变化率"直接上一个台阶。

   ③ 为什么振幅是"近似但不等"：
      完全相等 = 整块背景刚性平移，一眼看出像"整页在滑"；
      差一点点 = 图案会缓慢变形，更像"流动"。
      而差异被控制在 2vw / 1vh 以内，所以 ① 的保证依然成立。

   ④ 为什么轨道是【围绕原位对称摆动】（±9vw），而不是"从原位单向跑出去"：
      第一版写成 0% → 18vw，等于整场一直往右平移 18vw ——
      **左边缘被腾空**：实测 t=5s 白区飙到 **11.6%**、3×3 均匀比恶化到 4.81。
      改成以原位为中心 ±9vw 摆动后，出场永远不超过半程，边缘不会再露白。
      ⇒ 教训：让一群元素"整体平移"时，一定要检查【平移方向的后方】会不会露空。 */
@keyframes bgDriftA {
  0%   { transform: translate3d(-9vw, -6vh, 0); }
  25%  { transform: translate3d(9vw, -6vh, 0); }
  50%  { transform: translate3d(9vw, 6vh, 0); }
  75%  { transform: translate3d(-9vw, 6vh, 0); }
  100% { transform: translate3d(-9vw, -6vh, 0); }
}
@keyframes bgDriftB {
  0%   { transform: translate3d(-8vw, -5.5vh, 0); }
  25%  { transform: translate3d(8vw, -5.5vh, 0); }
  50%  { transform: translate3d(8vw, 5.5vh, 0); }
  75%  { transform: translate3d(-8vw, 5.5vh, 0); }
  100% { transform: translate3d(-8vw, -5.5vh, 0); }
}
@keyframes bgDriftC {
  0%   { transform: translate3d(-10vw, -6.5vh, 0); }
  25%  { transform: translate3d(10vw, -6.5vh, 0); }
  50%  { transform: translate3d(10vw, 6.5vh, 0); }
  75%  { transform: translate3d(-10vw, 6.5vh, 0); }
  100% { transform: translate3d(-10vw, -6.5vh, 0); }
}
@keyframes bgDriftD {
  0%   { transform: translate3d(-8.5vw, -6vh, 0); }
  25%  { transform: translate3d(8.5vw, -6vh, 0); }
  50%  { transform: translate3d(8.5vw, 6vh, 0); }
  75%  { transform: translate3d(-8.5vw, 6vh, 0); }
  100% { transform: translate3d(-8.5vw, -6vh, 0); }
}

/* —— 旧的关键帧块（3 段往返、位移 24/16vw）已在这里删除。
   ⚠️ 删它不是为了整洁：CSS 里【同名 @keyframes 后定义会覆盖先定义】，
      新版写在前面、旧版留在后面 ⇒ 浏览器实际用的是旧版，等于白改。
      （上面那份矩形轨道版才是生效的。） */

/* 系统开了「减少动态效果」→ 光斑彻底不动。
   ⚠️ 这里用 animation: none，而不是只靠文件上方那条通用规则去压 duration：
      压成 .01ms 会让"无限循环"每帧重头再来 —— 看不出区别，但白占合成器。 */
@media (prefers-reduced-motion: reduce) {
  html::before,
  html::after,
  body::before,
  body::after {
    animation: none !important;
  }
}

/* ⚠️ 顺带堵一个无障碍漏洞 —— 是【背景加上去之后】才暴露出来的：
   首页那行状态文字（「正在加载板块…」「暂无板块」「读取失败…」）用的是 .empty，
   而 .empty 的颜色是 --text-faint(#606c77)。它直接压在【页面背景】上，
   实测在背景最暗处只有 3.23:1，**远低于 AA 要求的 4.5:1**。
   ⇒ 先把这一处提到 --text-muted（4.6:1 过线）；后来背景再加密，
     --text-muted 的余量也不够了 → 直接提到 **--text**（≈9:1，留足余量）。
     ⚠️ 故意不全局改 .empty：跟读页的 .empty 待在白色 .panel 里，本来就达标，
        全局改等于顺手扩大范围。 */
body:has(.boards) .empty {
  color: var(--text);
}

/* ============================================================
   Day 11 · 跨页过渡（View Transitions）
   首页点卡片 → 跟读页，原来是一瞬间的硬切。这里让它连成一段上移：
     旧页（首页）    向上移、同时逐渐消散  ← 正好接住卡片"扩散铺满"那一下
     新页（跟读页）  从下方上移归位、同时逐渐显现

   为什么两页都【往上】走，而不是一左一右：
     同向的上移才读得出「同一叠纸往上翻了一层」；
     一左一右会读成两件互不相干的事，衔接感反而没了。

   ⚠️ 渐进增强，刻意【不写 JS 兜底】：
      认不出 @view-transition 的浏览器（Chrome 126 以下、部分 Safari）
      直接忽略这几条规则，点卡片照旧是一次普通跳转 ——
      功能一个不少，只是没有过场。为这个去写一段 JS 兜底不划算。
   ⚠️ 跨文档过渡要求【进出两个文档都声明】。但那条 opt-in【不在本文件里】，
      而是分别写在 index.html 与 reader.html 的 <head> 里 —— 为什么，见下面那段注释。
   ============================================================ */

/* ⚠️⚠️ 【这里故意没有 @view-transition { navigation: auto }】—— 别好心把它加回来。
   这条 opt-in 挪去了【每个页面 <head> 里、紧跟 charset 的 <style>】。原因是实测出来的：

   · 规范说这个检查应该和 pagereveal 一起、在首次渲染前做，理当等外部样式表；
     但 Chrome【不保证】等，它可能在 style.css 还没解析到时就去查这条规则。
   · 放在这份 1800 行样式表的【末尾】时，实测 6 次导航只有 1~2 次生效 ——
     "有时行有时不行"，最坏的那种 bug。查了很久才定位到这一条。
   · 挪进 <head> 之后：同一套真实页面，6/6 全部生效（另有两组变体也是 6/6）。

   ⇒ 以后要改这条规则，改的是 index.html 和 reader.html 的 <head>，不是这里。
      这里保留 keyframes 与 ::view-transition-* —— 它们是动画本身，放在共用样式表里没问题。 */

/* 旧页：向上移 + 消散 */
@keyframes vtLeave {
  to { transform: translateY(-24px); opacity: 0; }
}

/* 新页：从下方上移归位 + 显现 */
@keyframes vtEnter {
  from { transform: translateY(24px); opacity: 0; }
}

/* ⚠️ 这里【不覆盖】UA 自带的 mix-blend-mode: plus-lighter ——
   它正是为了让"淡出 + 淡入"叠在一起时不露出页面背后的底色。
   自己改成 normal 反而会在中途漏出一层灰。 */
::view-transition-old(root) {
  animation: vtLeave var(--duration-slow) var(--ease-out) both;
}

::view-transition-new(root) {
  animation: vtEnter var(--duration-slow) var(--ease-out) both;
}

/* 系统开了「减少动态效果」→ 过场整个不做，退回一次干脆的跳转。
   ⚠️ 要把两组动画都清掉，不能只清一组：只清旧页的话，新页仍会
      从下方滑上来，等于"轻描淡写地又动了一次"，与本意相反。 */
@media (prefers-reduced-motion: reduce) {
  ::view-transition-old(root),
  ::view-transition-new(root) {
    animation: none;
  }
}

/* ============================================
   Day 13：视图之间平级切换 ＋ 列表页（词汇速记 / 作文模板）
   本节全部是【新增】，上面的规则一条没动 —— 首页与跟读页已按
   PRD 第六节验收过，改它们等于把跑通的东西拉回风险里。
   ============================================ */

/* ---- 视图切换导航条 ----
   三个内页（跟读 / 词汇 / 作文）共用同一条，互相直达，不必先绕回首页。
   每一项都挂 class="btn item"：药丸形与那身皮直接复用现成组件，不另写一套。
   ⚠️ 语义用的是 aria-current="page"（「当前页就是这一个」，链接的正确说法），
      而 .item 那套选中皮只认 aria-current="true"（那是给"当前打开的文件"用的），
      两个值互不相通 —— 所以下面单独补一条，把同一套视觉给到 'page'。
      不这么写就得把导航项改成 aria-current="true"，那等于为了迁就样式写错语义。 */
.view-nav {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2);
  margin-bottom: var(--space-4);
}

/* .btn 是照 <button> 写的，链接得自己摘掉下划线 */
.view-nav a {
  text-decoration: none;
}

.view-nav a[aria-current='page'] {
  border-color: var(--accent);
  background: var(--ok-bg);
  color: var(--accent);
}

/* ---- 列表页：词汇速记 / 作文模板 ----
   一行一条，三栏：主词（或模板名）· 说明 · 右标签。
   ⚠️ 为什么不用 .file-btn：那个是「可点的按钮行」—— 带 cursor: pointer、
      带 aria-current 选中态。而这里的条目是【纯展示】，点了什么都不该发生；
      借那套皮等于给用户一个点不动的按钮，是误导。
      所以只借 .item 那身皮（白底 / 描边 / 圆角 / 阴影），排布自己写。 */
.list {
  display: flex;
  flex-direction: column;
  gap: 8px;
  list-style: none;
}

/* 列表空着时整个收掉：空状态那句话已经写在 #list-status 里，
   留着这个空 <ul> 只会白占一段间距（同 #reader:empty 的思路） */
.list:empty {
  display: none;
}

.list-item {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr) auto;
  align-items: baseline;
  gap: var(--space-3);
  padding: 12px 16px;
}

.li-title {
  font-weight: 500;
  overflow-wrap: break-word;
}

.li-desc {
  font-size: var(--text-sm);
  color: var(--text-muted);
  overflow-wrap: break-word;
}

/* 右标签：单元 / 类型那一类归组信息。
   tabular-nums 让数字等宽 —— 上下两行的 Unit 1 / Unit 2 不会左右跳 */
.li-tag {
  font-size: var(--text-xs);
  color: var(--text-faint);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

/* 窄屏：三栏挤不下，改成竖排（说明换到第二行，标签跟在后面） */
@media (max-width: 599px) {
  .list-item {
    grid-template-columns: minmax(0, 1fr);
    gap: var(--space-1);
  }
}

/* ---- 列表页入场：和跟读页同一套节奏（页头 → 导航 → 列表 依次上浮）----
   ⚠️ 必须用 .page-list 限定：.site-header 每个页面都有，
      不限定的话首页和跟读页会被动两次（见跟读页那一节踩过的坑）。
   ⚠️ #list-status 和 #list 都要动：列表区有四种状态，
      只动其中一种，就会出现「列表浮入、空状态硬闪」的不一致。
   ⚠️ 依旧用 riseIn（translate 属性），不用 transform，理由见本节上方那段说明。 */
.page-list .site-header { animation: riseIn .45s var(--ease-out) both; }
.page-list .view-nav    { animation: riseIn .45s var(--ease-out) both; animation-delay: .08s; }
.page-list #list-status,
.page-list #list        { animation: riseIn .5s  var(--ease-out) both; animation-delay: .16s; }

/* ============================================
   Day 28：跟读页的「课目录」层 —— 【已整层删除】
   ------------------------------------------------------------
   ⚠️⚠️ 这条记录已被同一天更晚的版本推翻，保留原文只为留痕：
      上面说的"整层回退"只维持了很短一段；斌哥随后明确要求把课目录做回来，
      但做法换成了【两页互斥、点一下换一页】（课程列表页 ⇄ 音频选择页），
      而不是当初那种"两个列表同屏叠着"。
      ⇒ 生效的实现见本文件上面「Day 28 晚（重做版）：主区的课程列表页与回路」那一段。
      留下这段的原因是它是"为什么第一版会失败"的唯一现场记录：

   第一版为什么被打回：课条目改到「与音频行同尺寸」之后，
   Lesson 列表和点开后的文件列表长得一模一样 —— 同一个页面读起来像出现了两遍。
   于是整层回退：目录回到「等级 → 文件 → 音频文本」三层，课层的
   HTML 挂点 / app.js 逻辑 / 本节样式 / materials.json 的 lessons 数据全部清干净。
   （撞了才知道：给"目录层"和"内容层"做一模一样的行，本身就是个坏主意 ——
     两条路长得一样，用户会以为自己没点动；第二版的解法是让它们永远不同屏。）
   ============================================ */

/* ============================================
   Day 28 晚 · 全站悬停反馈（统一增强，斌哥要求）
   ------------------------------------------------------------
   要求：光标浮在任何"板块"上时，下方产生阴影 + 周围加一圈浅描边。

   做法上只写【一处】，挂共享皮 .item（站内绝大多数卡片/按钮都带它：
   .file-btn / .lesson-btn / .list-item / .btn / .acct-card …），
   再加几个不挂 .item 的"边缘住户"（图标键、头像键、左栏条目）。
   ⚠️ 为什么不逐个组件写：那样改一处漏一处，永远对不齐 ——
      这和 Day 8 把"点击条目的皮"收进 .item 是同一个道理。

   ⚠️ 两条技术约束（都是踩过的坑）：
     ① 描边一律走【已有边框改颜色】或【box-shadow 的 0 0 0 1px 环】，
        绝不新增 border 宽度 —— 加 1px 边框会让元素尺寸跳一下，鼠标扫过去
        整列会轻微抖动（"扫过时页面在喘气"）。
     ② 位移用 transform（合成器线程），配合 .item 里已有的 transition，
        鼠标移开能平滑归位，不触发重排。
     ③ 选中态（aria-current / aria-pressed）不改悬停观感 —— 它本来就该"稳"，
        再抬一下会让"已选中"看起来像"又要被点走"。
   ============================================ */

@media (hover: hover) {
  /* ① 所有带 .item 皮的卡片 / 按钮 / 列表行
        Day 28 晚二改（斌哥指示）：悬停要「颜色加深 + 描边」——
          · 底色：白 → 淡薄荷（比选中态的 --ok-bg 浅一档，两者不会看混）
          · 描边：边框转强调绿（不是"加深一档灰"，要一眼看得出有圈线）
          · 再叠一圈 3px 的浅绿外环：卡片底下有阴影时，单靠 1px 边框
            在花色背景上边界会发虚，外环把"这个板块被点亮了"说清楚
        ⚠️ 自定义色值就地写十六进制，没有塞进 :root —— 它是这一处悬停的专用色，
           挂全局会让人以为别处也能用。 */
  .item:hover:not([aria-current='true']):not([aria-pressed='true']) {
    background-color: var(--item-hover-bg);
    border-color: var(--accent);
    box-shadow: var(--shadow-md), 0 0 0 3px var(--hover-ring);
    transform: translateY(-2px);
  }

  /* ② 圆形图标键（播放器的箭头 / 播放键）+ 两个不挂 .item 的可点元素
        （登录页的验证码图、登录/注册 tab）—— 同样的"加深 + 描边"：
        底色转淡薄荷、描边转绿并加外环。
        ⚠️ 验证码图 / tab 的样式住在 app-shell.css 里（那份后加载），
           但那边没写 background/box-shadow/transform 的悬停态，所以这几条照样生效。 */
  .icon-btn:not([disabled]):hover,
  .captcha-box:hover,
  .auth-tab:hover:not([aria-selected='true']) {
    background-color: var(--item-hover-bg);
    border-color: var(--accent);
    box-shadow: var(--shadow-md), 0 0 0 3px var(--hover-ring);
    transform: translateY(-2px);
  }

  /* ③ 顶栏头像键：它本来就是绿的，只加深一点 + 描边加亮 */
  .avatarbtn:hover {
    border-color: var(--accent);
    box-shadow: var(--shadow-md), 0 0 0 3px var(--hover-ring);
    transform: translateY(-2px);
  }

  /* ④ 左栏的三板块条目 + L1/L2/L3 —— 它们本身"透明底"、悬停时已经是薄荷底
        （那一步就是"颜色加深"），这里把描边补上：强调环 + 下阴影。 */
  .sidenav__item:hover:not([aria-current='page']),
  .side-levels .level-btn:hover {
    box-shadow: 0 0 0 1px var(--accent),
                0 0 0 4px var(--hover-ring),
                0 4px 10px -4px rgba(16, 24, 40, .22);
    transform: translateY(-1px);
  }
}

/* 系统开了「减少动态效果」→ 只保留"描边 + 阴影"（那是静态的可视反馈），
   去掉位移。移动带来的不适感对这部分用户是实打实的。 */
@media (hover: hover) and (prefers-reduced-motion: reduce) {
  .item:hover:not([aria-current='true']):not([aria-pressed='true']),
  .icon-btn:not([disabled]):hover,
  .captcha-box:hover,
  .auth-tab:hover:not([aria-selected='true']),
  .avatarbtn:hover,
  .sidenav__item:hover:not([aria-current='page']),
  .side-levels .level-btn:hover {
    transform: none;
  }
}
