/* ============================================================
   HEU ESTA 官网 · 第 1 层：令牌与基座
   ------------------------------------------------------------
   本文件是全站唯一的设计令牌来源：字体、色板、节拍曲线、圆角、
   以及 reset / 基础元素 / 焦点环 / 滚动条 / 跨页过渡。

   加载顺序（base.html）：tokens.css → core.css →（仅首页）home.css
   改这里会影响所有页面；只放"没有归属于某个组件"的东西。
   无构建链：直接编辑即可生效。
   ============================================================ */

/* ---------- 自托管字体（scripts/build_fonts.py 产出） ----------
   mono 数字是全站视觉主角，不依赖访客本机字体；
   中文标题用真 Heavy 字重，告别微软雅黑伪加粗 */
@font-face {
    font-family: "JetBrains Mono";
    src: url("../fonts/JetBrainsMono-subset.e868078c65a7.woff2") format("woff2");
    font-weight: 100 800;
    font-style: normal;
    font-display: swap;
}

/* 标题字：得意黑（Smiley Sans，SIL OFL）。
   为什么换掉思源黑体 Heavy：那是最中性的一款黑体，和微软雅黑、苹方同一类 ——
   所以「自托管了标题字体，但看起来还是黑体」正是它造成的，用户一眼就说出来了。
   得意黑是倾斜 + 紧缩的展示体，字形本身带态度；紧缩还让同样宽度能放更大的字。
   按模板取字后 270KB，比思源 Heavy 的 333KB 还小。

   **`font-weight` 必须声明成范围 `100 900`。** 这个家族只有一个字形，而 CSS 里
   到处写着 `font-weight: 900`（标题）—— 如果这里只声明 400，浏览器会认为
   「家族里没有 900」而**合成假粗**（把字形描粗），那正是这套设计要摆脱的东西。
   声明成范围等于告诉浏览器「任何字重都用这一个字形」，于是不合成。 */
@font-face {
    font-family: "ESTA Display";
    src: url("../fonts/SmileySans-subset.ada7fe739a63.woff2") format("woff2");
    font-weight: 100 900;
    font-style: normal;
    font-display: swap;
}

/* 正文的中文。**两档必须成对**：只自托管 Regular 的话，`<strong>` 和
   `font-weight: 700` 会让浏览器把 Regular 描粗（合成假粗），而系统黑体本来有
   真 Bold —— 自托管反倒变差。
   字表不按模板取字，取 GB2312 一级字全集（3760 个，见 build_fonts.py 的说明）：
   正文内容是站务以后随时写的，模板扫不到，而正文缺字比标题缺字难看得多。
   代价是两档合计约 2MB。这是明确用体积换效果的地方（用户口径：展示效果优先，
   重复访问有一年 immutable 缓存），如果哪天嫌重，下一步是按 unicode-range
   切片，而不是砍字表。 */
@font-face {
    font-family: "ESTA Sans";
    src: url("../fonts/SourceHanSansCN-Regular-subset.d2907c0d378e.woff2") format("woff2");
    font-weight: 400;
    font-style: normal;
    font-display: swap;
}

@font-face {
    font-family: "ESTA Sans";
    src: url("../fonts/SourceHanSansCN-Bold-subset.1591947505e8.woff2") format("woff2");
    font-weight: 700;
    font-style: normal;
    font-display: swap;
}

/* ---------- 科协专属数字 ----------
   自己设计的一套数字：每一笔是一段走线，笔画在空处收尾的地方是一个焊盘环，
   转角只走 45°。几何与线宽跟会标同源（见 scripts/build_lettering.py）。

   为什么做成字体而不是 SVG 图：站上的大数字大多是活的 —— 分镜 02 那个「31」由 JS
   从 0 计上去、倒计时每秒重写、统计数字来自数据库。SVG 图参与不了这些。
   整套只有 17 个字形，**2KB**。

   **只用在 26px 以上的展示数字上。** 焊盘环外径是 0.168em，13px 的小字上只剩 2px，
   缩成一个看不清的脏点；那些位置继续用 mono。 */
@font-face {
    font-family: "ESTA Digits";
    src: url("../fonts/ESTADigits.025c47d6c787.woff2") format("woff2");
    font-weight: 100 900;   /* 只有一个字形，声明成范围以免浏览器合成假粗 */
    font-style: normal;
    font-display: swap;
}

/* 第二声音：思源宋体 SemiBold。全站原来只有「几何无衬线 + 等宽」两种声音，
   温度是平的 —— 加一个**种类**上的对比，比再加一档字号有效得多。

   两条使用限制（不是风格偏好，是它会坏掉的条件）：
   1. **只用在模板文案上。** 它的字表和标题字体一样是按模板取字的（按正文那份
      GB2312 全集做出来是 1456KB，宋体轮廓复杂，多花 40%）。站务写的内容
      （公告引用块、作品简介）里可能有子集外的字，那会在一段引文里混进黑体。
   2. **只用在 18px 上下，不要拿去做小字。** 宋体的横画在纯黑底上本来就细，
      小字号会发虚 —— 这也是只做 SemiBold 一档的原因（Regular 在暗底上撑不住）。
      窄屏 15px 没问题：手机 DPR≥2，实际像素比 1x 桌面的 18px 还多。

   现在用在哪（全是「导语」这一个角色）：`.page-hero-sub`（每页页头）、
   `.recruit-hero p`、`.rec-hero-sub`、`.auth-lede`、`.nf-join-sub`。
   **`.wk-detail-lede` 刻意不用** —— 它的文字来自数据库。 */
