/* ==========================================================================
   cz-blog 前台设计系统 · 终末地工业编辑风
   规范见 .claude/skills/blog-design/SKILL.md，可运行原型见 prototypes/g-endfield-terrain.html

   这份文件只服务前台。后台 12 个模板共用 me.css，那边一个字都不动：
   me.css 里有 * { border-radius: 0 } 与 body { font-size: 17px } 这类全局规则，
   原地改写会把 Element UI 的按钮、输入框、对话框全部削平。等后台单独用 Element UI 重做时
   再连文件一起删（届时只需把 .admin-body 一条规则搬走）。

   字体不在这里 @font-face：拉丁与中文各三个 woff2，放在 fonts/fonts.css。
   页面先引 fonts.css 再引本文件，这样才能给首屏字体加 preload。
   ========================================================================== */

/* ==========================================================================
   1. 令牌
   ========================================================================== */
:root {
  /* ---- 纸与墨 ---- */
  --bg:            #ffffff;   /* 纯白是刻意选的：早期用暖灰纸 #e8e8e2，多数显示器上发闷 */
  --bg-sunken:     #fafaf9;   /* 信息条 / 页脚 */
  --panel:         #fbfbfa;   /* 抽屉 / 悬停面 */
  --line:          #e8e8e8;   /* 细线 l1 */
  --line-strong:   #d4d4d4;   /* 重线 l2 */
  --ink:           #111111;   /* 正文      白底 19.1:1 */
  --ink-2:         #4a4a4a;   /* 次要文字  白底  8.9:1 */
  --ink-on-signal: #111111;   /* 信号色底上的墨字 */

  /* ---- 信号黄：按「要不要被读到」分三档 ----
     任何亮到能被称为「亮黄」的颜色对白底都是 1:1，这是明度决定的物理限制，
     所以黄不能当一个颜色用，必须按落点拆角色。 */
  --signal:        #6b5d00;   /* 文字 / 焦点环         白底 6.58:1 */
  --signal-fill:   #ffe600;   /* 实心块，块内墨字 15.0:1 */
  --signal-fill-h: #ffd000;   /* 实心块 hover / press */
  --signal-edge:   #edc800;   /* 细线与描边            白底 1.63:1，只求看得见 */
  --signal-rgb:    255, 230, 0;

  /* ---- 几何与字阶 ---- */
  --cut:      16px;   /* 斜切楔形尺寸 */
  --fs-label: 15px;   /* 技术标签层字号：时间戳、页码、角度、侧轨编号。只做注释，不承载正文 */

  --font-cjk:   "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
  --font-sans:  "Space Grotesk", var(--font-cjk);
  --font-disp:  "Archivo", "Space Grotesk", var(--font-cjk);
  /* JetBrains Mono 没有中文字形。栈里必须垫中文字体，否则 mono 元素里的中文会一路回退
     到系统默认等宽（中文 Windows 上是宋体），和全站字体不是一套。字体回退按字形走：
     拉丁与数字仍走 JetBrains Mono，只有它缺的中文才落到 Noto Sans SC。 */
  --font-mono:  "JetBrains Mono", var(--font-cjk), ui-monospace, monospace;
}

/* ==========================================================================
   2. 基础
   ========================================================================== */
*, *::before, *::after { box-sizing: border-box; border-radius: 0 !important; }
html { scroll-behavior: smooth; }

body {
  margin: 0;
  background: var(--bg);
  color: var(--ink);
  font-family: var(--font-sans);
  font-size: 17px; line-height: 1.75;
  overflow-x: hidden;
  -webkit-font-smoothing: antialiased;
  font-variant-numeric: tabular-nums;   /* 日期、耗时、百分比跳动时不产生横向抖动 */
}

/* 本来就圆的形状保留圆形。用 !important 是防止任何第三方组件带进圆角 */
.avatar, .dot, .spinner, .fr-hub-ava { border-radius: 50% !important; }

::selection { background: var(--signal-fill); color: var(--ink-on-signal); }
:focus-visible { outline: 2px solid var(--signal); outline-offset: 1px; }

::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--line-strong); }
::-webkit-scrollbar-thumb:hover { background: var(--signal); }

a { color: inherit; text-decoration: none; }
img { display: block; max-width: 100%; }
button { font: inherit; }

.wrap { max-width: 1400px; margin: 0 auto; padding: 0 24px; }
@media (min-width: 1024px) { .wrap { padding: 0 48px; } }

/* 字号降回 15px 后字距要放回来：大写等宽字在小字号下需要更多呼吸才不糊成一团 */
.mono    { font-family: var(--font-mono); font-size: var(--fs-label); letter-spacing: .14em; text-transform: uppercase; }
.mono-sm { font-family: var(--font-mono); font-size: var(--fs-label); letter-spacing: .06em; }

/* 纯中文的标签不要给 mono：没有拉丁字符需要等宽对齐时，mono 只剩多余字距和对中文无效的 uppercase */
.u-plain { font-family: var(--font-sans); font-size: var(--fs-label); letter-spacing: normal; text-transform: none; }

/* 眉标签：一段短划线 + 一行等宽小字，标出「这是哪一块」。
   首页 hero、三个错误页、五个内页都用它，所以是独立类，不绑在任何一个版块上。
   全站克制使用：每 3 个章节最多 1 个。 */
/* keep-all：窄屏上眉标签会折行，而中文默认可以在任意字之间断开，
   于是「关于这个博客」被拆成「关于这个博 / 客」。keep-all 让中文词组整体不拆，
   折行点落到那个斜杠的空格上；再配 break-word 兜底 —— 万一某个词长到一行放不下，
   仍然允许它断，不会撑出横向滚动条。 */
.eyebrow {
  display: flex; align-items: center; gap: 14px; color: var(--ink-2); margin-bottom: 26px;
  word-break: keep-all; overflow-wrap: break-word;
}
.eyebrow .bar { width: 40px; height: 1px; background: var(--signal-edge); flex-shrink: 0; }

/* 斜切楔形。clip-path 是 grouping 属性，会拍平子元素的 3D 变换：
   一块图既要斜切又要停在不同纵深时，把 clip-path 和 translateZ 放在同一个元素上，
   它自身的 3D 定位仍生效，只是子元素被拍平。 */
.cut-tr { clip-path: polygon(0 0, calc(100% - var(--cut)) 0, 100% var(--cut), 100% 100%, 0 100%); }
.cut-bl { clip-path: polygon(0 0, 100% 0, 100% 100%, var(--cut) 100%, 0 calc(100% - var(--cut))); }
.cut-br { clip-path: polygon(0 0, 100% 0, 100% calc(100% - var(--cut)), calc(100% - var(--cut)) 100%, 0 100%); }

/* 纸张纤维：一层固定的极淡噪点，压住纯白背景的空洞感 */
.grain {
  position: fixed; inset: 0; z-index: 70; pointer-events: none; opacity: .14;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='p'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3'/%3E%3CfeColorMatrix values='0 0 0 0 0.055 0 0 0 0 0.055 0 0 0 0 0.055 0 0 0 0.02 0'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23p)'/%3E%3C/svg%3E");
}

/* 载入擦除：满屏信号黄，用 clip-path 定向抽走。
   静止态就是「已被裁掉」，动画负责先铺满再抽走 —— 顺序反过来（静止态=盖满）的话，
   动画一旦没跑起来整站会被一块黄板盖死。这是这套动效最危险的失败模式。 */
#wipe {
  position: fixed; inset: 0; z-index: 100;
  background: var(--signal-fill);
  pointer-events: none;
  display: flex; align-items: center; justify-content: center;
  clip-path: inset(0 0 0 100%);
  animation: wipe-curtain 1.15s cubic-bezier(.16, 1, .3, 1) forwards;
}
@keyframes wipe-curtain {
  0%   { clip-path: inset(0 0 0 0); }      /* 铺满 */
  45%  { clip-path: inset(0 0 0 0); }      /* 停一拍，让「地形加载中」被看见 */
  100% { clip-path: inset(0 0 0 100%); }   /* 向右抽走 */
}
#wipe .wipe-code { font-family: var(--font-mono); font-size: var(--fs-label); letter-spacing: .24em; color: var(--ink-on-signal); }

/* 翻页擦除板：站内跳转的过渡（chrome :: scripts 点击拦截 + animGate 标记驱动）。
   静止态裁没、躲左屏外 —— 与 #wipe 同一条安全红线：
   动画没跑起来（JS 禁用 / 类没挂上）页面也不该被盖住。
   运动方向与首页 #wipe 一致：板从左侧扫入盖满、停一拍（露出「正在加载哪页」）、
   落点页上向右收走。两段共用同一条从左到右的动线，接成一次完整的翻页。 */
#pagewipe {
  position: fixed; inset: 0; z-index: 101;
  background: var(--signal-fill);
  display: flex; align-items: center; justify-content: center;
  pointer-events: none;
  clip-path: inset(0 100% 0 0);
}
/* 板上的页面名。离开半程开始时字还没必要出现（板刚起步），
   用延迟淡入让它跟着盖满一起到位；到达半程它随板一起被右收裁掉 */
#pagewipe .wipe-code {
  font-family: var(--font-mono); font-size: var(--fs-label);
  letter-spacing: .24em; color: var(--ink-on-signal); opacity: 0;
}
/* 到达半程在前：离开与到达两个类同时存在时（本页既是落点又要离开），
   靠后的离开规则赢 —— 正要跳走的页面不该再播到达动画 */
.anim.nav-in #pagewipe { animation: pagewipe-out .55s cubic-bezier(.16, 1, .3, 1) both; }
.anim.nav-in #pagewipe .wipe-code { opacity: 1; }
@keyframes pagewipe-out {
  0%   { clip-path: inset(0 0 0 0); }        /* 盖满（上一页盖过来的那块板） */
  30%  { clip-path: inset(0 0 0 0); }        /* 停一拍，让页面名被读到 */
  100% { clip-path: inset(0 0 0 100%); }     /* 向右收走，与首页 #wipe 同向 */
}
.anim.nav-out #pagewipe { animation: pagewipe-in .34s cubic-bezier(.16, 1, .3, 1) both; }
.anim.nav-out #pagewipe .wipe-code { animation: pagewipe-code-in .34s ease-out both; }
@keyframes pagewipe-in {
  from { clip-path: inset(0 100% 0 0); }     /* 躲左屏外 */
  to   { clip-path: inset(0 0 0 0); }        /* 扫入盖满 */
}
@keyframes pagewipe-code-in {
  0%, 55% { opacity: 0; }
  100%    { opacity: 1; }
}

/* ==========================================================================
   3. 顶栏
   桌面端导航必须单行。字号变大后断点从 900px 上移到 1080px，低于此收进汉堡抽屉。
   ========================================================================== */
.topbar {
  position: fixed; top: 0; left: 0; right: 0; z-index: 50; height: 60px;
  background: rgba(255, 255, 255, .82);
  backdrop-filter: blur(14px) saturate(140%);
  -webkit-backdrop-filter: blur(14px) saturate(140%);
  border-bottom: 1px solid var(--line);
}
.topbar-inner { height: 100%; display: flex; align-items: center; justify-content: space-between; gap: 24px; }

.brand { display: flex; align-items: baseline; gap: 10px; flex-shrink: 0; }
.brand-mark {
  font-family: var(--font-disp); font-weight: 900;
  font-variation-settings: 'wdth' 110, 'wght' 900; font-size: 21px; letter-spacing: -.01em;
}
.brand-code { color: var(--ink-2); }

.nav { display: none; align-items: center; gap: 2px; }
@media (min-width: 1080px) { .nav { display: flex; } }
.nav a {
  position: relative; padding: 8px 14px;
  font-size: 17px; color: var(--ink-2); transition: color .25s; white-space: nowrap;
}
/* 导航下划线用 scaleX 从左展开，不用 opacity 淡入 */
.nav a::after {
  content: ""; position: absolute; left: 14px; right: 14px; bottom: 4px;
  height: 1px; background: var(--signal-edge);
  transform: scaleX(0); transform-origin: right;
  transition: transform .4s cubic-bezier(.16, 1, .3, 1);
}
.nav a:hover { color: var(--ink); }
.nav a:hover::after, .nav a[aria-current="page"]::after { transform: scaleX(1); transform-origin: left; }
.nav a[aria-current="page"] { color: var(--ink); }

.topbar-right { display: flex; align-items: center; gap: 16px; flex-shrink: 0; }
.status { display: none; align-items: center; gap: 7px; }
@media (min-width: 640px) { .status { display: flex; } }
.status .dot { width: 6px; height: 6px; background: var(--signal-edge); animation: breathe 3.2s ease-in-out infinite; }
@keyframes breathe { 0%, 100% { opacity: 1; } 50% { opacity: .2; } }

/* 方形按钮。字号 17px，可点击元素不降级到标签层 */
.btn-sq {
  font-family: var(--font-mono); font-size: 17px; letter-spacing: .06em; text-transform: uppercase;
  padding: 11px 18px; border: 1px solid var(--line-strong);
  background: transparent; color: var(--ink); cursor: pointer;
  transition: background .3s, color .3s, border-color .3s;
}
.btn-sq:hover { background: var(--signal-fill); color: var(--ink-on-signal); border-color: var(--signal-edge); }

.menu-btn { display: inline-flex; }
@media (min-width: 1080px) { .menu-btn { display: none; } }