@font-face {
    font-family: "ESTA Serif";
    src: url("../fonts/SourceHanSerifCN-SemiBold-subset.ff69e709df1d.woff2") format("woff2");
    font-weight: 600;
    font-style: normal;
    font-display: swap;
}

:root {
    --black: #050506;
    --black-2: #0b0b0e;
    --paper: #f7f8fa;
    --white: #ffffff;
    --ink: #0e1116;
    --ink-muted: #5f6774;
    --text: #e9edf4;
    --muted: #97a1b3;
    /* 第三档文字（页脚版权条、备案号）。比 --muted 更暗，但**实测对比度 5.05:1**
       仍过 AA 的 4.5 门槛 —— 原来这一档直接写 #6b7280 字面值，只有 4.21:1，
       是全站唯一不达标的正文色。改暗一档之前先拿 check_a11y.py 量一遍。 */
    --muted-dim: #767f8f;
    /* 品牌主色「信号青」：比通用天蓝更深更青，取自示波器荧光轨迹。
       色板叙事 = 电路板：黑色基板 + 信号青走线 + 裸铜焊盘 */
    --accent: #0da9cd;
    --accent-2: #41d8e8;
    /* 白区（`.nf-light`，底色 --paper）上的信号青。**同一个 --accent 放到浅底上
       只有 2.61:1**，连大字要的 3:1 都不到 —— 而白区里恰好有 eyebrow 小字和大标题
       里的强调词两处在用它。所以白区单独一个更暗的档，色相仍是那条信号青；
       暗底上继续用 --accent，那边是 11:1 以上。

       **从 #0a7a94（4.68:1）再压一档到 #0a7288（5.23:1）**，理由不是「更保险」
       而是白区铺了材质：`.nf-light.surf-hatch` 的浅底板面纹把有效底色从 248 压到
       中位 244、最暗 5% 到 237。而 4.68:1 只有 0.18 的余量 —— 材质一铺上去
       eyebrow 那 13px 小字就跌到 4.5 以下，**而 check_a11y.py 量不到**
       （材质画在 ::before 上，它量的是元素自己的 background-color，永远是 248）。
       压暗之后：纸白 5.23、材质中位 5.12、材质最暗处 4.81，三种情况都有余量。 */
    --accent-ink: #0a7288;
    /* 第二记忆色「焊锡铜」：与论坛皮肤 --esta-copper 同源，
       小面积用于编号、选区等工艺感细节，不承担交互语义 */
    --copper: #c98a3d;
    --copper-2: #e2ab63;
    --line-d: rgba(255, 255, 255, 0.1);
    --line-d-strong: rgba(255, 255, 255, 0.22);
    --line-l: rgba(14, 17, 22, 0.1);
    --ok: #3fd68f;
    --warn: #f4b63f;
    --danger: #f0716e;
    --radius: 16px;
    --font-mono: "JetBrains Mono", "Cascadia Code", Consolas, "Courier New", monospace;
    /* 系统兜底栈。它只是兜底 —— 三个自托管家族都排在它前面。
       留着是因为字体请求可能失败（`font-display: swap` 那一刻页面必须有字）。 */
    --font-sys: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
        "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif;
    /* 正文：思源黑体 Regular / Bold（见下面的 @font-face 与那里的说明）。
       改版上线后**站上读得最多的那部分文字，是唯一没被设计过的** ——
       Mac 上是苹方、Windows 上是微软雅黑，两个访客看到的是两种气质的网站。 */
    --font-body: "ESTA Sans", var(--font-sys);
    /* 标题：得意黑是按模板取字的子集，缺字时退到自己的正文 Bold，
       而不是跳到系统黑体（那是换一套字形，且两种字形会同时出现在一行标题里）。 */
    --font-display: "ESTA Display", "ESTA Sans", var(--font-sys);
    /* 第二声音（宋体）。**只能用在模板文案 + 18px 以上**，理由见下面的
       @font-face 注释。用它的地方一律显式写 font-weight: 600 —— 这个家族
       只有一档，写 400 或 700 会被浏览器映射到 600，看着对、实则在说谎。 */
    --font-serif: "ESTA Serif", Georgia, "Songti SC", "SimSun", serif;
    /* 科协专属数字（走线 + 焊盘，自己设计的）。只覆盖 0-9 与 . : + - / %，
       其余字符自动落到 mono 上 —— 所以这个栈可以直接用在混排的元素上。
       **只用在 26px 以上的展示数字**，理由见下面的 @font-face 注释。 */
    --font-num: "ESTA Digits", var(--font-mono);
    --ease-out: cubic-bezier(0.22, 1, 0.36, 1);

    /* 兼容内页组件的旧变量名 */
    --bg-0: var(--black);
    --bg-1: #0a0c10;
    --bg-2: #10131a;
    --panel: rgba(16, 19, 26, 0.75);
    --line: var(--line-d);
    --line-strong: var(--line-d-strong);
    --brand: var(--accent);
    --brand-2: var(--accent-2);
    --cyan: #63e2ef;
    --glow: rgba(13, 169, 205, 0.35);
}

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

/* `hidden` 属性必须真的隐藏。组件一旦设了 display: flex/grid，就会盖掉浏览器
   给 [hidden] 的默认 display: none —— 于是 el.hidden = true 变成没有任何效果，
   而 JS 那边看起来完全正确。全站有大量「脚本按能力放出/收起」的元素（倒计时
   时钟、分步表单的段与按钮、视频手动播放按钮），这条兜住它们。
   这是全站唯一该用 !important 的地方：hidden 的语义就是「无条件不渲染」。 */
[hidden] { display: none !important; }

/* 表单控件不继承字体，这是 UA 样式表的规定（`font: 400 13.33px Arial`）。
   于是每一个带文字的 `<button>` 都在用 Arial 画中文 —— 而 Arial 没有汉字，
   Windows 会退到宋体，也就是全站唯一一处衬线体，出现在按钮上。
   实测「复制群号」那个按钮就是这样（`font-family: Arial`）。
   英文界面上看不出来，所以这类漏洞能活很久。
   只接管 family，不写 `font: inherit` —— 后者会把 `.btn` 之外的裸控件
   连字号行高一起改掉，那是另一件事。 */
button, input, select, textarea, optgroup { font-family: inherit; }

/* ---------- 跨页过渡（MPA View Transitions，Chromium 渐进增强） ----------
   同源页面跳转时旧页淡出、新页淡起，替代硬切白屏；
   不支持的浏览器忽略此规则，行为不变。

   在这之上有三层「连续」，让跳转读起来像同一块板子在换内容，而不是换了一页纸：
     1. 固定导航条整段不参与淡入淡出（`site-nav`）—— 它在两页之间本来就没动，
        跟着整页淡一下是最容易被察觉的假动作。
     2. 页头横幅在页面之间形变（`page-hero`）—— 所有列表页与作品详情页都有一条
        暗场横幅，认成同一个元素之后高度是长出来的。
     3. 卡片封面 ↔ 详情页大图（`vt-cover`）—— 名字由 view-transitions.js 在导航
        那一刻只给参与的那一张卡片，因为 `view-transition-name` 在一页里必须唯一。 */
@media (prefers-reduced-motion: no-preference) {
    @view-transition { navigation: auto; }
}
::view-transition-old(root) {
    /* 纯不透明度的交叉淡入用 linear（UA 默认也是），加缓动只会让中段发灰 */
    animation: vt-fade-out var(--t-fast) linear both;
}
::view-transition-new(root) {
    animation: vt-fade-in var(--t-base) var(--ease-rise) both;
}
@keyframes vt-fade-out { to { opacity: 0; } }
@keyframes vt-fade-in { from { opacity: 0; transform: translateY(8px); } }

/* 固定导航条：不参与整页淡入淡出，跨页保持钉住 */
.site-nav { view-transition-name: site-nav; }
/* 页头横幅：列表 ↔ 列表 ↔ 作品详情之间形变。每一页最多一条，天然唯一 */
.page-hero, .wk-detail-hero { view-transition-name: page-hero; }

/* 由 view-transitions.js 指派。用属性映射而不是内联 style：收尾只要摘掉属性，
   不用还原原值 */
[data-vt-name="cover"] { view-transition-name: vt-cover; }

/* 封面形变要比根级淡入明显长一档，眼睛才跟得住「它长大了」这件事。
   `--ease-rise` 是 expo-out，动作极度前置：给 .35s 的话 140ms 就已经走完九成，
   读起来是「啪」地换了一张图而不是长大。给 --t-slow 之后可辨的那一段有 250ms，
   后面那截是亚像素的软着陆，不会让人觉得在等。 */
::view-transition-group(vt-cover) {
    animation-duration: var(--t-slow);
    animation-timing-function: var(--ease-rise);
}
/* 两张图裁切比例不一定完全一样（卡片是 16/10，详情页大图更宽），默认的
   `object-fit: fill` 会让中途那几帧被拉扁。铺满自己的组 + cover 裁切之后，形变
   过程里只有取景框在变，画面本身不变形。
   **不要在这里写 `animation: none`。** UA 给配上对的 old/new 一对交叉淡入淡出，
   靠的是 `mix-blend-mode: plus-lighter` + 组上的 `isolation: isolate`：两张图的
   不透明度相加恒为 1，中途不会透出底下的页面。关掉动画既丢了这层过渡，也让
   `document.getAnimations()` 再也看不到这两个伪元素（check_transitions.py 就是
   靠它判断有没有配上对的）。同理 `mix-blend-mode` 也不要改成 normal。 */
::view-transition-old(vt-cover),
::view-transition-new(vt-cover) {
    height: 100%; width: 100%;
    object-fit: cover;
}
/* 横幅只是高度长一截，比封面短一档：两条同时用 --t-slow 会显得整页都在慢慢胀 */
::view-transition-group(page-hero) {
    animation-duration: var(--t-base);
    animation-timing-function: var(--ease-out);
}

@media (prefers-reduced-motion: reduce) {
    ::view-transition-old(root), ::view-transition-new(root) { animation: none; }
    /* 命名元素这一侧：view-transitions.js 在减动效偏好下压根不指派名字，
       所以只剩 site-nav / page-hero 两个静态名。把它们也停住，结果是干净的瞬切。 */
    ::view-transition-group(site-nav), ::view-transition-group(page-hero) { animation: none; }
}