/* 移动端抽屉 */
#drawer {
  position: fixed; top: 60px; left: 0; right: 0; z-index: 49;
  background: var(--panel); border-bottom: 1px solid var(--line);
  clip-path: inset(0 0 100% 0);
  transition: clip-path .5s cubic-bezier(.16, 1, .3, 1);
}
#drawer[data-open] { clip-path: inset(0 0 0 0); }
#drawer nav { display: flex; flex-direction: column; padding: 8px 0; }
#drawer a { padding: 16px 24px; font-size: 18px; color: var(--ink-2); border-bottom: 1px solid var(--line); }
#drawer a:last-child { border-bottom: 0; }
#drawer a:hover { color: var(--signal); background: rgba(var(--signal-rgb), .16); }

/* ==========================================================================
   4. 侧边轨
   ========================================================================== */
.rail {
  position: fixed; left: 0; top: 60px; bottom: 0; width: 56px; z-index: 40;
  border-right: 1px solid var(--line);
  display: none; flex-direction: column; align-items: center; justify-content: space-between;
  padding: 32px 0; background: rgba(250, 250, 249, .6);
}
@media (min-width: 1280px) { .rail { display: flex; } }
.rail-idx { display: flex; flex-direction: column; gap: 18px; align-items: center; }
.rail-idx span { font-family: var(--font-mono); font-size: var(--fs-label); color: var(--ink-2); transition: color .3s; }
.rail-idx span[data-on] { color: var(--signal); font-weight: 700; }
.rail-vtext {
  writing-mode: vertical-rl; font-family: var(--font-mono);
  font-size: var(--fs-label); letter-spacing: .2em; color: var(--ink-2); text-transform: uppercase;
}
@media (min-width: 1280px) { .layer { padding-left: 56px; } }

/* ==========================================================================
   5. HERO
   ========================================================================== */
.hero {
  position: relative;
  min-height: 100dvh;
  display: flex; flex-direction: column; justify-content: center;
  padding-top: 60px;
  overflow: hidden;
}
/* 3D 等高线地形画布。Phase 4 接 Three.js，此前是空画布，不影响版面 */
.hero canvas { position: absolute; inset: 0; width: 100%; height: 100%; z-index: 0; pointer-events: none; display: block; }
.hero canvas#flat { display: none; }   /* WebGL 不可用时切到这张 2D 等高线 */

/* 左侧纸色渐变，保证文字压在地形之上依然清晰 */
.hero-scrim {
  position: absolute; inset: 0; z-index: 1; pointer-events: none;
  background: linear-gradient(96deg,
    rgba(255,255,255,.97) 0%,
    rgba(255,255,255,.88) 30%,
    rgba(255,255,255,.34) 54%,
    rgba(255,255,255,0) 72%);
}
/* Hero 底部渐隐，让地形沉入纸面 */
.hero-fade {
  position: absolute; left: 0; right: 0; bottom: 0; height: 180px; z-index: 1;
  background: linear-gradient(to bottom, rgba(255,255,255,0), var(--bg));
  pointer-events: none;
}
.hero > .wrap { position: relative; z-index: 2; }

.hero-grid {
  display: grid; grid-template-columns: 1fr; gap: 48px;
  align-items: center; padding: 48px 0 56px;
  perspective: 1600px;   /* perspective 必须写在父级，子元素的 translateZ 才看得出纵深 */
}
@media (min-width: 1024px) {
  .hero-grid { grid-template-columns: 1.1fr .9fr; gap: 56px; padding: 24px 0 40px; }
}

/* 雷霆大字。收一点字面、减一点重量：屏幕上最大的那块墨，收 20% 面积就轻很多 */
.thunder {
  font-family: var(--font-disp);
  font-variation-settings: 'wdth' 105, 'wght' 800;
  font-weight: 800;
  font-size: clamp(2.8rem, 9.4vw, 6.6rem);
  line-height: .96; letter-spacing: -.035em; margin: 0;
  perspective: 1000px;
  transform-style: preserve-3d;
}
.thunder-cjk { font-family: var(--font-cjk); font-variation-settings: normal; font-weight: 800; }
.thunder .ch {
  display: inline-block;
  transform-style: preserve-3d;
  will-change: transform, opacity;
  /* 白底上只留下缘一道浅影，像墨刚压进纸里 */
  text-shadow: 0 1px 0 rgba(17, 17, 17, .05);
}

/* 这句话就是全站字号下限，没有比它更小的文字 */
.hero-sub { margin: 28px 0 0; max-width: 34ch; color: var(--ink-2); font-size: 17px; line-height: 1.85; }
.hero-actions { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 38px; }

/* 主按钮：亮黄底 + signal-edge 描边 + 墨色字。亮黄块对白底是 1:1，
   块的边界会消失、只剩里面的黑字浮着，所以描边不是装饰是必需的 */
.btn-primary {
  display: inline-flex; align-items: center; gap: 12px;
  background: var(--signal-fill); color: var(--ink-on-signal);
  padding: 14px 26px;
  font-family: var(--font-mono); font-size: 17px; letter-spacing: .06em; text-transform: uppercase;
  font-weight: 500; border: 1px solid var(--signal-edge);
  cursor: pointer; will-change: transform;
  transition: background .3s, border-color .3s;
}
.btn-primary:hover { background: var(--signal-fill-h); }
.btn-primary:active { transform: translateY(1px); }

.btn-outline {
  display: inline-flex; align-items: center; gap: 12px;
  padding: 14px 26px; border: 1px solid var(--line-strong); color: var(--ink);
  font-family: var(--font-mono); font-size: 17px; letter-spacing: .06em; text-transform: uppercase;
  will-change: transform;
  transition: border-color .3s, color .3s;
}
.btn-outline:hover { border-color: var(--signal); color: var(--signal); }
.btn-outline:active { transform: translateY(1px); }

/* ---------- 3D 分层图像场 ---------- */
.field-wrap { perspective: 1500px; }
.field {
  /* 整块不收指针事件：三块 .tile 都是负 translateZ，在 preserve-3d 里位于 .field
     平面「后方」，命中测试按 3D 深度排序会让透明的 .field 挡住所有 tile ——
     悬停恢复彩色因此永远不触发（2026-10-01 实测 elementFromPoint 命中的是 .field）。
     事件放行给下层的 .tile（它那边显式 pointer-events: auto） */
  pointer-events: none;
  display: grid; grid-template-columns: 1fr 1fr; grid-template-rows: repeat(6, 46px); gap: 12px;
  transform-style: preserve-3d;
  will-change: transform;
}
@media (min-width: 1024px) { .field { grid-template-rows: repeat(6, 58px); gap: 14px; } }

/* pointer-events: auto 是显式声明，不是默认值复读：父级 .field 被设成了 none（原因见上），
   这里必须显式接回事件，:hover 恢复彩色与角标变色才会生效 */
.tile { position: relative; will-change: transform; pointer-events: auto; }
/* 每块停在不同纵深：指针一动就产生真实视差分层，不是整体平移 */
.t1 { grid-column: 1 / 2; grid-row: 1 / 4; transform: translateZ(-30px); }
.t2 { grid-column: 2 / 3; grid-row: 2 / 6; transform: translateZ(-160px); }
.t3 { grid-column: 1 / 2; grid-row: 4 / 7; transform: translateZ(-95px); }

/* 照片变成高调平灰的印刷品，悬停才恢复彩色 */
.tile img {
  width: 100%; height: 100%; object-fit: cover;
  filter: grayscale(1) contrast(.94) brightness(1.24);
  transition: filter .7s cubic-bezier(.16, 1, .3, 1);
}
.tile:hover img { filter: grayscale(0) contrast(1.02) brightness(1.02); }
/* 角标是「标签页」，不做成压在图上的贴片 */
.tile .tag {
  position: absolute; left: 0; bottom: 0;
  font-family: var(--font-mono); font-size: var(--fs-label); letter-spacing: .1em;
  background: var(--bg); color: var(--ink-2); padding: 5px 11px;
  border-top: 1px solid var(--line); border-right: 1px solid var(--line);
  transition: background .4s, color .4s, border-color .4s;
}
.tile:hover .tag { background: var(--signal-fill); color: var(--ink-on-signal); border-color: var(--signal-edge); }
.tile::after {
  content: ""; position: absolute; inset: 0; pointer-events: none;
  background: linear-gradient(140deg, rgba(255,255,255,.42), rgba(17,17,17,.015));
}

.hero-stats { display: grid; grid-template-columns: repeat(3, 1fr); border-top: 1px solid var(--line); margin-top: 8px; }
.hero-stats > div { padding: 18px 0; border-right: 1px solid var(--line); }
.hero-stats > div:last-child { border-right: 0; }
.hero-stats .k {
  font-family: var(--font-disp); font-variation-settings: 'wdth' 100, 'wght' 800;
  font-size: 34px; line-height: 1;
}
/* 「篇文章 / 条评论 / 次访问」是纯中文，整个不给 mono 处理 */
.hero-stats .u { color: var(--ink-2); margin-top: 6px; }

/* ==========================================================================
   6. 信息条（全站唯一走马灯）
   ========================================================================== */
.ticker {
  border-top: 1px solid var(--line); border-bottom: 1px solid var(--line);
  background: var(--bg-sunken); overflow: hidden;
  -webkit-mask-image: linear-gradient(90deg, transparent, #000 6%, #000 94%, transparent);
  mask-image: linear-gradient(90deg, transparent, #000 6%, #000 94%, transparent);
}
/* 走马灯是唯一允许 linear 的地方 */
.ticker-track { display: flex; width: max-content; animation: slide 42s linear infinite; }
.ticker:hover .ticker-track { animation-play-state: paused; }
@keyframes slide { to { transform: translateX(-50%); } }
.ticker-item {
  display: flex; align-items: center; gap: 14px;
  padding: 13px 28px; border-right: 1px solid var(--line); white-space: nowrap;
}
.ticker-item .flag { width: 3px; height: 13px; background: var(--signal-edge); flex-shrink: 0; }
.ticker-item .t { font-size: 17px; color: var(--ink-2); transition: color .25s; }
.ticker-item:hover .t { color: var(--ink); }

/* ==========================================================================
   7. 章节
   ========================================================================== */
.section { padding: 104px 0; position: relative; }
@media (min-width: 1024px) { .section { padding: 136px 0; } }
.section.flush { padding-top: 0; }

.sec-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; margin-bottom: 44px; flex-wrap: wrap; }
.sec-title {
  font-family: var(--font-cjk); font-weight: 900;
  font-size: clamp(1.9rem, 4vw, 2.9rem); line-height: 1.15; letter-spacing: -.02em; margin: 0;
  perspective: 700px;
}
.sec-link {
  font-family: var(--font-mono); font-size: var(--fs-label); letter-spacing: .14em; text-transform: uppercase;
  color: var(--ink-2); border-bottom: 1px solid var(--line-strong); padding-bottom: 4px;
  transition: color .3s, border-color .3s;
}
.sec-link:hover { color: var(--signal); border-color: var(--signal); }

/* 幽灵数字是版面装置，不是眉标签，不要退化成 001 · 这种小号编号 */
.ghost-num {
  position: absolute; top: 40px; right: 24px;
  font-family: var(--font-disp); font-variation-settings: 'wdth' 125, 'wght' 900;
  font-size: clamp(9rem, 22vw, 20rem); line-height: .72;
  color: rgba(17, 17, 17, .045);
  pointer-events: none; user-select: none;
}
@media (min-width: 1024px) { .ghost-num { right: 48px; } }

/* ==========================================================================
   8. 文章索引
   不用卡片。行式布局 + 1px 细线，悬停时信号黄从左填满整行。
   ========================================================================== */
.index { border-top: 1px solid var(--line); }
.idx-row {
  position: relative; isolation: isolate;   /* 让 z-index:-1 的填充层不跑到页面背景后面 */
  border-bottom: 1px solid var(--line);
  /* 用 minmax(0, 1fr) 而不是 1fr：1fr 的自动最小值是 min-content，
     摘要里一个长英文串就能把整条轨道撑得比视口还宽（移动端实测撑到 592px / 视口 390px）。
     显式给 0 下限，轨道才会跟着容器走，再由 -webkit-line-clamp 负责截断。 */
  display: grid; grid-template-columns: minmax(0, 1fr); gap: 10px;
  padding: 26px 20px 26px 0; cursor: pointer;
}
/* 有封面的行（模板按 firstPicture 是否为空加 .has-thumb）在窄屏排成两栏：
   封面占左，序号与正文走右。
   这里只给封面写显式落位，序号和正文交给网格的自动排布绕开它 ——
   多写两条 .idx-num / 正文的规则，桌面端还得再撤销一遍。
   没有封面的行不加这个类：88px 的轨道会一直占着，而最新的 12 篇里有 10 篇没封面。 */
.idx-row.has-thumb { grid-template-columns: 96px minmax(0, 1fr); gap: 6px 16px; }
.idx-row.has-thumb .idx-thumb { grid-column: 1; grid-row: 1 / span 2; }
/* 封面。宽屏尺寸与归档页 / 标签页的 .post-thumb 一致（216×122），
   区别只在窄屏：那边整张 display:none，这边给一个小缩略图。
   **不加灰度滤镜**：站内只有 Hero 的图像场走「印刷灰 → 悬停恢复彩色」，
   列表封面一律原色。灰度是给装饰性大图当版面装置用的，
   列表封面是内容的一部分，读者要靠它认出文章讲什么，不该被压成灰。
   两档宽高比都是 1.77，HTML 上的 width/height 给的就是这个比值，
   图片到达前用这个比例占位，不会跳版。
   这条基础规则必须排在下面 @media 里那条之前：两条特异性同为 (0,1,0)，
   媒体查询不加特异性，胜负纯看源序 —— 放到后面会把宽屏尺寸压回窄屏的（实测踩过）。 */
.idx-thumb { width: 96px; height: 54px; object-fit: cover; }

/* 三栏要 104 + 148 + 两块 32 的间距才够，768-1023px 之间放不下，
   硬套会把标题挤成一条竖线，所以这个断点放在 1024px，之下退回单栏堆叠。 */
@media (min-width: 1024px) {
  .idx-row { grid-template-columns: 104px minmax(0, 1fr); gap: 32px; align-items: baseline; padding: 30px 24px 30px 0; }
  .idx-row.has-thumb { grid-template-columns: 104px minmax(0, 1fr) 216px; }
  /* 桌面端封面回到最右列，窄屏那套显式落位必须撤销，否则会一直钉在第一列 */
  .idx-row.has-thumb .idx-thumb { grid-column: auto; grid-row: auto; align-self: center; }
  .idx-thumb { width: 216px; height: 122px; }
}
/* 列表行共用的悬停填充：一整块信号黄从左侧展开盖住整行。
   首页文章索引、归档、标签页都用这一套动作。分成三份写的话，
   改缓动或改时长就要改三处，而且很容易只改到一处。
   行内文字的翻色留在各自的行类型里 —— 每种子元素构成不一样，这里管不着。 */
.row-fill { position: relative; isolation: isolate; }
.row-fill::before {
  content: ""; position: absolute; inset: 0; z-index: -1;
  background: var(--signal-fill);
  clip-path: inset(0 100% 0 0);
  transition: clip-path .5s cubic-bezier(.16, 1, .3, 1);
}
.row-fill:hover::before { clip-path: inset(0 0 0 0); }
.idx-row:hover .idx-title,
.idx-row:hover .idx-desc,
.idx-row:hover .idx-num,
.idx-row:hover .row-meta,
.idx-row:hover .row-tags { color: var(--ink-on-signal); }
.idx-row:hover .row-meta .cat { border-color: var(--ink-on-signal); }

.idx-num {
  font-family: var(--font-disp); font-variation-settings: 'wdth' 100, 'wght' 800;
  font-size: 30px; line-height: 1; color: rgba(17, 17, 17, .28); transition: color .35s;
}
.idx-title {
  display: block; font-family: var(--font-cjk); font-weight: 700;
  font-size: 22px; line-height: 1.45; color: var(--ink); margin: 0 0 8px; transition: color .35s;
}
@media (min-width: 1024px) { .idx-title { font-size: 24px; } }
/* 摘要截两行。t_blog.description 里有相当一部分是 CSDN 同步过来的原始 markdown
   （`# 标题 ## 小节`、`[在这里插入图片描述](url)` 这类），不截断的话每行高度都不一样，
   整页索引会七长八短。这是数据侧的毛病，展示侧只负责别让它把版面撑歪。 */
.idx-desc {
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
  overflow: hidden;
  color: var(--ink-2); font-size: 17px; transition: color .35s;
}
/* 列表行里那两条附属信息：标签串与元数据串。
   首页索引与标签页都用，所以名字不绑页面。 */
.row-tags { display: flex; flex-wrap: wrap; gap: 4px 14px; margin-top: 10px; color: var(--ink-2); transition: color .35s; }
.row-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; color: var(--ink-2); transition: color .35s; }
.row-meta .cat { border: 1px solid var(--line-strong); padding: 3px 9px; transition: border-color .35s; }
/* 首页与标签页的元数据都跟在摘要下面、左对齐。不要再给 .idx-row .row-meta 补一条
   justify-content: flex-end —— 那是封面进来之前、右列空着时的写法；现在最右列是封面，
   甩过去会压到图上。 */