/* ---------- 线性图标（includes/icon.html） ---------- */
.icon { width: 20px; height: 20px; display: inline-block; vertical-align: -0.24em; flex-shrink: 0; }
.icon-sm { width: 16px; height: 16px; vertical-align: -0.18em; }
.icon-lg { width: 42px; height: 42px; }

html { scroll-behavior: smooth; }

body {
    font-family: var(--font-body);
    background: var(--black);
    color: var(--text);
    line-height: var(--lh-relaxed);
    -webkit-font-smoothing: antialiased;
    overflow-x: hidden;
}

a { color: var(--accent-2); text-decoration: none; transition: color .25s; }
a:hover { color: var(--cyan); }
img { max-width: 100%; display: block; }

/* 懒加载图片淡入（site.js 给未完成加载的 lazy 图挂类） */
img.img-fade { opacity: 0; transition: opacity .45s ease; }
img.img-fade.img-loaded { opacity: 1; }

/* 按钮成功反馈的小弹跳（复制群号等） */
@keyframes btn-pop {
    0% { transform: scale(1); }
    40% { transform: scale(1.07); }
    100% { transform: scale(1); }
}
.btn.copied { animation: btn-pop .4s var(--ease-out); }

/* ---------- 只给读屏软件的文字 ----------
   不要用 `display: none` 或 `visibility: hidden` 藏这类文字 —— 那两种写法读屏
   软件也读不到，等于什么都没写。这一份是「视觉上不占位、但留在无障碍树里」。
   `clip-path` 之外还留着 1px 尺寸与 `overflow: hidden`：宽高直接给 0 时部分
   读屏软件会跳过它。 */
.sr-only {
    position: absolute;
    width: 1px; height: 1px;
    margin: -1px; padding: 0;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
    border: 0;
}

/* ---------- 跳到主要内容 ----------
   固定导航里有十几个可聚焦项（会标 + 5~6 条链接 + 两个折叠组 + 账号区 + 汉堡）。
   没有这条链接，键盘与读屏用户在**每一页**都要先 Tab 穿过这一整排才能碰到正文。
   平时用 .sr-only 藏起来，一被聚焦就现身 —— 所以它对鼠标用户完全不存在。
   z-index 要压过导航（100），但**不要**压过加载动画（300）：开场遮罩盖着的时候
   本来就不该有任何可交互的东西露出来。 */
.skip-link:focus {
    position: fixed;
    top: 12px; left: 12px;
    z-index: 101;
    width: auto; height: auto;
    margin: 0;
    padding: 12px 20px;
    clip-path: none;
    background: var(--accent);
    color: #04121a;
    font-weight: 700;
    border-radius: 10px;
    box-shadow: 0 10px 30px rgba(0, 0, 0, .5);
}

/* 键盘焦点环：品牌信号青，只在键盘导航时出现 */
:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: 3px;
    border-radius: 4px;
}
/* 跳转目标拿到的是编程式焦点（tabindex="-1"），不该画焦点环 —— 用户没有
   「聚焦到正文」这个心理模型，画一个大框反而像出错了 */
.site-main:focus, .site-main:focus-visible { outline: none; }
.btn:focus-visible, .nav-links a:focus-visible, .pill:focus-visible,
.pagination a:focus-visible, .nav-user:focus-visible,
.nav-group > summary:focus-visible {
    outline-offset: 2px;
    border-radius: 999px;
}
.input:focus-visible { outline: none; } /* 输入框已有 box-shadow 焦点态 */

.container { width: min(1240px, 92%); margin: 0 auto; }