/* 空列表：三张表全空时也给一句人话，而不是一片留白 */
.idx-empty { padding: 64px 0; color: var(--ink-2); border-bottom: 1px solid var(--line); }

/* 分页：不用 Bootstrap 的 pagination 组件，一列等大方块。
   当前页用墨底反白而不是信号黄：黄是「需要被看见的交互」，当前页是状态不是动作。 */
.pager { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 10px; margin-top: 48px; }
/* 也支持 <button>：文章页的评论分页走 AJAX，没有可跳的地址，只能是非链接元素。
   用 button 而不是 <a href="#">，这样键盘可用、语义也对。 */
.pager a, .pager button, .pager .pg {
  display: inline-flex; align-items: center; justify-content: center;
  min-width: 46px; height: 46px; padding: 0 15px;
  border: 1px solid var(--line-strong); color: var(--ink); background: none;
  font-family: var(--font-mono); font-size: var(--fs-label); letter-spacing: .06em;
  cursor: pointer;
  transition: background .3s, color .3s, border-color .3s;
}
.pager a:hover, .pager button:not(:disabled):hover { background: var(--signal-fill); color: var(--ink-on-signal); border-color: var(--signal-edge); }
/* 用 [aria-current] 而不是 .pg：模板里当前页就是个 <a>，没有额外的类。
   用属性选择器还能顺带把「当前页」这个语义绑在无障碍属性上，不用两处维护。 */
.pager [aria-current] { background: var(--ink); color: var(--bg); border-color: var(--ink); }
.pager .is-off, .pager button:disabled { opacity: .35; pointer-events: none; }

/* ==========================================================================
   9. 数据格（原型的「常写的话题」）
   ========================================================================== */
.cats { display: grid; grid-template-columns: 1fr; gap: 1px; background: var(--line); border: 1px solid var(--line); }
@media (min-width: 768px) { .cats { grid-template-columns: 1.6fr 1fr 1fr; } }
.cat-cell {
  background: var(--bg); padding: 34px 28px;
  display: flex; flex-direction: column; justify-content: space-between; gap: 40px;
  min-height: 190px; position: relative; overflow: hidden; transition: background .4s;
}
.cat-cell:hover { background: var(--panel); }
.cat-cell::after {
  content: ""; position: absolute; left: 0; bottom: 0; height: 2px; width: 100%;
  background: var(--signal-edge); transform: scaleX(0); transform-origin: left;
  transition: transform .55s cubic-bezier(.16, 1, .3, 1);
}
.cat-cell:hover::after { transform: scaleX(1); }
.cat-cell .big { font-family: var(--font-disp); font-variation-settings: 'wdth' 105, 'wght' 800; font-size: 48px; line-height: 1; }
.cat-cell .name { font-family: var(--font-cjk); font-weight: 700; font-size: 20px; }
.cat-cell .hint { color: var(--ink-2); font-size: 17px; margin-top: 6px; }
.cat-cell.lead { justify-content: flex-end; }
.cat-cell.lead .big { color: var(--signal); }
.cat-cell.lead .lead-title {
  font-family: var(--font-cjk); font-weight: 900;
  font-size: clamp(1.5rem, 3vw, 2.1rem); line-height: 1.3; letter-spacing: -.02em;
}

/* ==========================================================================
   10. 关于

   版面叫「长卷」：左栏（.side）吸附在左边不动，右栏（.flow）从它旁边流过去。
   全站只有这一页有「不动的东西」—— 读自述的全程，人始终在场。

   .about / .about-photo / .about-body 这三个类是**首页**在用的（index.html 的
   「关于写字的人」段），关于页改版后不再使用，但规则一条都不能删。
   ========================================================================== */
.about { display: grid; grid-template-columns: 1fr; gap: 40px; align-items: center; }
@media (min-width: 900px) { .about { grid-template-columns: 300px 1fr; gap: 60px; } }
.about-photo { position: relative; perspective: 1000px; }
.about-photo img {
  /* height: auto 不能省。模板里 <img> 带着 width="750" height="750"，那是**表现性提示**：
     优先级虽然低于作者样式，但只在作者样式**没设过该属性**时才会被盖掉。这里只写了 width，
     于是 height="750" 照样生效，aspect-ratio 被完全架空 —— 首页那张头像实测渲染成
     300×750 的竖条（从方形原图中间裁出来的一条），不是这条规则写的 300×400。
     同一个坑关于页也踩了，那边改成 height: auto 之后恢复正常。 */
  width: 100%; height: auto; aspect-ratio: 3 / 4; object-fit: cover;
  filter: grayscale(1) contrast(.96) brightness(1.2);
  will-change: transform;
}
.about-photo .plate {
  position: absolute; left: 0; bottom: 0;
  background: var(--signal-fill); color: var(--ink-on-signal);
  border: 1px solid var(--signal-edge);
  font-family: var(--font-mono); font-size: var(--fs-label); letter-spacing: .1em; padding: 6px 12px;
}
/* 只挑直接子级的 <p>。写成后代选择器会连 .md-body 里那些富文本段落一起命中，
   把 white-space: pre-line 带过去 —— 那些段落之间在源码里是有换行的，
   结果就是正文里凭空多出一堆空行。about.html 的博客自述正好是这个结构。 */
.about-body > p { color: var(--ink-2); max-width: 60ch; white-space: pre-line; }  /* 库里存的简介带换行，pre-line 让它按原样断行 */
.about-body .lead { color: var(--ink); font-size: 20px; }
/* 联系方式清单。键是纯中文，所以不给 mono —— 没有拉丁字符要等宽对齐时 mono 只有副作用 */
.about-body .contact { list-style: none; margin: 26px 0 0; padding: 0; display: grid; gap: 11px; }
.about-body .contact li { display: flex; gap: 16px; align-items: baseline; color: var(--ink-2); }
.about-body .contact .k { flex-shrink: 0; min-width: 68px; color: var(--ink); font-size: var(--fs-label); }
.about-body .links { display: flex; flex-wrap: wrap; gap: 20px; margin-top: 30px; }
.about-body .links a { color: var(--ink-2); border-bottom: 1px solid var(--line-strong); padding-bottom: 3px; transition: color .3s, border-color .3s; }
.about-body .links a:hover { color: var(--signal); border-color: var(--signal); }

/* ---- 长卷骨架 ---- */

.split { display: grid; grid-template-columns: minmax(0, 1fr); gap: 48px; }
@media (min-width: 1024px) {
  .split { grid-template-columns: 320px minmax(0, 1fr); gap: 84px; align-items: start; }
}

/* 左栏。只在宽屏吸附：窄屏视口本来就矮，再钉一个 320px 高的栏，正文只剩一条缝 */
.side { position: static; }
@media (min-width: 1024px) { .side { position: sticky; top: 96px; } }

.side-frame {
  position: relative; border: 1px solid var(--line-strong); background: var(--bg-sunken); overflow: hidden;
}
/* me.jpg 是白底线稿。multiply 让白底透出去，--bg-sunken 才能真正吃到画面上；
   不加这一条，插图就是一块纯白方块浮在米色底上，像贴歪了。
   height: auto 的理由见上面 .about-photo img（同一个坑）。 */
.side-frame img {
  width: 100%; height: auto; aspect-ratio: 1 / 1; object-fit: contain;
  mix-blend-mode: multiply;
  filter: grayscale(1) contrast(.98) brightness(1.03);
  transition: filter 1s cubic-bezier(.16, 1, .3, 1);
}
/* 读到一半，灰度转回原色。这是这一版的落点：灰度的记录，读到后面才变回一个具体的人 */
.side.is-live .side-frame img { filter: grayscale(0) contrast(1) brightness(1); }

.side-fields { margin-top: 20px; }
.side-fields .row {
  display: flex; align-items: baseline; justify-content: space-between; gap: 16px;
  border-bottom: 1px solid var(--line); padding: 10px 0;
}
.side-fields .k { font-family: var(--font-mono); font-size: var(--fs-label); letter-spacing: .14em; color: var(--ink-2); }
/* 值转大写用 CSS 不用 Thymeleaf 的 toUpperCase：DOM 里留的还是库里的原值 */
.side-fields .v {
  font-family: var(--font-mono); font-size: var(--fs-label); letter-spacing: .06em;
  color: var(--ink); text-transform: uppercase;
}

/* 进度：一格一条。方块由页内脚本按实际的条款数生成 —— 条款数来自数据
   （技能清单为空时少一条），写死在模板里就会出现一个永远不亮的方块。
   用离散方块而不是连续进度条：连续条是归档页「时间轴」的装置，这里不重复它。 */
.side-steps { display: flex; align-items: center; gap: 8px; margin-top: 18px; }
.side-steps .sq {
  width: 10px; height: 10px; border: 1px solid var(--line-strong); background: transparent;
  transition: background .4s, border-color .4s;
}
.side-steps .sq.on { background: var(--signal-fill); border-color: var(--signal-edge); }
.side-steps .cnt {
  margin-left: auto; font-family: var(--font-mono); font-size: var(--fs-label);
  letter-spacing: .1em; color: var(--ink-2);
}

/* ---- 条款 ---- */
.cl {
  display: grid; grid-template-columns: minmax(0, 1fr); gap: 6px;
  border-top: 1px solid var(--line); padding: 30px 0 34px;
}
@media (min-width: 640px) { .cl { grid-template-columns: 84px minmax(0, 1fr); gap: 28px; } }
.cl:last-child { border-bottom: 1px solid var(--line-strong); }
/* 条款号是大号数字，走 Archivo（--font-disp）。静态时浅灰，读到它时转信号黄 ——
   一个明确的「正在读这一条」的指示 */
.cl .n {
  font-family: var(--font-disp); font-variation-settings: 'wdth' 100, 'wght' 800;
  font-size: 34px; line-height: 1; color: var(--line-strong);
  transition: color .45s cubic-bezier(.16, 1, .3, 1);
}
.cl.is-cur .n { color: var(--signal); }
/* 条款里的抬头收一号。.sec-title 是整页章节的尺寸（clamp 到 2.9rem），
   条款只是长卷里的一节，用整页的尺寸会把条款本身压没 */
.cl .sec-head { margin-bottom: 24px; }
.cl .sec-title { font-size: clamp(1.4rem, 2.4vw, 1.9rem); }
/* 正文量宽用 em 不用 ch：ch 量的是数字 0 的宽度，对中文完全不适用。
   在这套字体栈下 1ch ≈ 13.5px，.md-body 默认的 74ch 会宽到 1000px，一行 59 个汉字 */
.cl .md-body { max-width: 44em; }

/* ---- 技能清单 ---- */
.skl-list {
  list-style: none; margin: 0; padding: 0;
  border-top: 1px solid var(--line-strong);
}
.skl {
  position: relative; display: flex; align-items: baseline; gap: 18px;
  border-bottom: 1px solid var(--line); padding: 15px 0;
  transition: padding-left .4s cubic-bezier(.16, 1, .3, 1);
}
/* 悬停从左边长出一条信号黄。亮黄块照例带 --signal-edge 描边（§2.2） */
.skl::before {
  content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4px;
  background: var(--signal-fill); border-right: 1px solid var(--signal-edge);
  transform: scaleY(0); transform-origin: center;
  transition: transform .4s cubic-bezier(.16, 1, .3, 1);
}
.skl:hover { padding-left: 18px; }
.skl:hover::before { transform: scaleY(1); }
.skl .no {
  font-family: var(--font-mono); font-size: var(--fs-label); letter-spacing: .1em;
  color: var(--line-strong); flex-shrink: 0;
}
.skl .nm { font-size: 17px; }

/* 技能说明。改版前是写死在模板 style 里的，收进样式表；文案逐字未改 */
.cl-note { color: var(--ink-2); margin: 24px 0 0; max-width: 44em; }

/* ==========================================================================
   11. 页脚
   ========================================================================== */
.footer { border-top: 1px solid var(--line); background: var(--bg-sunken); }
/* 只写上下内边距。写 padding: 44px 0 会把 .wrap 的左右内边距一起抹掉 ——
   两个类是同一元素、同一优先级，.footer-inner 在后面所以整条 padding 都生效，
   页脚内容直接顶到视口右边缘（原型里也是这么写的，只是它的页脚内容短，没露出来）。*/
.footer-inner { padding-top: 44px; padding-bottom: 44px; display: flex; flex-direction: column; gap: 22px; }
@media (min-width: 768px) { .footer-inner { flex-direction: row; align-items: center; justify-content: space-between; } }
/* min-width: 0 是关键：flex 子项默认 min-width: auto，不给 0 的话它宁可撑破容器也不换行 */
.footer-meta { display: flex; flex-wrap: wrap; justify-content: flex-end; min-width: 0; gap: 10px 22px; color: var(--ink-2); }
.footer-meta a { transition: color .3s; }
.footer-meta a:hover { color: var(--signal); }

/* ==========================================================================
   12. 搜索浮层
   用原生 <dialog> 替掉 Bootstrap Modal：少了 80KB 的 bundle，还自带焦点陷阱、
   Esc 关闭和 ::backdrop。showModal() 之后点 backdrop 关闭要靠 JS 兜一下。
   ========================================================================== */
.search-dlg {
  border: 1px solid var(--line-strong); background: var(--bg); color: var(--ink);
  padding: 0; width: min(640px, calc(100vw - 32px)); margin: 12vh auto auto;
  max-height: 70vh;
}
.search-dlg::backdrop { background: rgba(17, 17, 17, .32); }
.search-dlg-head {
  display: flex; align-items: center; justify-content: space-between; gap: 16px;
  padding: 20px 24px; border-bottom: 1px solid var(--line);
}
.search-dlg-close {
  border: 1px solid var(--line-strong); background: transparent; color: var(--ink-2);
  font-family: var(--font-mono); font-size: var(--fs-label); letter-spacing: .1em;
  padding: 5px 10px; cursor: pointer; transition: background .3s, color .3s, border-color .3s;
}
.search-dlg-close:hover { background: var(--signal-fill); color: var(--ink-on-signal); border-color: var(--signal-edge); }
.search-dlg-body { padding: 24px; }
.search-input {
  width: 100%; padding: 13px 16px; font: inherit; font-size: 17px;
  background: var(--bg); color: var(--ink);
  border: 1px solid var(--line-strong); outline: none;
}
.search-input:focus { border-color: var(--signal); }
.search-input::placeholder { color: var(--ink-2); }
.search-hint { color: var(--ink-2); margin: 20px 0 10px; }
/* 搜索结果行：沿用索引行的悬停填充语言 */
.search-list { border-top: 1px solid var(--line); max-height: 42vh; overflow-y: auto; }
.search-list a {
  display: block; padding: 13px 4px; font-size: 17px;
  border-bottom: 1px solid var(--line); color: var(--ink);
  transition: background .3s, color .3s, padding-left .3s;
}
.search-list a:hover {
  background: var(--signal-fill); color: var(--ink-on-signal);
  border-color: var(--signal-edge);   /* 灰线压在黄块上看不见，悬停时要换成信号色的边 */
  padding-left: 12px;
}
.search-list .search-empty { padding: 16px 4px; color: var(--ink-2); }

/* ==========================================================================
   13. 动画初始态

   整块用 .anim 门控：只有 JS 在 <html> 上加了 .anim 才生效。
   否则一旦脚本没跑起来（被墙、出错、禁用 JS），.rise 的 opacity:0
   会让整页内容凭空消失 —— 这是这套动效最容易踩的坑。

   范围收在 .page-head / .post-head / .section / .footer 之内，Hero 不在其中。原因：
   Hero 那几处的初值（.hero-sub / .hero-actions / .hero-stats / .eyebrow / .field-wrap）
   由首页脚本用 GSAP 自己设，GSAP 的 from / fromTo 在建实例时就写好初值，不依赖这两个类。
   让 CSS 去藏它们的话，首页脚本只要在哪一步出错，首屏就是一片空白 ——
   而首屏空白和整页空白在观感上没有区别，还查不出是动画的锅。

   选择器与 fragments/chrome 的 motion 片段里那三处 toArray 一一对应，改一边要同时改另一边。
   ========================================================================== */
.anim .section .wipe-in,  .anim .footer .wipe-in,
.anim .page-head .wipe-in,  .anim .post-head .wipe-in  { clip-path: inset(0 100% 0 0); }
.anim .section .rise,     .anim .footer .rise,
.anim .page-head .rise,     .anim .post-head .rise     { opacity: 0; transform: translateY(22px); }
.anim .section .depth-in, .anim .footer .depth-in { opacity: 0; transform: translateZ(-260px); }

@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  .ticker-track, .status .dot, #wipe { animation: none; }
  /* 留言「对话生长」、友链星图据点入场、提示库条款行入场都是纯 CSS keyframes，
     不在上面那行通用停动画的范围里，单独停 */
  .anim .cm-list .cm { animation: none; }
  .anim .fr-board .fr-node { animation: none; }
  .anim .pp-rows > .pp-row { animation: none; }
  /* 静止态本来就是躲开的，停掉动画即等于不出现，不需要额外的 display: none */
  .anim .section .wipe-in,  .anim .footer .wipe-in,
  .anim .page-head .wipe-in,  .anim .post-head .wipe-in  { clip-path: none; }
  .anim .section .rise,     .anim .section .depth-in,
  .anim .footer .rise,      .anim .footer .depth-in,
  .anim .page-head .rise,   .anim .post-head .rise     { opacity: 1; transform: none; }
  *, *::before, *::after { transition-duration: .01ms !important; }
}

/* ==========================================================================
   14. 数据库内容的钩子
   ========================================================================== */

/* t_sys_config.about_me_content 是存在库里的富文本，里面直接写了 class="cz-text-green"。
   这个类必须继续有定义，否则那几处高亮会退化成普通正文。
   新规范没有绿色（信号色只有一个黄），所以映射到 --signal 深金：语义仍是「被标出的那一处」，
   对白底 6.58:1 也读得清。类名保留不改，改名要连着动库里的数据。 */
.cz-text-green { color: var(--signal); }

/* 文章正文（blog.html 的 #content，以及 about.html 的富文本）。
   blog.html 是 Phase 3 最后一页，这一节到时候会再按其实际结构收紧。 */
/* overflow-wrap 这条是必须的，别删：正文里一个长 URL（技术博客里到处都是）就能
   把 .md-body 的 scrollWidth 顶到 478px，而这一路上没有任何一级 overflow 会裁它，
   于是整页在 390px 视口下横向溢出到 502px。改版前这条规则由 github-markdown.min.css
   提供（.markdown-body { word-wrap: break-word }），换掉那个文件时就丢了。
   注意是 overflow-wrap 不是 word-break：后者会连正常的中文标点换行一起改掉。 */
.md-body { max-width: 74ch; overflow-wrap: break-word; }
.md-body > *:first-child { margin-top: 0; }
.md-body h1, .md-body h2, .md-body h3, .md-body h4 {
  font-family: var(--font-cjk); font-weight: 900; letter-spacing: -.02em;
  line-height: 1.3; margin: 2.2em 0 .7em; scroll-margin-top: 84px;
}
.md-body h1 { font-size: 30px; }
.md-body h2 { font-size: 26px; border-bottom: 1px solid var(--line); padding-bottom: .3em; }
.md-body h3 { font-size: 22px; }
.md-body h4 { font-size: 19px; }
.md-body p, .md-body li { color: var(--ink-2); }
.md-body strong { color: var(--ink); }
.md-body a { color: var(--signal); border-bottom: 1px solid var(--signal-edge); }
.md-body a:hover { background: var(--signal-fill); color: var(--ink-on-signal); border-color: var(--signal-edge); }
.md-body img { margin: 1.6em 0; }
.md-body hr { border: 0; border-top: 1px solid var(--line); margin: 2.6em 0; }
.md-body blockquote {
  margin: 1.6em 0; padding: 2px 0 2px 20px;
  border-left: 3px solid var(--signal-edge); color: var(--ink-2);
}
.md-body ul, .md-body ol { padding-left: 1.4em; }
.md-body li::marker { color: var(--signal); }
/* 行内代码与代码块。代码块用 bg-sunken 底 + 细线，不用深色主题：
   这套视觉是「纸与墨」，深色代码块会把纸戳出一个洞 */
.md-body code {
  font-family: var(--font-mono); font-size: var(--fs-label);
  background: var(--bg-sunken); border: 1px solid var(--line); padding: 1px 5px;
}
.md-body pre {
  background: var(--bg-sunken); border: 1px solid var(--line);
  padding: 18px 20px; overflow-x: auto; margin: 1.6em 0;
}
.md-body pre code { background: none; border: 0; padding: 0; font-size: var(--fs-label); line-height: 1.7; }
.md-body table { width: 100%; border-collapse: collapse; margin: 1.6em 0; font-size: 17px; }
.md-body th, .md-body td { border: 1px solid var(--line); padding: 9px 13px; text-align: left; }
.md-body th { background: var(--bg-sunken); font-weight: 700; }

/* Prism：只保留 token 着色，容器样式交给上面的 pre。
   色阶压在纸墨体系里，不引第三方主题，避免深色主题把纸面戳穿。
   各类 token 的对比度都按正文标准核对过（≥ 4.5:1）。 */