/* 选区用焊锡铜：划词像给文字镀上一层锡 */
::selection { background: rgba(201, 138, 61, .5); color: #fff; }
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: var(--black); }
::-webkit-scrollbar-thumb { background: #23262e; border-radius: 5px; }
::-webkit-scrollbar-thumb:hover { background: #33373f; }

/* ============================================================
   新增令牌（2026 招新版视觉升级）
   ------------------------------------------------------------
   下面这批是在原有色板之上的扩展，不改动任何既有取值。
   命名沿用「电路板」这条叙事线：基板 / 阻焊 / 丝印 / 铜箔 / 焊盘。
   ============================================================ */
:root {
    /* 第三色「阻焊墨绿」：真实 PCB 阻焊层的记忆色。只用于大面积基板质感
       区块（3D 会标底板、素材槽背板），不参与文字与交互语义，避免与
       信号青抢注意力。 */
    --solder-mask: #0a2f2a;
    --solder-mask-2: #0f4038;

    /* 丝印白：PCB 上印刷元件编号的那层白墨。用于区块编号、标签、
       占位槽的定位标记，比纯白柔和，不会在暗场里刺眼。 */
    --silk: rgba(233, 237, 244, .82);
    --silk-dim: rgba(233, 237, 244, .42);
    --silk-line: rgba(233, 237, 244, .22);

    /* 沉金焊盘：3D 会标的过孔与焊盘材质，比 --copper 更亮更黄。 */
    --pad-gold: #e8bd6d;

    /* 示波器网格（暗场版）。白区的坐标纸已在 core.css 的 .nf-light 内联，
       这里给黑区区块用，格距与白区一致以保持同一套度量。 */
    --scope-line: rgba(65, 216, 232, .055);
    --scope-line-strong: rgba(65, 216, 232, .1);
    --scope-cell: 50px;
    --scope-cell-fine: 10px;

    /* ---------- 字号阶梯 ----------
       为什么要有它：改版上线后数出来 **272 处 font-size 声明用了 51 种取值**，
       其中 13 / 13.5 / 14 / 14.5 / 15 / 15.5px 是六个取值在干同一件事 ——
       肉眼分不出，却让版面失去了「这两块地位不同」的能力。这不是「哪个数更好」
       的问题，是**没做决定**：每写一个组件就凭手感填一个数，一年下来就 51 种。

       所以每一档必须能说出职责。说不出来的就是噪音，不许新增。
       `scripts/type_audit.py` 挡着这件事：字面值一律不许再出现。

         --fs-3xs  11px   角标、徽章、极小标记
         --fs-2xs  12px   元数据：日期、计数、mono 小标签
         --fs-xs   13px   第三档说明：表单帮助、表格小字、eyebrow
         --fs-sm   14px   界面文字：药丸、导航、小按钮、卡片摘要
         --fs-md   15px   正文与表单控件（本站默认阅读尺寸）
         --fs-lg   16px   强调正文、卡片标题起点、长文正文
         --fs-xl   18px   小节标题
         --fs-2xl  20px   面板标题
         --fs-3xl  22px   段落标题
         --fs-4xl  26px   章节标题
         --fs-5xl  30px   大数字、章节序号
         --fs-6xl  36px   头像首字母、超大统计数字
         --fs-7xl  50px   固定尺寸的巨号数字（媒体查询里的分档）
         --fs-8xl  64px   丝印大号编号（压在卡片角上的那种，只当图形用）

       17–32px 这一段原来几乎是空的（12 种取值只占 47 处声明），于是每页都读成
       「一个巨标题 + 一片 13px 灰字」，中间没有台阶 —— xl/2xl/3xl 三档就是补它的。

       小号那一段刻意是 1px 一档（11→16）：这个尺寸下 1px 已经是 6~9%，
       是能看出来的一档；再细分就回到 51 种的老路。 */
    --fs-3xs: 11px;
    --fs-2xs: 12px;
    --fs-xs: 13px;
    --fs-sm: 14px;
    --fs-md: 15px;
    --fs-lg: 16px;
    --fs-xl: 18px;
    --fs-2xl: 20px;
    --fs-3xl: 22px;
    --fs-4xl: 26px;
    --fs-5xl: 30px;
    --fs-6xl: 36px;
    --fs-7xl: 50px;
    --fs-8xl: 64px;

    /* 展示尺寸（流体）。标题的字号本来就随视口变，所以这一段不是「几档 px」
       而是「几条 clamp 斜坡」—— 原来 21 条斜坡里每一条的 min/vw/max 都不一样
       （clamp(30,4vw,46) / clamp(30,5vw,54) / clamp(28,4.6vw,50) / clamp(30,4.4vw,48)
       这四条各服务一个页头，肉眼完全分不出），收成 6 条 + 一条导语尺寸。

         --fs-lead   导语、Hero 副标题
         --fs-dsp-1  面板标题、大数字的单位
         --fs-dsp-2  详情页 h1、统计数字
         --fs-dsp-3  列表页页头 h1
         --fs-dsp-4  分镜大标题（.nf-display）
         --fs-dsp-5  段落序号
         --fs-dsp-6  首屏巨字
         --fs-mega   占半屏的数字（404 码、SINCE 大数） */
    --fs-lead: clamp(15px, 1.6vw, 18px);
    --fs-dsp-1: clamp(22px, 3vw, 32px);
    --fs-dsp-2: clamp(26px, 3.8vw, 40px);
    --fs-dsp-3: clamp(30px, 4.6vw, 50px);
    --fs-dsp-4: clamp(32px, 5.4vw, 62px);
    --fs-dsp-5: clamp(40px, 7vw, 84px);
    --fs-dsp-6: clamp(46px, 7.2vw, 96px);
    --fs-mega: clamp(112px, 23vw, 270px);

    /* ---------- 行高阶梯 ----------
       原来 67 处声明用了 20 种取值（1.5 / 1.6 / 1.65 / 1.7 / 1.75 / 1.8 各有一批）。
       同样收成六档，每档差 10% 上下 —— 差 3% 的两个行高不是层次，是没决定。

         --lh-flat     1     单行的数字与图标，不留行距
         --lh-tight    1.15  巨字与多行大标题
         --lh-snug     1.4   卡片标题、短标题
         --lh-normal   1.55  界面文字、列表项
         --lh-relaxed  1.7   正文（body 默认）
         --lh-prose    1.85  长文与导语，行宽大时才撑得住 */
    --lh-flat: 1;
    --lh-tight: 1.15;
    --lh-snug: 1.4;
    --lh-normal: 1.55;
    --lh-relaxed: 1.7;
    --lh-prose: 1.85;

    /* 节拍：全站动效只允许用这三条曲线，避免每个组件各自发明缓动。
       - rise 上升沿：快起慢收，用于入场、位移、揭示
       - damp 阻尼振荡：轻微 overshoot，用于点击/悬停等交互反馈
       - ease-out 保留原值，是既有组件的默认曲线 */
    --ease-rise: cubic-bezier(.16, 1, .3, 1);
    --ease-damp: cubic-bezier(.34, 1.56, .64, 1);

    /* 时长阶梯：从"察觉不到"到"电影感"四档，杜绝 .23s/.37s 这类随手数字 */
    --t-fast: .18s;
    --t-base: .35s;
    --t-slow: .6s;
    --t-cine: 1.1s;

    /* 首屏遮罩层级：preloader 必须盖住导航栏（z-index 100）与反馈弹窗（200） */
    --z-preloader: 300;
}

/* ============================================================
   表面（surface）：一块区域的「材质」
   ------------------------------------------------------------
   为什么要一整套：改版上线后全站**只有一种底纹**（示波器网格 `.nf-scope`），
   于是每个区块的静止状态看起来都一样 —— 丰富度全压在动效和颜色上，页面不动
   的那一刻就显得薄。这一套把同一条「电路板」叙事拆成几层真实存在的工艺层，
   不同区块用不同层，静止时才有「这一段和上一段不是一回事」的分别。

   一层 = 一道工序，命名照工艺走：
     .surf-weave    裸基板     FR4 玻纤布纹，最弱，用于大面积铺底
     .surf-hatch    覆铜区     45° 网格填充，EDA 里覆铜就是这么画的
     .surf-via      过孔阵列   钻孔环点阵，比网格更「有东西」
     .surf-silk     丝印刻度   边缘的虚线标尺，用于分界与页边
   示波器网格仍然是 `.nf-scope`（在 core.css，带上下渐隐与 z-index 提升），
   它是「仪器屏幕」而不是板材，所以不并进这一套。

   **底纹画在 `::before` 上，不画在元素自己的 background 上。** 这不是风格选择，
   是被现有代码逼出来的唯一干净解法，三个原因：
   1. `.nf-dark` 用的是 `background:` **简写**，而 `.nf-light` 自己已经占用了
      `background-image`（白区坐标纸）。给元素直接加 background-image 的话，
      要么被简写擦掉、要么和坐标纸抢同一个属性 —— 全站已经因此踩过四次
      （`.rec-hero` / `.recruit-hero` / `.auth-stage` / `.page-hero` 的注释都还在）。
      画在伪元素上，这一整类冲突不存在。
   2. 伪元素**不在**「逐层向上找不透明背景」那条链上，所以
      `check_a11y.py` 的对比度计算照常能算到平底色。反过来，如果给元素本身加
      background-image，那一节会因为「祖先有图」而**跳过**它 —— 那是把检查悄悄
      关掉，不是通过。
   3. 边缘渐隐直接用 `mask-image` 做在同一个伪元素上，不必再多叠一层。

   代价与规矩：`::before` 被底纹占了，`::after` 被颗粒占了。所以
   **一个区块只能有一层表面**（`.nf-scope` 也算一层，它同样用 `::before`）
   —— 别把 `.nf-scope` 和 `.surf-*` 叠在同一个元素上，后者会被吃掉。
   两层材质加颗粒本来也已经是噪音而不是层次了。

   原来这里还有一个 `.scope-grid`，和 core.css 的 `.nf-scope` 是同一件事的第二份
   实现，模板里一次都没用过，而且两份已经漂移（这份方形平铺、那份整行整列）。
   已删除 —— 正是「不要复制第二份」那条的反面教材。
   ============================================================ */
:root {
    /* 表面线条色。都压到「几乎看不见」——它们是材质不是内容。
       暗底用微亮，白区用微暗，各给一套。 */
    --surf-weave-line: rgba(233, 237, 244, .022);
    --surf-hatch-line: rgba(201, 138, 61, .05);
    --surf-via-ring: rgba(65, 216, 232, .075);
    --surf-silk-line: rgba(233, 237, 244, .14);

    /* 度量。全部落在示波器网格的 10 / 50px 之上，保持同一套刻度感 */
    --surf-weave-cell: 20px;
    --surf-hatch-cell: 7px;
    --surf-via-cell: 40px;
    --surf-via-dot: 1.6px;
}

/* 四种表面共用的那一层：铺满、不挡事件、上下边缘渐隐。
   渐隐用 mask 而不是再叠一层渐变 —— 那样才不占第二个伪元素，
   而且底色是什么都不用管（叠一层渐变就得知道底色，`.nf-scope::before` 当年
   正是为此把 `var(--black)` 写死，于是换个底色就露出一条生硬的边）。 */
.surf-weave, .surf-hatch, .surf-via { position: relative; }
.surf-weave::before, .surf-hatch::before, .surf-via::before {
    content: "";
    position: absolute;
    inset: 0;
    pointer-events: none;
    z-index: 0;
    -webkit-mask-image: linear-gradient(180deg, transparent 0,
        #000 10%, #000 90%, transparent 100%);
    mask-image: linear-gradient(180deg, transparent 0,
        #000 10%, #000 90%, transparent 100%);
}
/* **白区不做边缘渐隐，铺满整块。** 上面那道 mask 是为暗区做的：暗材质在近黑底上
   淡出淡入看不出来，而一条生硬的截断线会很显眼。白区的取舍正好相反 ——
   浅材质在白底上表现为**一层暖色偏移**，把它在上下 10% 渐隐掉，结果是一大片平坦的
   白上出现一条从奶油色渐变到冷白的横带。实测：右侧干净纸面从 y=80 到 680 是
   亮度 232~239 / R−B +5~+8（有材质），到 y=880 变成 243 / R−B −0.7（没材质）——
   大面积平色上的这种缓变，眼睛最容易挑出来，而它并没有换来什么：
   区块边界那里本来就有 `includes/edge.html` 的过渡件在接。 */
.nf-light.surf-weave::before,
.nf-light.surf-hatch::before,
.nf-light.surf-via::before {
    -webkit-mask-image: none;
    mask-image: none;
}
.surf-weave > *, .surf-hatch > *, .surf-via > * { position: relative; z-index: 1; }

/* 裸基板：换成真实材质照片（`img/tex-fr4-weave.webp`，512² 可四方连续，30KB）。
   原来是两道相互垂直的 CSS 斜纹 —— 那是「示意」，这是「材质」，静止时的厚度差别
   全在这里。母图与加工流程见 `scripts/build_artwork.py` 与 `docs/美术资产清单.md`。

   两条要记住的：
   - **平铺尺寸给 256px**，不是图的原始 512 —— 512 平铺时织纹太大，读成布料而不是
     玻纤；缩到一半才是「显微镜下的基板」那个尺度。
   - `opacity` 仍然由上面那条共用规则控制（0.5 上下），照片不需要再压暗；
     压得太狠就退回成一片黑，等于没加。 */
.surf-weave::before {
    background-image: url("../img/tex-fr4-weave.30d4854265bb.webp");
    background-size: 256px 256px;
    background-repeat: repeat;
}
/* 白区上照片材质会变成一块脏斑（它是暗材质），所以浅底退回原来的 CSS 斜纹。
   `background-size`/`repeat` 也要一起还原 —— 只换 image 的话会继承上面那条
   给照片定的 256px 平铺，虽然渐变看起来没差别，但这条规则就不再是自洽的了。 */
.nf-light.surf-weave::before {
    background-size: auto;
    background-image:
        repeating-linear-gradient(45deg,
            var(--surf-weave-line) 0 1px, transparent 1px var(--surf-weave-cell)),
        repeating-linear-gradient(-45deg,
            var(--surf-weave-line) 0 1px, transparent 1px var(--surf-weave-cell));
}

/* 覆铜区：暗区换成真实材质照片（`img/tex-etched-copper.webp`，512² 可四方连续，
   56KB）。和 `.surf-weave` 同一个做法、同一个理由 —— CSS 斜纹是「示意」，
   照片是「材质」，静止时的厚度差别全在这里。

   平铺给 **320px** 而不是织纹那档 256px：蚀刻纹本身比玻纤布粗一档，
   256px 平铺时那些走向会密到读成噪点；320px 才看得出「铜面被蚀刻过」。 */
.surf-hatch::before {
    background-image: url("../img/tex-etched-copper.1e47eeddac50.webp");
    background-size: 320px 320px;
    background-repeat: repeat;
}
/* 白区换**浅底版**（`img/tex-copper-light.webp`，512² 可四方连续）。
   暗区那张铺到 #f7f8fa 上是一块脏斑（它是暗材质），所以两边各用一张 ——
   和 `.surf-weave` 那里「白区退回 CSS 斜纹」不同，这里白区有专门的材质。

   为什么白区值得单独做一张：改版之后白区一直是**全站最平的地方** ——
   一块 #f7f8fa 的纯色加几条几乎看不见的 CSS 细线，静止时没有任何材质信息，
   而白区在首页占了四段。

   平铺给 256px：母图的纹理是大尺度的流动走向，512 平铺时那些波纹会大到读成
   「布料花纹」；缩到一半才是「纸面/板面的肌理」那个尺度。 */
.nf-light.surf-hatch::before {
    background-image: url("../img/tex-copper-light.b316ab198301.webp");
    background-size: 256px 256px;
    background-repeat: repeat;
}

/* 过孔阵列：钻孔的金属环。中心要留空 —— 填实了就变成一片装饰圆点，
   而过孔的识别特征恰恰是那个孔 */
.surf-via::before {
    background-image:
        radial-gradient(circle at center,
            transparent 0 calc(var(--surf-via-dot) - .7px),
            var(--surf-via-ring) calc(var(--surf-via-dot) - .7px) var(--surf-via-dot),
            transparent var(--surf-via-dot));
    background-size: var(--surf-via-cell) var(--surf-via-cell);
}

/* 丝印刻度：贴着顶边的一道虚线标尺，用于区块分界 —— 比一条实线有工艺感。
   这一条**不占 ::before**（只有 1px 高、不需要渐隐），所以可以和别的表面叠。 */
.surf-silk {
    background-image:
        repeating-linear-gradient(90deg,
            var(--surf-silk-line) 0 6px, transparent 6px 14px);
    background-size: 100% 1px;
    background-repeat: no-repeat;
    background-position: 0 0;
}

/* 白区（`.nf-light`）上要换成深色，否则浅底上等于没有 */
.nf-light {
    --surf-weave-line: rgba(14, 17, 22, .04);
    --surf-hatch-line: rgba(201, 138, 61, .1);
    --surf-via-ring: rgba(13, 169, 205, .16);
    --surf-silk-line: rgba(14, 17, 22, .18);
}

/* ---------- 颗粒（grain） ----------
   纯平的黑是「薄」的一大来源：屏幕上一块完全均匀的 #050506 没有任何材质信息。
   一层极淡的噪声就能让它读起来像「涂了阻焊的板子」而不是「一块色块」。

   用 SVG 的 feTurbulence 内联成 data URI，而不是贴一张 PNG：
     - 任意 DPR 都清晰，不像位图会在 2x 屏上发虚
     - 一个请求都不多，且改强度只要改一个数
     - 确定性的，不会每次构建都变
   挂法是 `::after` 覆盖层而不是 background —— 这样它能叠在任何已有底纹之上，
   而且**不会给元素本身添 background-image**，于是 check_a11y.py 那套「逐层
   向上找不透明背景」的对比度计算不受影响（伪元素不在那条链上）。 */
/* 噪声图本身做成令牌，而不是写死在 `.grain` 里：core.css 的组件
   （`.page-hero` 等）要在自己的 `::after` 上复用同一份噪声，
   而 tokens.css 不该知道那些组件的名字（CSS 三层的分工）。
   一份图、两处用，改强度或密度只动这里。 */
:root {
    --grain-strength: .05;
    --grain-cell: 160px;
    --grain-img: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)'/%3E%3C/svg%3E");
}

.grain { position: relative; }
.grain::after {
    content: "";
    position: absolute;
    inset: 0;
    pointer-events: none;
    z-index: 0;
    opacity: var(--grain-strength);
    background-size: var(--grain-cell) var(--grain-cell);
    background-image: var(--grain-img);
}
/* 颗粒在最底层，内容要浮在它上面 */
.grain > * { position: relative; z-index: 1; }

/* ============================================================
   区块过渡件（edge）
   ------------------------------------------------------------
   黑区与白区之间原来全是硬切 —— 首页有六处，而全站唯一的过渡件是页脚那道波。
   一刀切本身没错（「屏幕亮起/熄灭」是刻意的叙事），但六处一模一样的一刀切
   就成了单调。

   母题取**邮票孔**：拼板上那排半圆断桥孔，是一块板从另一块上掰下来的地方。
   语义正好是「分界」，形状又是纯几何的，所以能精确生成、任意宽度不变形。

   实现要点：
   - 挂在**下面那个区块**里（`position: absolute; top: 0`），因为
     `.nf-section` 已经有 `position: relative` 和 `overflow: hidden`，
     不需要额外的定位上下文，也不会溢出到别处。
   - 孔的颜色是**上面那个区块的底色**（`--edge-from`），这样看起来才像
     「上面那块板咬下来一排孔」，而不是「下面这块板长了一排点」。
     用方向类给值，不要在模板里写内联颜色 —— 颜色一律走令牌。
   - `background-size` 的宽度就是孔距，高度就是整条的高度；用 `radial-gradient`
     而不是 SVG，因为 SVG 要 `preserveAspectRatio="none"` 拉伸才能铺满任意宽度，
     而那会把圆孔压成椭圆（页脚那道波是纯 stroke 曲线，拉伸看不出来，孔会）。
   ============================================================ */
:root {
    /* 孔距要比孔径大不了多少，才读得出「一排连着的断桥孔」；
       拉宽到 26px 时会变成城堡齿（castellation），是另一种工艺、另一种意思。 */
    --edge-bite-pitch: 17px;
    --edge-bite-r: 4.5px;
}

/* 特异性刻意写成两级（`.edge.edge`）。原因是一整类同特异性的规则会把它按掉：
   `.nf-scope > *`（core.css）、`.surf-weave|hatch|via > *`、`.grain > *`
   都写着 `position: relative; z-index: 1`，用途是把内容提到材质层之上。
   它们和单个 `.edge` 同为 (0,1,0)，而 core.css 在 tokens.css 之后加载 ——
   于是挂了 `.nf-scope` 的区块里，过渡件的 position 被改回 relative、掉进常规流，
   被区块的 padding-top 推到区块**内部**。孔照样画得好好的，所以这不是「不见了」
   而是「跑到区块中间去了」：分镜 02 那块的 padding-top 正好 240px
   （`.nf-bridge + .nf-section`），整条孔就落在大数字上方一片黑里，不报错。
   `check_hero.py` 的「每条都压在自己区块的顶边上」钉住这件事。 */
.edge.edge {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    pointer-events: none;
    z-index: 2;               /* 压过表面（0）与内容（1）*/
}

/* 邮票孔：一排半圆，圆心落在分界线上，所以只露出下半个圆。
   实心到透明之间留 .6px 过渡 —— 写 `0 99%, transparent 100%` 的话，
   半径只有 4.5px 时那个 1% 不到 0.05px，浏览器没有抗锯齿的余地，
   半圆会被光栅化成方齿（放大 6 倍一眼就看出来）。 */
.edge-bite {
    height: var(--edge-bite-r);
    background-image: radial-gradient(circle var(--edge-bite-r) at 50% 0,
        var(--edge-from) 0 calc(var(--edge-bite-r) - .6px),
        transparent var(--edge-bite-r));
    background-size: var(--edge-bite-pitch) 100%;
    background-repeat: repeat-x;
}

/* 方向：孔的颜色 = 上一个区块的底色 */
.edge-from-dark { --edge-from: var(--black); }
.edge-from-light { --edge-from: var(--paper); }

/* 高对比偏好下把材质全部摘掉：这些层的存在意义是「质感」，
   而对高对比用户来说它们只是干扰前景的噪声。
   过渡件保留 —— 它标的是结构（区块边界），不是装饰。 */
@media (prefers-contrast: more) {
    .surf-weave, .surf-hatch, .surf-via, .surf-silk { background-image: none; }
    .grain::after { display: none; }
}