.md-body .token.comment, .md-body .token.prolog, .md-body .token.doctype, .md-body .token.cdata { color: #6b6b6b; }
.md-body .token.punctuation { color: var(--ink-2); }
.md-body .token.keyword, .md-body .token.tag, .md-body .token.rule, .md-body .token.important { color: #8a2b1f; }
.md-body .token.string, .md-body .token.attr-value, .md-body .token.char { color: #2f6b3a; }
.md-body .token.function, .md-body .token.class-name { color: #1f4f7a; }
.md-body .token.number, .md-body .token.boolean, .md-body .token.constant { color: #6b5d00; }
.md-body .token.attr-name, .md-body .token.property, .md-body .token.selector { color: #5a3a7a; }
.md-body .token.operator, .md-body .token.entity, .md-body .token.url { color: var(--ink-2); }

/* Tocbot 目录：把默认的圆点与缩进换成细线语言 */
.js-toc { font-size: var(--fs-label); }
.js-toc .toc-list { list-style: none; padding-left: 0; margin: 0; }
.js-toc .toc-list-item { border-left: 1px solid var(--line); }
.js-toc .toc-list .toc-list { padding-left: 14px; }
.js-toc a.toc-link {
  display: block; position: relative; padding: 6px 0 6px 12px; color: var(--ink-2);
  font-size: var(--fs-label); line-height: 1.5; transition: color .3s, border-color .3s;
}
.js-toc a.toc-link:hover { color: var(--ink); }
/* 当前阅读到的那一节：左缘长出 3px 信号块，文字转墨。
   两条都写成 a.is-active-link：tocbot 把活跃类直接加在 <a class="toc-link"> 上，
   与上面的基础规则（.js-toc a.toc-link / ::after）特异性打平，靠源序赢。
   写成 .js-toc .is-active-link 的话特异性反而低一位，会被基础规则压住（实测踩过）。
   ::after 不用 ::before —— tocbot 自己的 stylesheet 用过 ::before，避开它就是避开一类冲突 */
.js-toc a.toc-link::after {
  content: ""; position: absolute; left: -1px; top: 0; bottom: 0; width: 3px;
  background: var(--signal-fill); outline: 1px solid var(--signal-edge);
  transform: scaleY(0); transform-origin: top; transition: transform .25s;
}
.js-toc a.is-active-link { color: var(--ink); font-weight: 700; }
.js-toc a.is-active-link::after { transform: scaleY(1); }

/* ==========================================================================
   15. 错误页
   error/404、error/500、error/error 三页共用。

   这一节的存在意义之一是自证：错误页拿不到任何业务 model
   （只有基础错误的 timestamp/status/error/path，或 @ControllerAdvice 给的 url/message），
   所以这里不能引用 --signal 之外任何与页面数据有关的东西，也不假设导航有高亮项。
   ========================================================================== */

/* 页脚压到底：错误页正文只有三两行，不撑高的话页脚会浮在屏幕中间 */
.err-page { min-height: 100vh; min-height: 100dvh; display: flex; flex-direction: column; }
.err-page main { flex: 1 0 auto; display: flex; align-items: center; padding-top: 60px; }
.err-page .footer { flex-shrink: 0; }

.err { width: 100%; padding: 72px 0 80px; }

/* 巨型状态码，只描边不填充：实心 10rem 会把整页压成一个色块，
   而这一页真正该被读到的是下面那句人话。
   先给一个浅灰实心兜底，再用 @supports 换成描边 —— 反过来写的话，
   不认 -webkit-text-stroke 的浏览器会拿到 color: transparent，数字直接消失。 */
.err-code {
  font-family: var(--font-disp); font-variation-settings: 'wdth' 105, 'wght' 900;
  font-size: clamp(88px, 20vw, 220px); line-height: .82; letter-spacing: -.04em;
  margin: 0 0 8px;
  color: var(--line-strong);
}
@supports (-webkit-text-stroke: 1px #000) {
  .err-code { color: transparent; -webkit-text-stroke: 1.5px var(--signal-edge); }
}
/* error.html 拿不到状态码，那个位置放的是「错误」两个字。
   中文不能描边：笔画内部空间本来就只有一两像素，1.5px 的描边会把「错」「误」这种字糊成两团墨
   （实测就是这个效果）。改实心金，做成盖章的感觉；字号也降一档，因为它只有两个字，不出体积。
   选择器权重 (0,2,0) 高于 @supports 里的 .err-code (0,1,0)，所以能盖掉那套透明填充。 */
.err-code.is-word {
  color: var(--signal); -webkit-text-stroke-width: 0;
  font-size: clamp(44px, 8vw, 76px); letter-spacing: .04em;
}

.err-title { font-family: var(--font-cjk); font-weight: 900; font-size: 30px; line-height: 1.3; letter-spacing: -.02em; margin: 0; }
@media (min-width: 768px) { .err-title { font-size: 36px; } }
.err-sub { color: var(--ink-2); margin: 14px 0 0; max-width: 46ch; }
.err-actions { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 34px; }

/* 诊断信息。有就给、没有就整块不渲染：404/500 走 BasicErrorController 时只有 path，
   @ControllerAdvice 兜住的异常只有 url + message，两者拿到的字段不是一套。 */
.err-detail {
  margin-top: 44px; border-top: 1px solid var(--line); padding-top: 20px;
  display: grid; gap: 10px; max-width: 78ch; color: var(--ink-2);
}
.err-detail > div { display: flex; gap: 16px; align-items: baseline; }
.err-detail .k { flex-shrink: 0; min-width: 76px; color: var(--ink); }
/* 异常信息可能是任意长的一行（含堆栈里的路径），必须允许在任意字符处断行 */
.err-detail .v { min-width: 0; word-break: break-word; }

/* ==========================================================================
   16. 内页公共版式
   about / archives / tags / friends / message 五页共用。
   它们没有首页那个 100dvh 的 hero，但都需要一个「这是哪一页」的开场。
   ========================================================================== */

/* 页头。顶栏是 position: fixed 的 60px，内页没有 hero 撑着，
   所以这里的上内边距必须自己把顶栏的高度让出来，不能只靠 margin。 */
.page-head { padding: 132px 0 52px; border-bottom: 1px solid var(--line); }
@media (min-width: 1024px) { .page-head { padding: 156px 0 60px; } }

/* 与首页 .thunder 同一套配方，这样内页标题和首页大字是一张脸 */
.page-title {
  font-family: var(--font-disp); font-variation-settings: 'wdth' 105, 'wght' 800;
  font-weight: 800;
  font-size: clamp(34px, 6.4vw, 62px); line-height: 1.06; letter-spacing: -.035em;
  margin: 0; overflow-wrap: break-word;
}
/* 眉标签压在标题上方时把下边距收掉，避免和标题之间出现一道空档 */
.page-head .eyebrow { margin-bottom: 20px; }

/* 页头下面的导语。库里的自我介绍带换行，pre-line 让它按原样断行
   （改版前是写在 style 里的，现在收进样式表）。 */
.page-lead { color: var(--ink-2); margin: 22px 0 0; max-width: 58ch; white-space: pre-line; }

/* about 页 page-title 的逐字 3D 入场（首页 .thunder 的小号版）。
   perspective 只在 .chars-on 上开 —— 那是脚本拆字之后才加的类，
   不拆字的页面（reduce / GSAP 没到位）不需要这层 3D 上下文。
   配方与 .thunder .ch 同源，行内块 + preserve-3d 是逐字旋转能成立的前提。 */
.page-title.chars-on { perspective: 1000px; transform-style: preserve-3d; }
.page-title .ch { display: inline-block; transform-style: preserve-3d; will-change: transform, opacity; }

/* 技能清单的样式在 §10 的 .skl-list / .skl。
   改版前这里是一组 .skills 标签片，每片下面还画一根等宽进度条 ——
   所有条一样宽，因为宽度不来自任何数据，等于没有信息量。改版后换成
   带编号的清单行，已在 §10 定义，这一段随之删除。 */

/* ==========================================================================
   17. 归档（流年记）
   ========================================================================== */

/* 「时间地层」：页头背后那层缓慢漂移的等高线地形（/js/terrain.js 驱动，
   与首页 hero 同一片地形的另一个标本）。只裁在页头范围内；浓度由页内脚本
   压到 0.09（首页是 0.22）——它是背景装置，不能抢页头标题的阅读。
   画布默认 display:none，由脚本初始化成功后显示，免得 JS 没跑时留一个空画布占位。 */
.page-head.has-terrain { position: relative; overflow: hidden; }
.page-head .head-terrain {
  position: absolute; inset: 0; width: 100%; height: 100%;
  pointer-events: none; display: none;
}
/* 内容压到画布之上 */
.page-head.has-terrain .wrap { position: relative; z-index: 1; }

/* 滚动时间轴。左缘一条 1px 墨轨（::before），上面压一段 3px 信号黄进度
   （.arc-progress，由页内脚本按滚动进度 scaleY 生长）。
   .arc 让出 28px 左内边距放这条轨，行内容相应右移。
   亮黄块照例带 --signal-edge 描边：纯 #ffe600 压在白底上边界会消失（§2.2）。 */
.arc { position: relative; border-top: 1px solid var(--line); padding-left: 28px; }
.arc::before {
  content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 1px;
  background: var(--line);
}
.arc-progress {
  position: absolute; left: -1px; top: 0; bottom: 0; width: 3px;
  background: var(--signal-fill); outline: 1px solid var(--signal-edge);
  transform: scaleY(0); transform-origin: top;
  pointer-events: none;
}

/* 年份分组头。吸在顶栏下方：46 条滚到一半时，不吸顶就不知道自己在哪一年。
   顶栏是 fixed 的 60px 且 z-index 50，这里给 2，滚上去会从顶栏底下穿过。
   必须给不透明底色，否则行会从字缝里透出来。 */
.arc-year {
  position: sticky; top: 60px; z-index: 2;
  margin: 0; padding: 18px 0 12px;
  background: var(--bg); border-bottom: 1px solid var(--line);
  font-family: var(--font-disp); font-variation-settings: 'wdth' 105, 'wght' 800;
  font-size: 32px; line-height: 1; letter-spacing: -.03em;
  display: flex; align-items: baseline; gap: 14px;
}

/* 年份刻度：每个吸顶年份在时间轴墨轨上咬一个 6px 墨块（尺寸与顶栏呼吸状态块一致）。
   页内脚本用 IntersectionObserver 判「哪个年份正压着视口中线」，给它 .is-current，
   刻度转信号黄。left 的算式：轨心在 .arc 左缘（内容区左移了 28px），
   6px 方块要咬住轨心就得再往左让半个块宽。 */
.arc-year::before {
  content: ""; position: absolute; left: -31px; top: 50%;
  width: 6px; height: 6px; margin-top: -3px;
  background: var(--ink);
  transition: background .3s;
}
.arc-year.is-current::before {
  background: var(--signal-fill);
  outline: 1px solid var(--signal-edge);
}

/* 行。移动端单栏堆叠，≥1024px 才排成 日期 / 标题描述 / 封面 三栏 ——
   断点与 .idx-row 一致，两页的节奏感才是同一个。 */
.post-row { display: grid; grid-template-columns: minmax(0, 1fr); gap: 10px; padding: 22px 20px 22px 0; border-bottom: 1px solid var(--line); }
/* baseline 而不是 center：行高随摘要行数变（一到两行），居中会让日期在行与行之间
   上下浮动，看着像没对齐。baseline 让日期始终咬住标题那一行。
   封面反过来要居中，否则会跟着标题基线掉下来。 */
/* 第一列 120px：归档页只放 MM-dd，标签页要放完整的 yyyy-MM-dd。
   15px 等宽体 + .14em 字距下，10 个字符约 111px，96px 会把日期挤断行。 */
@media (min-width: 1024px) {
  .post-row { grid-template-columns: 120px minmax(0, 1fr); gap: 32px; align-items: baseline; padding: 24px 20px 24px 0; }
  /* 与首页同一个机制：封面列只在有封面时才是第三栏。
     46 篇里有 13 篇没填 firstPicture，固定三栏会让那些行右边空出一条 216px 的槽 */
  .post-row.has-thumb { grid-template-columns: 120px minmax(0, 1fr) 216px; }
  .post-thumb { align-self: center; }
}

.post-date { color: var(--ink-2); transition: color .35s; }
.post-title { display: block; font-family: var(--font-cjk); font-weight: 700; font-size: 21px; line-height: 1.45; color: var(--ink); margin: 0 0 6px; overflow-wrap: break-word; transition: color .35s; }
.post-desc {
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
  color: var(--ink-2); font-size: 17px; transition: color .35s;
}

/* 封面。库里存的是有道笔记 / CSDN 的外链，全部在别人域名上，
   所以必须 loading="lazy"：不留神就是进页面并发几十个跨域请求。
   尺寸与首页的 .idx-thumb 对齐（宽屏 216×122），三页的行节奏才是同一个。
   窄屏整张不显示：归档页一次要列 40+ 行，手机上不值得为它拉几十个跨域请求。 */
.post-thumb { display: none; }
@media (min-width: 1024px) {
  .post-thumb { display: block; width: 216px; height: 122px; object-fit: cover; }
}

.post-row:hover .post-date,
.post-row:hover .post-title,
.post-row:hover .post-desc,
.post-row:hover .row-meta,
.post-row:hover .row-tags { color: var(--ink-on-signal); }
.post-row:hover .row-meta .cat { border-color: var(--ink-on-signal); }

/* ==========================================================================
   18. 标签页
   ========================================================================== */

/* 标签选择器。整块是一张真表单（method=get、action=/tags）：
   点一个标签就把它的 id 并进隐藏域再提交，与改版前完全一致。
   不做成 AJAX 局部刷新 —— 那样虽然快，但会让「选了哪些标签」不再进 URL，
   刷新和分享就都丢了状态。 */
.tag-picker { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 52px; }

.tag-chip {
  display: inline-flex; align-items: baseline; gap: 8px;
  position: relative; isolation: isolate;    /* 给 ::before 的擦入填充层兜底，同 .idx-row 的写法 */
  padding: 9px 16px; border: 1px solid var(--line-strong);
  font-size: 17px; color: var(--ink-2); cursor: pointer;
  transition: background .3s, color .3s, border-color .3s;
}
.tag-chip .hash { font-family: var(--font-mono); font-size: var(--fs-label); color: var(--signal); transition: color .3s; }
.tag-chip .count { font-family: var(--font-mono); font-size: var(--fs-label); color: var(--ink-2); transition: color .3s; }
.tag-chip:hover { border-color: var(--signal); color: var(--ink); }
/* 选中态沿用列表行悬停的那套语言：实心信号黄从左边擦进来 + 墨字，
   全站只有一个「被选中」的样子。填充放在 ::before 上是为了让擦入有方向；
   整页跳转的契约不变，页内脚本会留 280ms 让这段擦入播完再提交表单。 */
.tag-chip::before {
  content: ""; position: absolute; inset: 0; z-index: -1;
  background: var(--signal-fill);
  clip-path: inset(0 100% 0 0);
  transition: clip-path .45s cubic-bezier(.16, 1, .3, 1);
}
.tag-chip.active { border-color: var(--signal-edge); color: var(--ink-on-signal); }
.tag-chip.active::before { clip-path: inset(0 0 0 0); }
.tag-chip.active .hash,
.tag-chip.active .count { color: var(--ink-on-signal); }

/* 入场初始态：标签云按序 pop（页内脚本 GSAP stagger）。
   与第 13 节同逻辑：.anim 门控保证 JS 不可用 / reduce 时直接可见。
   播完脚本会 clearProps 掉 transform —— 之后悬停位移归指针物理循环（.magnet）管。 */
.anim .tag-picker .tag-chip { opacity: 0; }

/* ==========================================================================
   19. 友链页
   ========================================================================== */

/* 友链申请（排在星图**之后**，先看朋友再谈加入）：左边申请要求与格式，右边申请表单。窄屏堆叠 */
.fr-apply { display: grid; grid-template-columns: minmax(0, 1fr); gap: 56px; }
@media (min-width: 1024px) { .fr-apply { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 72px; } }

.fr-rules h3 {
  font-family: var(--font-cjk); font-weight: 700; font-size: 19px;
  margin: 0 0 16px; padding-bottom: 10px; border-bottom: 1px solid var(--line);
}
.fr-rules p { color: var(--ink-2); margin: 0 0 10px; }
.fr-rules + h3, .fr-rules h3 + h3 { margin-top: 40px; }

/* 申请格式：一行「字段名 + 值 + 复制按钮」 */
.fr-copy-row { display: flex; align-items: baseline; gap: 10px; margin-bottom: 12px; }
.fr-copy-row .k { flex-shrink: 0; color: var(--ink-2); }
.fr-copy-row .v {
  min-width: 0; font-family: var(--font-mono); font-size: var(--fs-label);
  color: var(--ink); word-break: break-all;
}
.fr-copy {
  flex-shrink: 0; margin-left: auto; cursor: pointer;
  border: 1px solid var(--line-strong); background: none;
  padding: 4px 10px; font-size: var(--fs-label); color: var(--ink-2);
  transition: background .3s, color .3s, border-color .3s;
}
.fr-copy:hover { background: var(--signal-fill); color: var(--ink-on-signal); border-color: var(--signal-edge); }
/* 复制成功/失败的短暂反馈。用 min-width 顶住按钮宽度，否则文案一变按钮就跳一下 */
.fr-copy { min-width: 62px; text-align: center; }
.fr-copy.is-ok { border-color: var(--signal-edge); color: var(--signal); }

/* 申请表单 */
.form-field { margin-bottom: 22px; }
.form-field label { display: flex; align-items: baseline; gap: 8px; font-weight: 700; margin-bottom: 8px; }
.form-field .req { color: var(--signal); }
.form-field .hint { color: var(--ink-2); font-weight: 400; font-size: var(--fs-label); }
.form-input, .form-textarea {
  width: 100%; padding: 12px 14px; font: inherit; font-size: 17px;
  background: var(--bg); color: var(--ink);
  border: 1px solid var(--line-strong); transition: border-color .3s;
}
.form-input:focus, .form-textarea:focus { border-color: var(--signal); outline: none; }
.form-textarea { min-height: 108px; resize: vertical; }

/* 友链网络。一套 DOM、两种版面：
   默认（也是 JS 没跑起来时的落点）= 头像墙，方格密排，站名与状态常驻；
   JS 判定视口够宽时给 .fr-board 加 .is-map，同一批节点被重新定位成「据点星图」。
   两套共用 .fr-node 这一批节点，不渲染两份 —— 否则 22 条链接会在同页重复两遍，
   而且 JS 一挂页面就什么都不剩。 */

/* ---------- 头像墙（默认 / 窄屏 / 无 JS） ---------- */
.fr-board {
  --ava: 72px;                       /* 头像边长；星图模式下由脚本按条数分档覆盖 */
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
  gap: 14px;
}
.fr-node {
  display: flex; flex-direction: column; align-items: center;
  gap: 7px; text-align: center; min-width: 0;
}
.fr-node-ava {
  width: var(--ava); height: var(--ava); object-fit: cover;
  background: var(--bg-sunken); border: 1px solid var(--line);
  /* 头像是外链，而这一页有一半友链已经挂掉，它们的图同样打不开 —— 那是真实数据，不藏。
     color:transparent 只是不让浏览器把 alt 文本铺在槽位上（读屏仍读得到 alt），
     空槽本身照旧可见，配右上角的红色状态，语义是完整的。 */
  color: transparent;
  filter: grayscale(1) contrast(.96) brightness(1.15); transition: filter .3s;
}
.fr-node:hover .fr-node-ava { filter: none; border-color: var(--signal-edge); }
.fr-node-name {
  font-size: 13px; line-height: 1.3; color: var(--ink-2);
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
  overflow: hidden; overflow-wrap: break-word;
  /* 固定两行高：站名有长有短，不留的话同一行里状态方块会高低不齐，
     看起来像排版坏了。留白比参差好。 */
  min-height: 34px;
}
.fr-node:hover .fr-node-name { color: var(--ink); }

/* 状态。方块的四值配色不新增色相 —— 绿与红直接取正文代码块里已经用过的两个 token 色
   （Prism 的字符串绿与关键字红），黄用信号色边线档。
   文字标签由 CSS 从节点的 data-net 派生：单一事实来源，模板里不再写一遍三元。
   四值 + 枚举之外的坏值**逐一显式列出**，不用 else/default 兜 —— 契约变更必须看得见。 */
.fr-node-net {
  display: inline-flex; align-items: center; gap: 5px;
  font-family: var(--font-mono); font-size: 12px; color: var(--ink-2);
}
.fr-node-net::before {
  content: ""; width: 7px; height: 7px; flex-shrink: 0;
  border: 1px solid var(--line-strong);
}
.fr-node[data-net="0"] .fr-node-net::before { background: #2f6b3a; border-color: #2f6b3a; }
.fr-node[data-net="0"] .fr-node-net::after  { content: "良好"; }
.fr-node[data-net="1"] .fr-node-net::before { background: var(--signal-edge); border-color: var(--signal-edge); }
.fr-node[data-net="1"] .fr-node-net::after  { content: "一般"; }
.fr-node[data-net="2"] .fr-node-net::before { background: #8a2b1f; border-color: #8a2b1f; }
.fr-node[data-net="2"] .fr-node-net::after  { content: "异常"; }
/* 3 与 2 同色但留一道墨描边：同属故障，可区分于「异常」 */
.fr-node[data-net="3"] .fr-node-net::before { background: #8a2b1f; border-color: var(--ink-on-signal); }
.fr-node[data-net="3"] .fr-node-net::after  { content: "超时"; }
/* 枚举之外的值（含 null）画空心方块 + 「未知」。原模板对这些值什么都不画，
   于是一个坏值在页面上完全看不出来，那是静默回退。 */
.fr-node[data-net="unknown"] .fr-node-net::before { background: none; border-color: var(--line-strong); }
.fr-node[data-net="unknown"] .fr-node-net::after  { content: "未知"; }

/* ---------- 星图专属的三层：头像墙模式下不渲染 ---------- */
.fr-terrain, .fr-links, .fr-hub { display: none; }

/* ---------- 据点星图（.is-map，脚本在够宽的视口上挂） ---------- */
.fr-board.is-map {
  display: block; position: relative;
  height: clamp(520px, 72vh, 820px);
}
/* 两块画布（WebGL 与 2D 兜底）在这一层都先显示，由脚本隐藏用不上的那块 ——
   显隐的决策只在一个地方（脚本），CSS 不替它猜。 */
.fr-board.is-map .fr-terrain {
  display: block; position: absolute; inset: 0; z-index: 0;
  width: 100%; height: 100%; pointer-events: none;
}
.fr-board.is-map .fr-links {
  display: block; position: absolute; inset: 0; z-index: 1;
  width: 100%; height: 100%; pointer-events: none; overflow: visible;
}
.fr-board.is-map .fr-links line { stroke: var(--signal-edge); stroke-width: 2; }
/* 圆心：本站。占位从「装得下任意长度昵称的实心黄方块」收成站长头像 ——
   方块越宽盖得越多，而圆心正是内圈据点的地方（旧版 "ChenZhen" 撑到 120px 宽，
   把左右两个据点整个盖住）。58px 是「还认得出来 + 不压据点」的平衡，
   内圈退开的净空由脚本按像素留出（friends.html 的 HUB_CLEAR），两边配合才成立。
   改大小时两边要同步：净空 = 头像半径 + 2px 描边 + 据点半宽。 */
/* 圆心不随指针抬起 —— 据点抬起是「被指到的点」，圆心是参照系，它动了整张图就散了。
   反馈改走环的颜色（见下）。圆心恒为舞台正中：不可达区域缩在角落的小牌子里，
   不占版面，也就不推圆心。 */
.fr-board.is-map .fr-hub {
  display: block; position: absolute; left: 50%; top: 50%; z-index: 6;
  transform: translate(-50%, -50%);
}
/* 信号黄的环是「本站」这个身份：没有它，圆心那块就只是又一个头像，
   读不出「这是整张地图的原点」。头像是圆的（§4.1 保留圆形的一类）。 */
.fr-hub-ava {
  display: block; width: 58px; height: 58px; object-fit: cover;
  background: var(--bg);
  border: 2px solid var(--signal-edge);
  /* 悬停时环转成可读档的深金。--signal-edge 对白底只有 1.63:1（装饰带），
     2px 的环停在这个浓度是「看得见」而不是「读得到」，作为常驻态没问题；
     但它同时是链接的反馈，所以 hover/focus 要跳到真正读得清的 --signal。 */
  transition: border-color .25s;
}
.fr-hub:hover .fr-hub-ava,
.fr-hub:focus-visible .fr-hub-ava { border-color: var(--signal); }
/* 据点。left/top 由脚本按向日葵盘算好写成百分比。
   translate(-50%,-50%) 让百分比落在节点中心；悬停态的 transform 必须**带上它**，
   漏了据点会一边放大一边往右下角跑（缩放是绕元素中心的，位移是另一回事）。 */
.fr-board.is-map .fr-node {
  position: absolute; width: 116px; z-index: 2;
  transform: translate(-50%, -50%);
  transition: transform .3s cubic-bezier(.16, 1, .3, 1);
}
/* 指针停到哪个据点，哪个抬起来 —— 「地图上的点被指到」的样子。
   .near 由脚本挂（不是 :hover），这样头像墙模式不受影响，也方便触屏以外的
   输入方式复用同一个状态。 */
.fr-board.is-map .fr-node.near {
  transform: translate(-50%, -50%) scale(1.45);
  z-index: 5;
}
/* 抬起时的反馈只有「抬起来 + 头像恢复彩色」两样。
   黄框和白底不再加 —— 这块地方已经有信号黄的连线、状态方块和圆心环，
   抬一个据点就再多两处黄色，指针划过去整张图跟着闪，反而盖住了「抬起来」本身。
   边框**显式写回基础线色**：不写的话会被上面那条 .fr-node:hover 的信号黄接走
   （那条是头像墙的悬停反馈，星图靠 .near 同名的这条压住它）。 */
.fr-board.is-map .fr-node.near .fr-node-ava {
  filter: none;
  border-color: var(--line);
}
/* 站名抬起只提亮，不垫底。压到邻近据点上时的可读性交给随节点一起放大的字号
   （scale 不改计算值仍是 13px，但视觉上是 13×1.45 ≈ 19px）与底部的详情条，
   那块白底来回闪比糊一点更吵。 */
.fr-board.is-map .fr-node.near .fr-node-name { color: var(--ink); }
/* ---------- 不可达区域 ----------
   网络状态「异常 / 超时」的友链从星图里摘出来，收到舞台底部一条带。
   动机：状态差说的是「这个站现在连不上」，跟「这个站是谁」是两件事。混在盘里，
   读者分不清哪个点还是活着的朋友、哪个点已经挂掉 —— 实测 22 条里 11 条是挂的。
   虚线是「这里是边界之外」的工程图语言：实线做版面分隔，虚线做范围标注。
   默认（头像墙 / 无 JS）下 .fr-zone 只是透明的分组容器，标签不显示 ——
   脚本没把节点搬进来，就不声称分过组。 */
.fr-zone { display: contents; }
.fr-zone-tag { display: none; }
.fr-board.has-zone .fr-zone-tag {
  display: flex; align-items: center; gap: 10px;
  /* 窄屏放不下时整块换行，而不是把「Unreachable / 不可达区域」拦腰断开 ——
     CJK 默认随处可断，不 nowrap 的话 360px 下会断成「不可达区 / 域」 */
  flex-wrap: wrap;
  grid-column: 1 / -1;              /* 头像墙：占满整行，把两组分开 */
  margin-top: 8px; padding-top: 16px;
  border-top: 1px dashed var(--line-strong);
}
.fr-zone-tag > * { white-space: nowrap; }
/* 标签前的「信号不通」图形。走「要读的黄」那一档（--signal，白底 6.58:1）而不是
   --signal-edge 的装饰档（1.63:1）—— 17px 的图形要靠形状被认出来，不是 1px 下划线。 */
.fr-zone-icon { width: 17px; height: 17px; flex-shrink: 0; color: var(--signal); }
.fr-zone-count {
  font-family: var(--font-mono); font-size: var(--fs-label); font-style: normal;
  color: var(--ink);
}
/* 区域里的据点淡一档：还能点、能悬停，视觉上退到「备用」那一层。
   只压头像不压站名 —— 「这是谁」的信息要保持可读。 */
.fr-zone .fr-node-ava { opacity: .62; transition: opacity .25s; }
.fr-zone .fr-node:hover .fr-node-ava,
.fr-zone .fr-node.near .fr-node-ava { opacity: 1; }

/* 星图模式下区域是**铺满整个舞台的一层**，不是右下角那个盒子 ——
   这样里面的据点与星图据点共用一套百分比坐标，两边算位置不用换算。
   这一层刻意不设 z-index：设了就自成层叠上下文，区域里的小点再也抬不到
   星图据点之上（.near 的 z-index 5 会被关在层里）。 */
.fr-board.is-map .fr-zone {
  display: block; position: absolute; inset: 0;
  pointer-events: none;              /* 层本身不可点，只有里面的点是链接 */
}
/* 右下角一块贴边的牌子。贴住舞台的两条边（去掉右边框与下边框），
   读作「地图角落的图例」，而不是又一个版面区块。 */
.fr-board.is-map .fr-zone::before {
  content: ""; position: absolute; right: 0; bottom: 0;
  width: var(--zone-w, 0px); height: var(--zone-h, 0px);
  background: var(--bg-sunken);
  border: 1px dashed var(--line-strong);
  border-right: 0; border-bottom: 0;
}
.fr-board.is-map .fr-zone .fr-node { pointer-events: auto; }
/* 牌子里的图标是「点」不是「据点」：22px、没有站名、宽度就是图标宽度。
   区域里全是连不上的站，站名在这儿只占地方 —— 悬停时详情条会报全名。 */
.fr-board.is-map .fr-zone .fr-node {
  --ava: var(--zone-dot, 22px);
  width: var(--zone-dot, 22px);
}
.fr-board.is-map .fr-zone .fr-node-name { display: none; }
/* 牌子内左上角。头像墙那套边距在这里全部作废，位置按牌子尺寸从右下角反推。
   左对齐而不是右对齐：图标要跟下面那一列小点从同一条竖线起排，
   贴右边的话标签的起点在小点列的中间，看着像飘着的。 */
.fr-board.is-map .fr-zone-tag {
  position: absolute; left: calc(100% - var(--zone-w, 0px) + 10px); gap: 8px;
  bottom: calc(var(--zone-h, 0px) - 10px - 20px);
  margin: 0; padding: 0; height: 20px; border-top: 0;
}
/* 角落牌子放不下「Unreachable / 」，中文标签就够表明这是什么了 */
.fr-board.is-map .fr-zone-en { display: none; }

/* 星图上站名与状态都让位给图形：站名按条数分档决定要不要显示（脚本挂 .no-label），
   状态只留方块，位置挪到头像右上角。文字含义由详情条与节点自身的 title 承载。 */
.fr-board.is-map .fr-node-net {
  position: absolute; top: 0; left: calc(50% + var(--ava) / 2 - 5px);
}
.fr-board.is-map .fr-node-net::after { display: none; }
.fr-board.is-map.no-label .fr-node-name { display: none; }

/* 入场：据点由内向外依次亮起（从本站长出一圈朋友）。
   用纯 CSS keyframes + 每节点一个 animation-delay（delays 由脚本按盘半径算，
   内圈靠前），与留言板「对话生长」同源 —— 节点被重排后动画会自己重播，不需要 JS 介入。
   与全站同一条安全线：.anim 门控 + 延迟由 .in 触发。
   JS 没跑起来或 reduce 时没有 .anim，节点直接可见，绝不会因为动效没播而看不见内容。 */
@keyframes fr-node-in { from { opacity: 0; } to { opacity: 1; } }
.anim .fr-board .fr-node { opacity: 0; }
.anim .fr-board.in .fr-node { animation: fr-node-in .55s cubic-bezier(.16, 1, .3, 1) both; }

/* 详情条：星图的据点承载不了描述，指针停到哪个据点上就在这里报出它的全部信息。
   只在星图模式下出现 —— 头像墙模式跑在触屏上，没有悬停可依靠，
   而站名与状态已经常驻在格子里了。 */
.fr-detail { display: none; }
.fr-board.is-map + .fr-detail {
  display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px 20px;
  margin-top: 26px; padding-top: 18px; border-top: 1px solid var(--line);
  min-height: 64px; font-size: 17px; color: var(--ink-2);
}
.fr-detail-hint { font-family: var(--font-mono); font-size: var(--fs-label); }
.fr-detail .d-name { font-family: var(--font-cjk); font-weight: 700; font-size: 20px; color: var(--ink); }
.fr-detail .d-net {
  display: inline-flex; align-items: center; gap: 6px;
  font-family: var(--font-mono); font-size: var(--fs-label);
}
.fr-detail .d-net::before { content: ""; width: 8px; height: 8px; border: 1px solid var(--line-strong); }
.fr-detail .d-net[data-net="0"]::before { background: #2f6b3a; border-color: #2f6b3a; }
.fr-detail .d-net[data-net="1"]::before { background: var(--signal-edge); border-color: var(--signal-edge); }
.fr-detail .d-net[data-net="2"]::before { background: #8a2b1f; border-color: #8a2b1f; }
.fr-detail .d-net[data-net="3"]::before { background: #8a2b1f; border-color: var(--ink-on-signal); }
.fr-detail .d-net[data-net="unknown"]::before { background: none; border-color: var(--line-strong); }
.fr-detail .d-meta {
  margin-left: auto;
  font-family: var(--font-mono); font-size: var(--fs-label);
}

/* ==========================================================================
   20. 留言板
   留言列表这一块是 #message-container，它同时是 Thymeleaf 片段 messageList ——
   /messagecomment/{n} 单独渲染它并返回纯片段 HTML，页面脚本收到后替换整个容器。
   所以这一节的样式必须在 site.css（页面已加载）里，不能写在片段里，
   片段拿不到 <head>，单独返回时没有任何样式表可引。
   ========================================================================== */

/* 发帖区 */
.compose { border: 1px solid var(--line); padding: 26px 28px; }
.compose-head { display: flex; align-items: flex-start; gap: 18px; margin-bottom: 20px; }
.compose-avatar { flex-shrink: 0; width: 56px; height: 56px; }
.compose-avatar img {
  width: 56px; height: 56px; object-fit: cover;
  filter: grayscale(1) contrast(.96) brightness(1.15);
}
.compose-inputs { flex: 1; min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr); gap: 10px; }
@media (min-width: 768px) { .compose-inputs { grid-template-columns: repeat(3, minmax(0, 1fr)); } }

/* 表情条。90 个表情一行放不下，固定高度内滚动；高度压到两行半，
   刚好露出"还有更多"，比做成弹层的成本低得多。 */
.emoji-bar {
  display: flex; flex-wrap: wrap; gap: 2px;
  max-height: 92px; overflow-y: auto;
  padding: 10px 12px; margin: 16px 0;
  border: 1px solid var(--line); background: var(--bg-sunken);
}
.emoji-bar button {
  border: 0; background: none; cursor: pointer;
  font-size: 20px; line-height: 1; padding: 4px 5px;
  transition: background .2s;
}
.emoji-bar button:hover { background: var(--signal-fill); }

/* 留言列表 */
.cm-list { border-top: 1px solid var(--line); }
.cm { display: flex; gap: 16px; padding: 26px 0; border-bottom: 1px solid var(--line); }

/* 对话生长：留言项入场用纯 CSS keyframes，不用 GSAP。
   原因：发完留言 #message-container 会被 AJAX 整块替换，新节点上没有任何 JS 实例，
   纯 CSS 动画在新节点插入时自动重播 —— 「生长」感正是由此而来，零 JS 介入。
   backwards 填充让 delay 期间元素停在 from 态（藏着），播完停在终态。
   整条链挂在 .anim 门控上：脚本没跑起来就没有动画，内容直接可见。 */
@keyframes cm-grow {
  from { opacity: 0; transform: translateY(16px); }
  to   { opacity: 1; transform: translateY(0); }
}
.anim .cm-list .cm { animation: cm-grow .5s cubic-bezier(.16, 1, .3, 1) backwards; }
/* 顶层留言按序错开（回复嵌在父级里，跟着父级一起长出来，不再单独错开）。
   只错前 10 条：再往后 delay 会拖到半秒以上，看着像卡住而不是生长。 */
.anim .cm-list > .cm:nth-child(1)  { animation-delay: .04s; }
.anim .cm-list > .cm:nth-child(2)  { animation-delay: .10s; }
.anim .cm-list > .cm:nth-child(3)  { animation-delay: .16s; }
.anim .cm-list > .cm:nth-child(4)  { animation-delay: .22s; }
.anim .cm-list > .cm:nth-child(5)  { animation-delay: .28s; }
.anim .cm-list > .cm:nth-child(6)  { animation-delay: .34s; }
.anim .cm-list > .cm:nth-child(7)  { animation-delay: .40s; }
.anim .cm-list > .cm:nth-child(8)  { animation-delay: .46s; }
.anim .cm-list > .cm:nth-child(9)  { animation-delay: .52s; }
.anim .cm-list > .cm:nth-child(10) { animation-delay: .58s; }

.cm-avatar { position: relative; flex-shrink: 0; width: 48px; height: 48px; }
.cm-avatar > img {
  width: 48px; height: 48px; object-fit: cover;
  filter: grayscale(1) contrast(.96) brightness(1.15);
}
/* 头像上的站点链接遮罩。原来是一直铺在头像上的半透明块，会一直压着头像；
   改成悬停才出现，静态时头像干净。 */
.cm-avatar .link-mask {
  position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
  background: rgba(17, 17, 17, .58); color: #fff;
  opacity: 0; transition: opacity .3s;
}
.cm-avatar:hover .link-mask { opacity: 1; }

.cm-body { flex: 1; min-width: 0; }
.cm-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 10px; }
.cm-name { font-weight: 700; color: var(--ink); }
/* 站主标记：用实心信号块。全站「被标出的那一处」都是这个语言 */
.cm-badge {
  background: var(--signal-fill); color: var(--ink-on-signal);
  border: 1px solid var(--signal-edge);
  padding: 1px 7px; font-size: var(--fs-label);
}
.cm-at { color: var(--signal); font-size: var(--fs-label); }

/* 留言正文。
   pre-wrap 是这次新增的：留言框是多行输入，而原样式下换行会被 HTML 折叠成空格，
   用户敲的三行显示出来是一行。th:text 输出的是纯文本，保留换行才对。 */
.cm-text { color: var(--ink-2); margin: 8px 0 0; white-space: pre-wrap; overflow-wrap: break-word; }

.cm-foot { display: flex; flex-wrap: wrap; gap: 16px; margin-top: 12px; color: var(--ink-2); font-size: var(--fs-label); }
.cm-foot a { border-bottom: 1px solid transparent; transition: color .3s, border-color .3s; cursor: pointer; }
.cm-foot a:hover { color: var(--signal); border-color: var(--signal-edge); }

/* 回复列表：左侧一条竖线，与目录、引用块同一套语言 */
.cm-replies { margin-top: 20px; padding-left: 18px; border-left: 1px solid var(--line); }
.cm-replies .cm { border-bottom: 0; padding: 14px 0; }
.cm-replies .cm:first-child { padding-top: 0; }
.cm-replies .cm:last-child { padding-bottom: 0; }
.cm-list .cm-empty { padding: 56px 0; color: var(--ink-2); }

/* 只给读屏看的文字。Bootstrap 有这个类，前台摘掉 Bootstrap 后要自己带一份，
   否则那些「访问站点」的说明会被当成普通文字显示出来。 */
.visually-hidden {
  position: absolute !important; width: 1px; height: 1px;
  padding: 0; margin: -1px; overflow: hidden;
  clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
}

/* ==========================================================================
   21. 文章详情页
   ========================================================================== */

/* 阅读仪器：视口顶部 3px 信号黄进度条，随正文滚动 scaleX 生长（页内脚本 scrub）。
   z-index 压过 fixed 顶栏（50）。3px 黄条贴视口最顶缘，底缘给它一道 --signal-edge，
   否则亮黄压在白底上边界会消失（§2.2）。 */
.read-progress {
  position: fixed; top: 0; left: 0; right: 0; height: 3px; z-index: 90;
  background: var(--signal-fill); border-bottom: 1px solid var(--signal-edge);
  transform: scaleX(0); transform-origin: left;
  pointer-events: none;
}

/* 小节标题的定向擦除揭示。初始态（.anim 门控）裁到不可见，
   页内脚本的 IntersectionObserver 看到它进视口就加 .wipe-done 放开。
   显示规则写成 .anim .md-body h2.wipe-done（特异性 (0,3,1) > 隐藏规则的 (0,2,1)），
   靠特异性赢，不依赖源序 —— 两条规则谁前谁后都不会翻车。 */
.md-body h2 { transition: clip-path .7s cubic-bezier(.16, 1, .3, 1); }
.anim .md-body h2 { clip-path: inset(0 100% 0 0); }
.anim .md-body h2.wipe-done { clip-path: inset(0 0 0 0); }

/* 页头。与内页的 .page-head 不同：这里没有下边框，正文区自己带分隔，
   而且宽度要大一些（标题可能很长）。 */
.post-head { padding: 132px 0 0; }
@media (min-width: 1024px) { .post-head { padding: 156px 0 0; } }
.post-head .eyebrow { margin-bottom: 22px; }

/* 文章页大标题。必须收在 .post-head 下：.post-title 这个类同时被归档/标签的
   列表行用着（那边是 21px 的行标题），裸写 .post-title 两条规则特异性相同，
   靠后的这条会在所有页面通吃 —— 列表标题被撑到 54px 就是这么来的。 */
.post-head .post-title {
  font-family: var(--font-cjk); font-weight: 900;
  font-size: clamp(30px, 5vw, 54px); line-height: 1.14; letter-spacing: -.03em;
  margin: 0; overflow-wrap: break-word;
}
.post-lead { color: var(--ink-2); margin: 22px 0 0; max-width: 62ch; }

.post-meta {
  display: flex; flex-wrap: wrap; align-items: center; gap: 12px 18px;
  margin-top: 32px; padding-top: 22px; border-top: 1px solid var(--line);
  color: var(--ink-2);
}
.post-meta img { width: 38px; height: 38px; object-fit: cover; flex-shrink: 0; }
.post-meta .who { color: var(--ink); font-weight: 700; }
.post-tags { display: flex; flex-wrap: wrap; gap: 4px 14px; margin-left: auto; }
.post-tags a {
  font-family: var(--font-mono); font-size: var(--fs-label); color: var(--signal);
  border-bottom: 1px solid var(--signal-edge); transition: background .3s, color .3s;
}
.post-tags a:hover { background: var(--signal-fill); color: var(--ink-on-signal); }

.post-cover { margin-top: 48px; }
.post-cover img {
  width: 100%; max-height: 460px; object-fit: cover;
  filter: grayscale(1) contrast(.96) brightness(1.2);
}

/* 正文 + 目录。目录 ≥1280px 才出现（再窄正文就没地方了）。
   用 align-self: start 让 sticky 生效：默认的 stretch 会把高度撑满，
   sticky 就没有可移动的余量。
   基础规则 .post-toc{display:none} 必须排在 @media 覆盖之前：两条特异性同为 (0,1,0)，
   媒体查询不加特异性，胜负纯看源序 —— 写反了目录在全宽度都出不来（实测踩过，
   与 .idx-thumb 同型）。 */
.post-layout { display: grid; grid-template-columns: minmax(0, 1fr); gap: 56px; }
.post-toc { display: none; }
@media (min-width: 1280px) {
  .post-layout { grid-template-columns: minmax(0, 1fr) 260px; gap: 72px; }
  .post-toc {
    display: block; position: sticky; top: 88px; align-self: start;
    max-height: calc(100vh - 120px); overflow-y: auto;
  }
}
.post-toc .toc-head {
  font-family: var(--font-mono); font-size: var(--fs-label); letter-spacing: .14em;
  text-transform: uppercase; color: var(--ink-2);
  padding-bottom: 12px; margin-bottom: 14px; border-bottom: 1px solid var(--line);
}

/* 版权声明 */
.copyright { margin-top: 64px; padding: 24px 26px; background: var(--bg-sunken); border: 1px solid var(--line); }
.copyright .h { font-weight: 700; color: var(--ink); margin-bottom: 14px; }
.copyright p { color: var(--ink-2); margin: 0 0 8px; font-size: 17px; }
.copyright p:last-child { margin-bottom: 0; }
.copyright a { color: var(--signal); border-bottom: 1px solid var(--signal-edge); }
.copyright a:hover { background: var(--signal-fill); color: var(--ink-on-signal); }

/* 相关推荐。
   用 grid 而不是 flex：flex 下两侧的最小尺寸都是 auto（等于 min-content），
   右边那串「日期 + 阅读 + 评论」在窄屏放不下时不会缩，左边标题也压不到更窄，
   于是整行把文档撑宽（实测 390px 视口下 scrollWidth 403）。
   minmax(0, 1fr) 才是那个能真正缩到 0 的轨道。 */
.rel-list { border-top: 1px solid var(--line); }
.rel-row {
  display: grid; grid-template-columns: minmax(0, 1fr); gap: 8px;
  padding: 20px 20px 20px 0; border-bottom: 1px solid var(--line);
}
@media (min-width: 768px) {
  .rel-row { grid-template-columns: minmax(0, 1fr) auto; gap: 24px; align-items: baseline; }
}
.rel-title { font-family: var(--font-cjk); font-weight: 700; font-size: 18px; color: var(--ink); overflow-wrap: break-word; }
.rel-meta { display: flex; flex-wrap: wrap; gap: 14px; color: var(--ink-2); }

/* 右下角浮动操作。原来靠 scroll 监听做淡入淡出，规范明令不要 scroll 监听，
   而且那种「往下滚就消失」的行为会让想回到顶部的人找不到按钮，索性常驻。 */
.float-actions {
  position: fixed; right: 20px; bottom: 24px; z-index: 45;
  display: flex; flex-direction: column;
  border: 1px solid var(--line); background: var(--bg);
}
@media (min-width: 1024px) { .float-actions { right: 32px; bottom: 32px; } }
.float-actions button {
  display: flex; flex-direction: column; align-items: center; gap: 3px;
  padding: 10px 12px; min-width: 68px;
  border: 0; border-bottom: 1px solid var(--line); background: none;
  color: var(--ink-2); font-size: var(--fs-label); cursor: pointer;
  transition: background .3s, color .3s;
}
.float-actions button:last-child { border-bottom: 0; }
.float-actions button:hover { background: var(--signal-fill); color: var(--ink-on-signal); }

/* 赞赏浮层。
   原版是 Bootstrap Modal，要引 80KB 的 bundle 才有个居中盒子；
   原生 <dialog> 自带焦点陷阱、Esc 关闭与 ::backdrop，一分钱不花。
   注意块级 <dialog> 的默认样式带 border 和 margin:auto，
   这里显式给宽度与居中，别让它退化成贴边的窄条。 */
.appr-dlg {
  width: calc(100% - 32px); max-width: 560px;
  padding: 0; border: 1px solid var(--line-strong); color: var(--ink);
  margin: auto;
}
.appr-dlg::backdrop { background: rgba(17, 17, 17, .32); }
.appr-dlg-head {
  display: flex; align-items: center; justify-content: space-between; gap: 16px;
  padding: 16px 20px; border-bottom: 1px solid var(--line);
}
.appr-dlg-body { padding: 24px; text-align: center; }
.appr-dlg-body > p { color: var(--ink-2); margin: 0 0 22px; }
.appr-qrs { display: flex; flex-wrap: wrap; gap: 26px; justify-content: center; }
.appr-qrs figure { margin: 0; }
.appr-qrs figcaption { margin-bottom: 10px; color: var(--ink-2); font-size: var(--fs-label); }
.appr-qrs img { width: 180px; border: 1px solid var(--line); }

/* 评论区的分页必须放在 #comment-container 之外 ——
   那个容器会被 AJAX 整块重写，分页放里面会被一起抹掉（改版前就是这样，
   加载后整页一个 .pagination 都不剩，评论分页等于不存在）。 */
#comment-pager { margin-top: 40px; }

/* ==========================================================================
   22. 提示库（prompt-lib.html · 指令目录）

   版面定稿自原型 A（prototypes/prompt-a-catalog.html，盲选胜出）：
   左栏分类索引吸附，右栏连续编号的条款行。
   与 about 页长卷的分别：那边是「读一个人」，这边是「查一本手册」——
   吸附栏装的是目录而不是肖像，行是拿来扫的，悬停整条填黄（索引行同族）。

   条款行由页内脚本 fetch /prompt 后渲染，DOM 是后插入的，
   所以行入场与留言板同一条路子：纯 CSS keyframes，插入即播，
   不依赖 JS 动效库在场（GSAP 缺位时内容照样完整可见）。
   ========================================================================== */

/* ---- 统计条：三个大数字横排。不是卡片，是页眉的注脚 ---- */
.pp-stats {
  display: flex; flex-wrap: wrap; gap: 48px;
  border-top: 1px solid var(--line-strong); border-bottom: 1px solid var(--line-strong);
  padding: 22px 0; margin-bottom: 56px;
}
.pp-stat .num {
  font-family: var(--font-disp); font-variation-settings: 'wdth' 100, 'wght' 800;
  font-size: 34px; line-height: 1;
}
.pp-stat .lbl { font-family: var(--font-mono); font-size: var(--fs-label); letter-spacing: .14em; color: var(--ink-2); margin-top: 8px; }

/* ---- 骨架：侧栏目录 + 条款流。窄屏不吸附（与长卷同一理由：矮视口钉栏只剩一条缝） ---- */
.pp-split { display: grid; grid-template-columns: minmax(0, 1fr); gap: 40px; }
@media (min-width: 1024px) {
  .pp-split { grid-template-columns: 232px minmax(0, 1fr); gap: 72px; align-items: start; }
}
.pp-side { position: static; }
@media (min-width: 1024px) { .pp-side { position: sticky; top: 96px; } }

.pp-side .cap {
  font-family: var(--font-mono); font-size: var(--fs-label); letter-spacing: .14em;
  color: var(--ink-2); padding-bottom: 10px; border-bottom: 1px solid var(--line-strong);
}
.pp-cat {
  position: relative;
  display: flex; align-items: baseline; gap: 10px; width: 100%; text-align: left;
  padding: 10px 0 10px 12px; border: 0; border-bottom: 1px solid var(--line);
  background: none; cursor: pointer; font-size: 17px; color: var(--ink);
}
/* 选中态：左边贴一块实心黄。块小，但亮黄对白底是 1:1，必须带 --signal-edge 描边 */
.pp-cat::before {
  content: ""; position: absolute; left: 0; top: 50%; width: 6px; height: 0;
  background: var(--signal-fill); border: 1px solid var(--signal-edge);
  transform: translateY(-50%); transition: height .3s cubic-bezier(.16, 1, .3, 1);
}
.pp-cat.on::before { height: 22px; }
.pp-cat .cnt { margin-left: auto; font-family: var(--font-mono); font-size: var(--fs-label); color: var(--ink-2); }
.pp-cat.on .cnt { color: var(--signal); }

/* ---- 条款行：行号随当前清单重排（编号是给这份清单编的，不是数据主键） ---- */
.pp-rows { border-top: 1px solid var(--line-strong); }
.pp-row {
  position: relative; isolation: isolate;
  display: grid; grid-template-columns: 52px minmax(0, 1fr); gap: 8px 20px;
  padding: 18px 12px 18px 0; border-bottom: 1px solid var(--line);
}
.pp-row::before {
  content: ""; position: absolute; inset: 0; z-index: -1;
  background: var(--signal-fill);
  clip-path: inset(0 100% 0 0);
  transition: clip-path .45s cubic-bezier(.16, 1, .3, 1);
}
.pp-row:hover::before { clip-path: inset(0 0 0 0); }
.pp-row:hover .pp-no, .pp-row:hover .pp-title, .pp-row:hover .pp-text,
.pp-row:hover .pp-use { color: var(--ink-on-signal); }

.pp-no { font-family: var(--font-mono); font-size: var(--fs-label); letter-spacing: .1em; color: var(--ink-2); padding-top: 4px; }
.pp-title { font-size: 17px; font-weight: 600; display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
.pp-lang {
  font-family: var(--font-mono); font-size: 12px; letter-spacing: .1em;
  border: 1px solid var(--line-strong); padding: 1px 6px; color: var(--ink-2);
  display: none;
}
.pp-row.show-en .pp-lang { display: inline-block; }
/* 指令内容是管理员维护的自由文本，可能带 URL、英文长串 —— 不断行就会撑破窄屏
   （实测 360px 下一条含 Markdown 长串的内容把页面撑出 110px 横向溢出） */
.pp-text, .pp-title { overflow-wrap: break-word; }
.pp-text { color: var(--ink-2); margin: 6px 0 0; cursor: pointer; }
.pp-has-en .pp-text:hover { color: var(--ink); }
.pp-acts { grid-column: 2; display: flex; align-items: center; gap: 18px; margin-top: 12px; }
.pp-use { font-family: var(--font-mono); font-size: var(--fs-label); letter-spacing: .06em; color: var(--ink-2); }
.pp-cp {
  font-size: 15px; letter-spacing: .1em; padding: 6px 16px; cursor: pointer;
  background: none; border: 1px solid var(--line-strong); color: var(--ink);
  transition: background .25s, border-color .25s;
}
.pp-cp:hover { border-color: var(--ink); }
.pp-cp.done { background: var(--signal-fill); border-color: var(--signal-edge); color: var(--ink-on-signal); }
@media (min-width: 768px) {
  .pp-row { grid-template-columns: 52px minmax(0, 1fr) auto; align-items: start; }
  .pp-acts { grid-column: 3; margin-top: 0; flex-direction: column; align-items: flex-end; gap: 10px; }
}
.pp-empty { padding: 40px 0; color: var(--ink-2); font-family: var(--font-mono); font-size: var(--fs-label); letter-spacing: .1em; }

/* 行入场：纯 CSS keyframes（与留言板 .cm 同族），筛选重渲时自然重放 */
@keyframes pp-grow {
  from { opacity: 0; transform: translateY(14px); }
  to   { opacity: 1; transform: translateY(0); }
}
.anim .pp-rows > .pp-row { animation: pp-grow .45s cubic-bezier(.16, 1, .3, 1) backwards; }
.anim .pp-rows > .pp-row:nth-child(1)  { animation-delay: .03s; }
.anim .pp-rows > .pp-row:nth-child(2)  { animation-delay: .08s; }
.anim .pp-rows > .pp-row:nth-child(3)  { animation-delay: .13s; }
.anim .pp-rows > .pp-row:nth-child(4)  { animation-delay: .18s; }
.anim .pp-rows > .pp-row:nth-child(5)  { animation-delay: .23s; }
.anim .pp-rows > .pp-row:nth-child(6)  { animation-delay: .28s; }
.anim .pp-rows > .pp-row:nth-child(7)  { animation-delay: .33s; }
.anim .pp-rows > .pp-row:nth-child(8)  { animation-delay: .38s; }
.anim .pp-rows > .pp-row:nth-child(9)  { animation-delay: .43s; }
.anim .pp-rows > .pp-row:nth-child(10) { animation-delay: .48s; }

/* ---- 首次复制的提示条：旧版 Semantic popup 的替代，方角墨底 ---- */
.pp-toast {
  position: fixed; right: 20px; bottom: 20px; z-index: 70;
  max-width: calc(100vw - 40px); padding: 12px 18px;
  background: var(--ink); color: var(--bg); font-size: 15px;
  opacity: 0; transform: translateY(10px); pointer-events: none;
  transition: opacity .35s cubic-bezier(.16, 1, .3, 1), transform .35s cubic-bezier(.16, 1, .3, 1);
}
.pp-toast.show { opacity: 1; transform: translateY(0); }

/* ---- 页头 slogan 轮播的淡出半程（淡入由移除类完成） ---- */
.pp-slogan { transition: opacity .5s; }
.pp-slogan.out { opacity: 0; }
