/* ============================================================
   components.css — 组件层
   全部只消费 tokens.css / themes.css 的语义变量,
   不出现任何硬编码色值与尺寸。
   类名前缀 vx- , 避免与宿主项目冲突。
   ============================================================ */

/* ------------------------------------------------------------
   布局容器
   ------------------------------------------------------------ */
.vx-container {
  width: 100%;
  max-width: var(--content-max);
  margin-inline: auto;
  padding-inline: var(--page-gutter);
}

.vx-section {
  --pad-y: var(--sp-2000);
  position: relative;
  padding-block: var(--pad-y);
  background: var(--bg-base);
  isolation: isolate;
}

.vx-section--tight { --pad-y: var(--sp-1200); }
.vx-section--flush { --pad-y: 0px; }

/* 斜切分区: 顶边不是水平的, 而是斜着切过去。
   原站用它做分区过渡, 斜度取 4vw(宽屏) 与 6vw(窄屏) 两档 —— 这是装饰艺术
   味道的来源之一, 也最容易被忽略: 只要顶边是水平的, 整体就会退回普通网页。

   三件事必须同时做, 少一件就会露馅:
     负 margin  让分区往上"咬"进前一区, 咬掉的高度正好等于斜切高度;
     加 padding 把内容推回原位, 否则内容会跟着上移;
     scroll-margin 让锚点跳转落点不被斜边盖住。
   --angled 顶边左高右低(左上角贴顶, 右上角下沉),
   --angled-alt 顶边左低右高(反过来), 交替使用形成锯齿节奏。 */
.vx-section--angled,
.vx-section--angled-alt {
  --angle-h: calc(var(--angle-cut) * 1vw);
  margin-top: calc(var(--angle-h) * -1);
  scroll-margin-top: calc(var(--angle-h) * -1);
  /* --pad-y 由 .vx-section 提供; 带上回退值, 这样单独用 --angled 也不会塌 */
  padding-top: calc(var(--pad-y, var(--sp-2000)) + var(--angle-h));
}

.vx-section--angled     { clip-path: polygon(0 0, 100% var(--angle-h), 100% 100%, 0 100%); }
.vx-section--angled-alt { clip-path: polygon(0 var(--angle-h), 100% 0, 100% 100%, 0 100%); }

/* 窄屏加大斜度, 与原站一致(实测: 桌面 --avvi50:4, 移动 --avvi50:6)。
   反直觉但正确 —— vw 是宽度的百分比, 窄屏上 1vw 的绝对像素小得多,
   4vw 在 390px 上只有 15.6px, 斜边几乎看不出来; 放到 6vw 才有同等观感。 */
@media (max-width: 767.98px) {
  .vx-section--angled,
  .vx-section--angled-alt { --angle-cut: var(--angle-cut-mobile); }
}

/* 分区衔接: 上一区的底色渐隐进下一区, 避免硬切 */
.vx-section--blend::before {
  content: "";
  position: absolute;
  inset-inline: 0;
  top: 0;
  height: var(--sp-2000);
  background: linear-gradient(to bottom, var(--scrim), transparent);
  pointer-events: none;
  z-index: -1;
}

.vx-stack > * + * { margin-top: var(--sp-400); }
.vx-stack--tight > * + * { margin-top: var(--sp-200); }
.vx-stack--loose > * + * { margin-top: var(--sp-800); }

/* ------------------------------------------------------------
   栅格 —— 命名列线
   ------------------------------------------------------------
   原站的布局骨架不是"12 等分然后数格子", 而是给关键位置起了名字:

     main-start ┃ 1fr ┃ partial-start ┃ … ┃ mid ┃ … ┃ partial-end ┃ 1fr ┃ main-end

   于是布局意图直接写成 `grid-column: main` / `partial-start / span 8`
   / `9 / main-end`, 而不是一串会随断点失效的数字。桌面 12 列、
   移动 4 列, 名字不变 —— 换断点只改列数, 所有引用自动跟随, 这是
   命名列线相对数字列号的全部价值。

   用法:
     <div class="vx-grid">
       <div class="vx-col-main">通栏内容</div>
       <div class="vx-col-partial">两侧内缩</div>
       <div class="vx-col-half-l">左半</div><div class="vx-col-half-r">右半</div>
     </div>
   ------------------------------------------------------------ */
.vx-grid {
  --grid-cols: 12;
  --grid-half: calc(var(--grid-cols) / 2);
  display: grid;
  gap: var(--grid-gap, var(--sp-300));
  padding-inline: var(--page-gutter);
  grid-template-columns:
    [main-start] 1fr
    [partial-start] repeat(calc(var(--grid-half) - 1), 1fr)
    [mid]          repeat(calc(var(--grid-half) - 1), 1fr)
    [partial-end] 1fr
    [main-end];
}

/* 移动端 4 列。列数一变, --grid-half 与两段 repeat 自动跟着算,
   命名线的位置随之收拢, 引用命名线的元素不用改任何一行。 */
@media (max-width: 767.98px) { .vx-grid { --grid-cols: 4; } }

.vx-col-main    { grid-column: main; }
.vx-col-partial { grid-column: partial-start / partial-end; }
.vx-col-half-l  { grid-column: main-start / mid; }
.vx-col-half-r  { grid-column: mid / main-end; }

/* 通栏出血: 用负外边距抵消栅格自身的页边距, 让媒体铺到屏幕边缘 */
.vx-col-full {
  grid-column: main;
  margin-inline: calc(var(--page-gutter) * -1);
}

/* 数字跨度。桌面按 12 列写, 移动端自动退成整行 —— 否则 span 6
   落在 4 列栅格上会溢出。 */
.vx-col-2 { grid-column: span 2; }
.vx-col-3 { grid-column: span 3; }
.vx-col-4 { grid-column: span 4; }
.vx-col-5 { grid-column: span 5; }
.vx-col-6 { grid-column: span 6; }
.vx-col-8 { grid-column: span 8; }
@media (max-width: 767.98px) {
  .vx-col-2, .vx-col-3, .vx-col-4,
  .vx-col-5, .vx-col-6, .vx-col-8 { grid-column: main; }
}

/* 分幕栅格 —— 滚动叙事的骨架
   原站给行也起了名字: [intro-start] … [title-start] … [intro-end] …
   [title-end], 而且这组行名直接对应 JS 的 intro / outro 状态机
   (ScrollTrigger 的 onLeave 里切幕)。比列名那组更要紧: 列名管的是
   "东西放哪", 行名管的是"这一幕从哪开始到哪结束"。

   intro 区占满一屏减去标题高度, 标题区紧随其后, 于是标题总是恰好
   停在首屏底边 —— 滚动第一下, 标题就位, 这个时机是设计出来的。 */
.vx-act {
  display: grid;
  width: 100%;
  grid-template-columns: 1fr;
  grid-template-rows:
    [intro-start] calc(100lvh - var(--act-title-h, 12rem))
    [title-start] var(--act-title-h, 12rem)
    [intro-end]   min-content
    [title-end];
}
.vx-act__intro { grid-row: intro-start / intro-end; grid-column: 1; }
.vx-act__title { grid-row: title-start / title-end; grid-column: 1; }

/* 自动填充栅格 —— 卡片墙这类"不在乎落在第几列"的场合用它,
   跟上面的命名栅格是两套, 不要混用在同一个容器上。 */
.vx-grid-auto {
  display: grid;
  gap: var(--sp-600);
  grid-template-columns: repeat(auto-fit, minmax(min(20rem, 100%), 1fr));
}

/* ------------------------------------------------------------
   表面 (elevation)
   4 层 189° 渐变 —— 不是 180°。1° 的偏斜让大色块看起来像被光照过,
   而不是被程序填充的。这是原站最容易被忽略、也最难自己想到的细节。
   ------------------------------------------------------------ */
.vx-surface   { background-image: var(--surface-0); }
.vx-surface-1 { background-image: var(--surface-1); }
.vx-surface-2 { background-image: var(--surface-2); }
.vx-surface-3 { background-image: var(--surface-3); }

/* ------------------------------------------------------------
   光晕 —— 整套设计的视觉签名
   自顶部中央发散的三色径向渐变。放在任何容器里当背景光源。
   ------------------------------------------------------------ */
.vx-glow {
  position: relative;
  isolation: isolate;
}

.vx-glow::before {
  content: "";
  position: absolute;
  inset-inline: 0;
  top: 0;
  height: 60%;
  background: var(--glow);
  opacity: var(--glow-opacity, 0.55);
  filter: blur(var(--blur-bloom));
  pointer-events: none;
  z-index: -1;
}

.vx-glow--soft  { --glow-opacity: 0.3; }
.vx-glow--vivid { --glow-opacity: 0.85; }

/* 光晕描边: 用同一套渐变给元素镶一道会发光的边 */
.vx-glow-edge {
  position: relative;
  background:
    linear-gradient(var(--bg-raised), var(--bg-raised)) padding-box,
    var(--glow) border-box;
  border: var(--stroke-thin) solid transparent;
}

/* ------------------------------------------------------------
   噪点纹理
   叠在任何渐变面上, 消除色带。默认 3% 不透明度 —— 肉眼几乎看不见,
   但关掉之后大面积渐变会立刻显出一圈圈的台阶。
   容器需要能容纳绝对定位子元素 (本类已自带 position: relative)。
   ------------------------------------------------------------ */
.vx-noise { position: relative; }

.vx-noise::after {
  content: "";
  position: absolute;
  inset: 0;
  background-image: var(--noise-texture);
  opacity: var(--noise-opacity);
  pointer-events: none;
  z-index: 0;
}

/* 整页噪点: 挂在 body 上, 覆盖全视口且不随滚动移动 */
.vx-noise-page::after {
  content: "";
  position: fixed;
  inset: 0;
  background-image: var(--noise-texture);
  opacity: var(--noise-opacity);
  pointer-events: none;
  z-index: var(--z-overlay);
}

/* ------------------------------------------------------------
   辉光
   文字用 text-shadow, 图形用 drop-shadow, 都是双层叠加。
   ------------------------------------------------------------ */
.vx-glow-text          { text-shadow: var(--glow-text); }
.vx-glow-text--bright  { text-shadow: var(--glow-text-bright); }
.vx-glow-text--strong  { text-shadow: var(--glow-text-strong); }
.vx-glow-drop          { filter: var(--glow-drop); }
.vx-glow-drop--strong  { filter: var(--glow-drop-strong); }
.vx-glow-drop--light   { filter: var(--glow-drop-light); }
.vx-glow-drop--fluid   { filter: var(--glow-drop-fluid); }
.vx-glow-text--gold    { text-shadow: var(--glow-text-gold); }

/* ------------------------------------------------------------
   渐变文字
   ------------------------------------------------------------
   把渐变裁进字形里: 背景铺渐变, 再用 background-clip: text 只保留
   字形覆盖的部分, 同时把字身填充设成透明。

   关键在渐变的**刻度**。--vx-text-gradient 默认取主题的 --glow ——
   跟页面顶部那束光是同一个渐变, 所以标题看起来像是被那束光照亮的,
   而不是自己发着另一种颜色。整页色彩能统一, 这一步出力最大。

   .vx-text-sunset 换用视口刻度的日落渐变 (刻度是 vh 不是元素自身):
   同一段文字在页面里的位置不同, 取到的色段就不同 —— 上方偏金,
   下方偏紫。多个标题各取一段, 合起来像共用一片天空。

   注意: 文字变透明后, 选中高亮会一起消失, 所以 ::selection 要把
   填充色改回实色, 否则用户拖选时看到的是一片空白。
   ------------------------------------------------------------ */
.vx-text-gradient {
  background-image: var(--vx-text-gradient, var(--glow));
  -webkit-background-clip: text;
  background-clip: text;
  -webkit-text-fill-color: transparent;
  color: var(--text-hi);           /* 不支持 background-clip 时的回退 */
  filter: var(--vx-text-gradient-adjust, none);
}

/* 这套渐变是为深色底调的 —— 直接放到浅底上, 渐变的亮端 (金 / 浅粉)
   会糊在背景里。亮色模式下整体压暗提饱和, 保住色相与可读性。
   两条选择器的分工与 tokens.css 里的明暗继承一致。 */
[data-theme][data-mode="light"] .vx-text-gradient,
:where([data-mode="light"]) [data-theme]:not([data-mode="dark"]) .vx-text-gradient {
  --vx-text-gradient-adjust: brightness(0.68) saturate(1.25);
}
.vx-text-gradient::selection {
  -webkit-text-fill-color: var(--text-hi);
  background: var(--accent);
}

.vx-text-sunset { --vx-text-gradient: var(--gradient-sunset); }

/* ------------------------------------------------------------
   染色层 (mix-blend-mode)
   ------------------------------------------------------------
   盖在图片/视频上的一层纯色, 用 color 混合模式合成:
   保留下层的**明暗**, 换掉它的**色相与饱和度**。
   效果是任意来源的素材——不同摄影师、不同色温、不同年代——都被
   拉到同一个主题色系里, 整页看起来像一次拍摄的。

   单纯盖一层半透明色做不到这件事: 那会把暗部一起提亮, 图片发灰。
   ------------------------------------------------------------ */
/* 这里是组件层唯一一处直接引用色阶索引的地方, 是有意的例外:
   染色层染的是**素材**(照片/视频), 不是界面元素。语义色会随明暗模式
   反转, 而同一张照片在亮色页面里也不该被染成另一个色调 —— 它要的是
   "本主题的中间调色相", 这在两种模式下是同一个值。所以这里必须绕开
   语义层, 直接取色阶。别的组件不要照抄这条。 */
.vx-tint {
  position: absolute;
  inset: 0;
  z-index: 1;
  pointer-events: none;
  background: var(--vx-tint-color, var(--tone-8));
  mix-blend-mode: color;
}
.vx-tint--soft   { opacity: 0.6; }
.vx-tint--accent { --vx-tint-color: var(--accent); }
/* luminosity 反过来: 保留下层颜色、换掉明暗, 用于把彩色照片压成单色底 */
.vx-tint--mono   { mix-blend-mode: luminosity; }

/* 混合模式在 Windows 强制配色下会产生不可预期的结果, 直接关掉 */
@media (forced-colors: active) { .vx-tint { display: none; } }

/* ------------------------------------------------------------
   排版
   ------------------------------------------------------------ */
.vx-display {
  font-family: var(--font-display);
  font-size: var(--text-hero);
  font-weight: var(--weight-black);
  line-height: var(--leading-tight);
  letter-spacing: var(--tracking-display);
  text-transform: uppercase;
  color: var(--text-hi);
  margin: 0;
}

/* 展示字填充光晕渐变 —— hero 主标题的常用处理 */
.vx-display--glow {
  background: var(--glow);
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
}

.vx-label {
  font-family: var(--font-body);
  font-size: var(--text-xs);
  font-weight: var(--weight-bold);
  letter-spacing: var(--tracking-label);
  text-transform: uppercase;
  color: var(--text-low);
  display: inline-flex;
  align-items: center;
  gap: var(--sp-100);
}

.vx-label--accent { color: var(--accent); }

/* 编号小标签, 原站用于章节序号 */
.vx-label--indexed::before {
  content: attr(data-index);
  font-family: var(--font-mono);
  padding: var(--sp-25) var(--sp-100);
  border: var(--stroke-thin) solid var(--line-base);
  border-radius: var(--radius-50);
  color: var(--text-mid);
}

.vx-lead {
  font-size: var(--text-lg);
  line-height: var(--leading-body);
  color: var(--text-mid);
  max-width: var(--measure);
}

.vx-muted { color: var(--text-low); }
.vx-hi    { color: var(--text-hi); }

/* 排版节奏: 标题与其上下文之间的默认间距。
   展示字行高是 100%, 不给默认间距的话标题会和正文粘在一起。 */
.vx-label + :where(h1, h2, h3, h4, .vx-display) { margin-top: var(--sp-200); }
:where(h1, h2, h3, h4, .vx-display) + :where(.vx-lead, p) { margin-top: var(--sp-300); }

.vx-divider {
  height: var(--stroke-thin);
  border: 0;
  margin: 0;
  background: linear-gradient(to right, transparent, var(--line-base), transparent);
}

/* ------------------------------------------------------------
   导航 —— 玻璃态
   初始透明浮在 hero 上, 滚动后由 motion.js 加 is-scrolled 变成毛玻璃。
   ------------------------------------------------------------ */
.vx-nav {
  position: fixed;
  inset-inline: 0;
  top: 0;
  z-index: var(--z-nav);
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--sp-400);
  padding: var(--sp-300) var(--page-gutter);
  background: transparent;
  border-bottom: var(--stroke-thin) solid transparent;
  transition:
    background var(--dur-medium) var(--ease-standard),
    border-color var(--dur-medium) var(--ease-standard),
    padding var(--dur-medium) var(--ease-standard),
    backdrop-filter var(--dur-medium) var(--ease-standard);
}

.vx-nav.is-scrolled {
  padding-block: var(--sp-200);
  background: var(--glass);
  border-bottom-color: var(--line-subtle);
  -webkit-backdrop-filter: blur(var(--blur-glass)) saturate(1.4);
  backdrop-filter: blur(var(--blur-glass)) saturate(1.4);
}

/* 滚动方向感知: 向下滚时收起, 向上滚时回来 */
.vx-nav.is-hidden { transform: translateY(-100%); }
.vx-nav { transition-property: background, border-color, padding, backdrop-filter, transform; }

.vx-nav__brand {
  font-family: var(--font-display);
  font-size: var(--text-h6);
  font-weight: var(--weight-black);
  letter-spacing: var(--tracking-display);
  text-transform: uppercase;
  color: var(--text-hi);
  text-decoration: none;
}

.vx-nav__links {
  display: flex;
  align-items: center;
  gap: var(--sp-500);
  list-style: none;
  margin: 0;
  padding: 0;
}

.vx-nav__link {
  font-size: var(--text-sm);
  font-weight: var(--weight-bold);
  letter-spacing: var(--tracking-label);
  text-transform: uppercase;
  text-decoration: none;
  /* 导航常浮在媒体素材上, 用最亮档 + 透明度压弱,
     这样无论底下是亮画面还是暗画面都读得出来 */
  color: var(--text-hi);
  opacity: 0.72;
  position: relative;
  padding-block: var(--sp-50);
  transition: opacity var(--dur-fast) var(--ease-standard);
}

.vx-nav__link::after {
  content: "";
  position: absolute;
  left: 0;
  bottom: 0;
  width: 100%;
  height: var(--stroke-medium);
  /* 用 --sync-accent 而不是 --accent: 滚过分区交界时它是连续插值的,
     导航下划线因此不会跟着分区"啪"地换色。见 tokens.css 第 16 节。 */
  background: var(--sync-accent, var(--accent));
  transform: scaleX(0);
  transform-origin: right;
  transition: transform var(--dur-base) var(--ease-emphasis);
}

.vx-nav__link:hover { opacity: 1; }
.vx-nav__link:hover::after { transform: scaleX(1); transform-origin: left; }

@media (max-width: 767.98px) {
  .vx-nav__links { display: none; }
}

/* ------------------------------------------------------------
   Hero —— 全屏沉浸
   用 lvh 而非 dvh/vh: 见 tokens.css 第 10 节。地址栏收起后正好铺满,
   且高度是固定值 —— hero 里通常有视差和入场动画, 高度一跳就全乱。
   ------------------------------------------------------------ */
.vx-hero {
  position: relative;
  min-height: 100lvh;
  display: grid;
  align-items: end;
  overflow: clip;
  isolation: isolate;
  background: var(--bg-inset);
}

.vx-hero__media {
  position: absolute;
  inset: 0;
  z-index: -2;
}

/* 视差图层。
   位移与放大必须严格绑定, 否则会露边 —— 这是最容易写错的地方:
   位移幅度 A = 0.5 × 高度 × strength, 放大留出的余量 = 0.5 × 高度 × (scale − 1)。
   要让位移始终被余量吃住, 只能取 scale = 1 + strength。两个数不能各写各的。

   原站实测两个视差元素恰好都满足这条恒等式:
     ty ±21.12px, overscale 1.0733  →  1 + 2×(21.12/576)  = 1.0733 ✓
     ty ±18.78px, overscale 1.0882  →  1 + 2×(18.78/426)  = 1.0882 ✓
   位移比例本身可调(两者分别是高度的 3.67% 与 4.41%), 但 scale 永远跟着它走。 */
.vx-hero__media > img,
.vx-hero__media > video,
.vx-parallax {
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: var(--focal, 50% 50%);
  /* --parallax(像素位移) 与 --parallax-strength 均由 motion.js 写入 */
  transform: translate3d(0, var(--parallax, 0px), 0)
             scale(calc(1 + var(--parallax-strength, 0.0733)));
  will-change: transform;
}

/* ------------------------------------------------------------
   裁切焦点 —— 原站铺得最密、却最没人提的一项

   object-fit: cover 会按容器比例裁掉图的两边, 默认从正中裁。
   人物偏左、地平线偏低的照片一裁就毁。原站的做法是给**每一张图**
   单独指定裁切焦点: 实测 81 条 object-position 声明、53 个不同取值,
   精确到 0.5%(`42.5% center`、`47.5% center`) —— 这是一张张手调出来的。

   所以模板能给的不是一组值, 而是一个接口。原站自己也是变量驱动的
   (`object-position: var(--jlzlp20)`, 取值 `57% center` / `center 10%`)。

     <img class="vx-parallax" style="--focal: 32% center" …>

   两个配套细节, 少了任何一个都会露馅:

   1) 一组叠放图层要共享同一个焦点, 否则切换素材时画面会跳。
      原站用 object-position: inherit —— 父元素定一次, 子图层全跟上。
      .vx-focal 就是这个父元素。
   2) 焦点可以过渡。原站在 hero 图上写了 transition: object-position .2s,
      hover 时挪动裁切窗口 = 一次极廉价的"镜头微移", 不动 transform、
      不触发重排, 也不会像 scale 那样把边缘顶出去。
   ------------------------------------------------------------ */
.vx-focal {
  object-position: var(--focal, 50% 50%);
}

.vx-focal > img,
.vx-focal > picture > img,
.vx-focal > video {
  object-fit: cover;
  object-position: inherit;
  width: 100%;
  height: 100%;
}

/* 焦点过渡: 挂在容器上, hover 时改 --focal 即可 */
.vx-focal--shift,
.vx-focal--shift > img,
.vx-focal--shift > picture > img {
  transition: object-position var(--dur-fast) var(--ease-in-out);
}

/* 压暗层: 保证任何素材上文字都可读 */
.vx-hero__scrim {
  position: absolute;
  inset: 0;
  z-index: -1;
  background:
    linear-gradient(to top, var(--scrim-solid) 0%, var(--scrim-soft) 35%, transparent 70%),
    linear-gradient(to bottom, var(--scrim-soft) 0%, transparent 40%);
  pointer-events: none;
}

.vx-hero__glow {
  position: absolute;
  inset-inline: 0;
  top: 0;
  height: 70%;
  z-index: -1;
  background: var(--glow);
  opacity: 0.4;
  mix-blend-mode: screen;
  filter: blur(var(--blur-bloom));
  pointer-events: none;
}

.vx-hero__body {
  position: relative;
  padding: var(--sp-2000) var(--page-gutter) var(--sp-1200);
  width: 100%;
  max-width: var(--content-max);
  margin-inline: auto;
}

.vx-hero__scroll-cue {
  position: absolute;
  bottom: var(--sp-500);
  left: 50%;
  translate: -50% 0;
  display: grid;
  justify-items: center;
  gap: var(--sp-100);
  color: var(--text-low);
  animation: vx-bob 2.15s var(--ease-in-out) infinite;
}

@keyframes vx-bob {
  0%, 100% { transform: translateY(0); opacity: 0.6; }
  50%      { transform: translateY(8px); opacity: 1; }
}

/* 横屏手机: 全屏 hero 会把内容挤没, 改为按内容高度排 */
@media (max-width: 767.98px) and (orientation: landscape) {
  .vx-hero { min-height: auto; padding-block: var(--sp-1600); }
  .vx-hero__scroll-cue { display: none; }
}

/* 超宽屏: 限制媒体高度, 否则 21:9 上人物会被拉到画面外 */
@media (min-aspect-ratio: 2.25) {
  .vx-hero { min-height: 120lvh; }
}

/* ------------------------------------------------------------
   按钮
   ------------------------------------------------------------ */
.vx-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--sp-100);
  padding: var(--sp-200) var(--sp-500);
  border: var(--stroke-thin) solid transparent;
  border-radius: var(--radius-pill);
  font-family: var(--font-body);
  font-size: var(--text-sm);
  font-weight: var(--weight-bold);
  letter-spacing: var(--tracking-label);
  text-transform: uppercase;
  text-decoration: none;
  cursor: pointer;
  white-space: nowrap;
  transition:
    background-color var(--dur-fast) var(--ease-standard),
    border-color var(--dur-fast) var(--ease-standard),
    color var(--dur-fast) var(--ease-standard),
    transform var(--dur-fast) var(--ease-standard);
}

.vx-btn:active { transform: translateY(1px); }

.vx-btn--primary {
  background: var(--accent);
  color: var(--text-invert);
}
.vx-btn--primary:hover { background: var(--accent-hover); }

.vx-btn--outline {
  background: transparent;
  border-color: var(--line-strong);
  color: var(--text-hi);
}
.vx-btn--outline:hover {
  border-color: var(--accent);
  color: var(--accent);
}

.vx-btn--ghost {
  background: var(--glass-soft);
  color: var(--text-hi);
  -webkit-backdrop-filter: blur(var(--blur-subtle));
  backdrop-filter: blur(var(--blur-subtle));
}
.vx-btn--ghost:hover { background: var(--glass-hover); }

.vx-btn--lg { padding: var(--sp-300) var(--sp-800); font-size: var(--text-md); }

/* ------------------------------------------------------------
   卡片 / 媒体
   ------------------------------------------------------------ */
.vx-card {
  position: relative;
  overflow: hidden;
  border-radius: var(--radius-200);
  border: var(--stroke-thin) solid var(--line-subtle);
  background-image: var(--surface-1);
  transition:
    transform var(--dur-medium) var(--ease-standard),
    border-color var(--dur-medium) var(--ease-standard);
}

.vx-card:hover {
  transform: translateY(-4px);
  border-color: var(--line-base);
}

.vx-card__body { padding: var(--sp-500); }
.vx-card__title { font-size: var(--text-h5); margin-bottom: var(--sp-150); }

.vx-media {
  position: relative;
  overflow: hidden;
  aspect-ratio: var(--media-ratio, 16 / 9);
  background: var(--bg-inset);
}

.vx-media > img,
.vx-media > video {
  width: 100%;
  height: 100%;
  object-fit: cover;
  transition: transform var(--dur-slow) var(--ease-standard);
}

.vx-card:hover .vx-media > img,
.vx-card:hover .vx-media > video { transform: scale(1.05); }

/* 原站的比例分布是 1:1 最多、其次 9/16 竖版, 16/9 反而少 ——
   营销素材以竖版和方版为主, 因为要同时喂给手机端和社交平台。 */
.vx-media--square   { --media-ratio: 1 / 1; }
.vx-media--vertical { --media-ratio: 9 / 16; }
.vx-media--portrait { --media-ratio: 3 / 4; }
.vx-media--wide     { --media-ratio: 21 / 9; }

/* ------------------------------------------------------------
   滚动洗刷 (scrubbing) —— 背景里"像视频一样的东西"
   视频并不播放: 滚动进度被直接映射到时间轴 currentTime。
   往下滚画面前进, 往上滚画面倒退, 停下画面就停住。
   原站还有一条 canvas 序列帧分支用于低端设备, 本模板走 video 这条,
   并在触屏 / 减弱动效 / 视频不可用时自动退回 poster 静态图。

   结构:
     <div class="vx-scrub" style="--scrub-height: 300svh">
       <div class="vx-scrub__stage">
         <video class="vx-scrub__media" src="..." poster="..."
                muted playsinline preload="auto" aria-hidden="true"></video>
       </div>
     </div>
   --scrub-height 决定"洗完整段视频"需要滚多远, 越大越慢。
   原站按断点调这个值(实测同一元素在各断点取 110/120/130/140/160svh),
   窄屏行程短一些 —— 手机上滑同样距离花的力气更大。
   ------------------------------------------------------------ */
.vx-scrub {
  position: relative;
  /* svh 与原站一致。裸 vh 在规范上等同 lvh 也不会跳, 用 svh 是为了让
     "这是固定行程"的意图写在单位上, 且与舞台的 lvh 形成明确的一大一小。 */
  height: var(--scrub-height, 300svh);
}

/* 舞台钉在视口上, 外层高度提供滚动行程。
   height 必须用 lvh —— 这里是 dvh 危害最直接的地方:
   舞台高度参与"滚过了多少"的计算, 地址栏一收放, 分母就变, 进度会肉眼可见地跳。 */
.vx-scrub__stage {
  position: sticky;
  top: 0;
  height: 100lvh;
  overflow: clip;
  isolation: isolate;
}

.vx-scrub__media {
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: var(--scrub-focus, 50% 50%);
}

/* 覆盖在洗刷画面上的文字层 */
.vx-scrub__overlay {
  position: absolute;
  inset: 0;
  display: grid;
  align-content: end;
  padding: var(--sp-1200) var(--page-gutter);
  z-index: 1;
}

/* 降级: 触屏与减弱动效下不做 seek, 直接显示 poster */
.vx-scrub.is-static { height: auto; }
.vx-scrub.is-static .vx-scrub__stage { position: relative; height: auto; }
.vx-scrub.is-static .vx-scrub__media { aspect-ratio: var(--media-ratio, 16 / 9); height: auto; }

@media (prefers-reduced-motion: reduce) {
  .vx-scrub { height: auto; }
  .vx-scrub__stage { position: relative; height: auto; }
}

/* ------------------------------------------------------------
   运镜
   ------------------------------------------------------------
   实测原站的镜头推进有三层, 按重要性排:

   1. 元素级线性缩放 —— 主运镜。canvas 元素的 CSS scale 从 1.000 走到
      1.100, 行程约 2100 / 2560px, **严格线性**(实测 R²=0.999999,
      GSAP 侧统一 ease:"none")。滚动位置 1:1 绑定动画进度, 所以来回滚
      画面严格跟手可逆 —— 这是"运镜感"而不是"入场动画感"的根本原因。
   2. 画面内部再推 —— canvas 重绘时内容自身再放大约 4~6%。
   3. hero 横向段 —— scale 1.05→1.0 的同时 translateX −5%→+5%,
      即"拉远的同时横移", 车窗外掠过的那种感觉。

   关键否定事实: 全站 `perspective` / `transform-style` / `preserve-3d`
   / `matrix3d` 出现次数**全部为 0**。纵深不是 3D 变换做的, 是靠方向性
   mask 渐变伪造的(见下面的 .vx-depth-fade)。

   下面给两种实现:
     .vx-dolly--linear   照抄原站: 单层线性缩放
     .vx-dolly           分层透视: 本模板的扩展, 见该处注释
   ------------------------------------------------------------ */

/* ---- 照抄原站的线性缩放 ---- */
.vx-dolly--linear .vx-dolly__layer {
  --k: calc(1 + var(--zoom, 0.1) * var(--dolly-progress));
  --dolly-base: 1;
}

/* ---- 分层透视 (本模板扩展) ----
   说明来源: 透视公式 k = 焦距/(焦距 + z) 与焦距 5000 确实取自原站 JS,
   但那段代码是 canvas 里的**图层/碎片渲染器**, 并不是驱动页面镜头的
   那条通路 —— 页面镜头如上所述是线性缩放。把这套投影搬到 CSS 上是
   本模板自己的做法, 请不要当成"原站同款"。

   它解决的是原站没解决的问题: 线性缩放整张图没有纵深, 而分层之后
   z 小(近)的层 k 大动得多、z 大(远)的层几乎不动, 纵深来自这个差值。

   用法:
     <div class="vx-dolly" style="--dolly-travel: 900">
       <img class="vx-dolly__layer" style="--z: 200"  src="前景.png">
       <img class="vx-dolly__layer" style="--z: 2400" src="远景.png">
     </div>
   --dolly-travel 是滚动走完全程时镜头推进的距离(z 减少量)。
   ------------------------------------------------------------ */
.vx-dolly {
  position: relative;
  overflow: clip;
  isolation: isolate;
  aspect-ratio: var(--media-ratio, 16 / 9);
  --dolly-focal: 5000;
  --dolly-travel: 900;
  --dolly-progress: 0;        /* 由 motion.js 写入 0→1 */
}

/* 图层必须绝对定位 —— 原站 .itiptr5 也是 position:absolute; inset:0。
   换成 grid 叠放会出事: <img> 的固有高度会把 grid 行撑开, 把容器的
   aspect-ratio 顶掉, 比例设了等于没设(实测 21/9 被拉回图片自身的 16/9)。
   绝对定位让图层脱离流, 容器高度才由 aspect-ratio 说了算。 */
.vx-dolly__layer {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  /* 当前深度 = 静止深度 − 已推进的距离 */
  --dolly-z: calc(var(--z, 0) - var(--dolly-travel) * var(--dolly-progress));
  /* 透视系数。除数下限钳在 1 上, 免得镜头穿过图层时除以 0 或负数 */
  --k: calc(var(--dolly-focal) / max(1, calc(var(--dolly-focal) + var(--dolly-z))));
  /* 深度补偿: 静止时 base × k 恰好等于 1, 每层都正好铺满。
     没有它, z 大的层一上来就被透视缩到 0.6 倍, 直接露出容器底色。
     补上之后, 层与层的差别只体现在"推进时各自放大多少", 这才是想要的。 */
  --dolly-base: calc((var(--dolly-focal) + var(--z, 0)) / var(--dolly-focal));
  transform:
    translate3d(calc(var(--x, 0px) * var(--k)), calc(var(--y, 0px) * var(--k)), 0)
    scale(calc(var(--scale, var(--dolly-base)) * var(--k)));
  transform-origin: var(--dolly-origin, 50% 50%);
  will-change: transform;
}

/* 越远的层越淡, 模拟大气透视 —— 原站在 canvas 里用 globalAlpha 做同一件事 */
.vx-dolly__layer--hazy { opacity: calc(0.35 + 0.65 * var(--k)); }

/* ------------------------------------------------------------
   景深渐隐 —— 原站"纵深感"的真正来源
   ------------------------------------------------------------
   既然全站 perspective 为 0, 那种"从雾里驶出 / 驶入雾中"的空间感
   完全是这条方向性 mask 渐变做的: 图像朝行进方向的一侧被渐隐掉,
   而且三个色标随滚动整体前移 ——

     硬边界   30% → 0%
     中间调   72% → 42%   (不透明度固定 0.3)
     消失点  100% → 80%

   注意它跟 .vx-fade-left/right 不是一回事: 那两个是静态边缘渐隐,
   这个是随滚动推移的, 观感上是"雾在后退", 不是"边上有一层雾"。

   用法:
     <div class="vx-dolly vx-dolly--linear">
       <img class="vx-dolly__layer vx-depth-fade" style="--depth-dir: 90deg" src="…">
     </div>
   --depth-dir 取 90deg(向右行进) 或 -90deg(向左行进)。
   ------------------------------------------------------------ */
.vx-depth-fade {
  /* 同一条 mask 推移, 两种驱动都能用: 横向画廊写 --h-progress, 纵向运镜写
     --dolly-progress。嵌套回退让这个类不必分裂成两个。 */
  --depth-p: var(--h-progress, var(--dolly-progress, 0));
  --depth-hard: calc((30 - 30 * var(--depth-p)) * 1%);
  --depth-mid:  calc((72 - 30 * var(--depth-p)) * 1%);
  --depth-end:  calc((100 - 20 * var(--depth-p)) * 1%);
  -webkit-mask-image: linear-gradient(var(--depth-dir, 90deg),
                        #000 var(--depth-hard),
                        #0000004d var(--depth-mid),
                        #0000 var(--depth-end));
  mask-image: linear-gradient(var(--depth-dir, 90deg),
                        #000 var(--depth-hard),
                        #0000004d var(--depth-mid),
                        #0000 var(--depth-end));
}

/* 减弱动效: 停在初始帧, 不做推进 */
@media (prefers-reduced-motion: reduce) {
  .vx-dolly { --dolly-progress: 0 !important; }
  .vx-dolly__layer { will-change: auto; }
}

/* ------------------------------------------------------------
   边缘渐隐 —— 这才是原站 mask-image 的主要用途
   把元素的一侧柔化到透明, 让内容"融"进背景而不是被硬边切断。
   实测的渐隐曲线是三段式: 实色到 60%, 30% 灰到 80%, 全透明到 100%。
   ------------------------------------------------------------ */
.vx-fade-bottom {
  -webkit-mask-image: linear-gradient(#000 60%, #0000004d 80%, #0000 100%);
  mask-image: linear-gradient(#000 60%, #0000004d 80%, #0000 100%);
}

.vx-fade-top {
  -webkit-mask-image: linear-gradient(#0000 0%, #000 15%, #000 100%);
  mask-image: linear-gradient(#0000 0%, #000 15%, #000 100%);
}

.vx-fade-right {
  -webkit-mask-image: linear-gradient(90deg, #000 30%, #0000004d 72%, #0000 100%);
  mask-image: linear-gradient(90deg, #000 30%, #0000004d 72%, #0000 100%);
}

.vx-fade-left {
  -webkit-mask-image: linear-gradient(-90deg, #000 30%, #0000004d 72%, #0000 100%);
  mask-image: linear-gradient(-90deg, #000 30%, #0000004d 72%, #0000 100%);
}

.vx-fade-radial {
  -webkit-mask-image: radial-gradient(50% 50%, #000 0%, #0000 100%);
  mask-image: radial-gradient(50% 50%, #000 0%, #0000 100%);
}

/* 半渐隐: 到底也不全透明, 停在 25% 左右。
   用在下方还有内容要透出来、但不希望这一层彻底消失的场合。 */
.vx-fade-bottom-half {
  -webkit-mask-image: linear-gradient(#000 86%, #00000080 94%, #00000040 100%);
  mask-image: linear-gradient(#000 86%, #00000080 94%, #00000040 100%);
}

/* ------------------------------------------------------------
   阶梯式硬切 —— 原站 mask 里最不像 mask 的一种

   前面那些都是渐变。这一类**没有渐变**: 顶部一条带子恒定压到 80% 不透明
   (#000c), 到了边界一刀切回全实色。做出来的效果是"内容从固定导航底下穿过时
   先淡一档", 而不是渐渐消失 —— 渐变会让人以为内容到此为止, 硬切则明确告诉
   眼睛"它还在, 只是被压住了"。

   原站四个变体, 带宽都取自间距 token, 上下可以各来一条。
   --step-top / --step-bottom 控制带宽, 给 0 就等于不要那一侧。
   ------------------------------------------------------------ */
.vx-fade-step-top {
  /* 默认给一个通用带宽; 实际用时通常设成固定导航的高度 */
  --step-top: var(--sp-1000);
  -webkit-mask-image: linear-gradient(180deg,
    #000c 0, #000c var(--step-top), #000 var(--step-top), #000 100%);
  mask-image: linear-gradient(180deg,
    #000c 0, #000c var(--step-top), #000 var(--step-top), #000 100%);
}

.vx-fade-step-both {
  --step-top: var(--sp-800);
  --step-bottom: var(--sp-600);
  -webkit-mask-image: linear-gradient(180deg,
    #000c 0, #000c var(--step-top),
    #000 var(--step-top), #000 calc(100% - var(--step-bottom)),
    #000c calc(100% - var(--step-bottom)), #000c 100%);
  mask-image: linear-gradient(180deg,
    #000c 0, #000c var(--step-top),
    #000 var(--step-top), #000 calc(100% - var(--step-bottom)),
    #000c calc(100% - var(--step-bottom)), #000c 100%);
}

/* ------------------------------------------------------------
   斜向压角 —— 也是不到全透明的一类

   三条实测取值都只走两段就停住(末端 20~45% 不透明), 角度是 216 / ±246 度
   这种"不整"的数。它们不是要让边缘消失, 是把某个角压暗, 给叠在上面的
   文字腾出对比度 —— 比加一个方块压暗层干净得多, 因为压的是形状本身。
   ------------------------------------------------------------ */
.vx-fade-corner {
  -webkit-mask-image: linear-gradient(246deg, #000 30%, #00000059 72%);
  mask-image: linear-gradient(246deg, #000 30%, #00000059 72%);
}

.vx-fade-corner-alt {
  -webkit-mask-image: linear-gradient(-246deg, #000 30%, #00000073 72%);
  mask-image: linear-gradient(-246deg, #000 30%, #00000073 72%);
}

.vx-fade-corner-soft {
  -webkit-mask-image: linear-gradient(216deg, #000 55%, #0003 75%);
  mask-image: linear-gradient(216deg, #000 55%, #0003 75%);
}

/* ------------------------------------------------------------
   跑马灯
   两份内容首尾相接做无缝循环; 悬停暂停。
   ------------------------------------------------------------ */
.vx-marquee {
  display: flex;
  overflow: hidden;
  user-select: none;
  gap: var(--sp-600);
  /* 两端渐隐, 避免内容硬生生被裁断。
     渐隐宽度用**固定长度**而不是百分比 —— 原站就是这么写的
     (linear-gradient(90deg, transparent 0%, black var(--间距token), …))。
     用百分比的话, 窄屏上 8% 只有二三十像素、宽屏上却有一百多，
     同一套设计在不同屏幕上的"化开程度"会不一致。 */
  --marquee-fade: var(--sp-800);
  -webkit-mask-image: linear-gradient(90deg, transparent 0%,
    #000 var(--marquee-fade), #000 calc(100% - var(--marquee-fade)), transparent 100%);
  mask-image: linear-gradient(90deg, transparent 0%,
    #000 var(--marquee-fade), #000 calc(100% - var(--marquee-fade)), transparent 100%);
}

.vx-marquee__track {
  display: flex;
  flex-shrink: 0;
  align-items: center;
  gap: var(--sp-600);
  min-width: 100%;
  animation: vx-scroll var(--marquee-duration, 40s) linear infinite;
}

.vx-marquee:hover .vx-marquee__track { animation-play-state: paused; }

@keyframes vx-scroll {
  from { transform: translateX(0); }
  to   { transform: translateX(calc(-100% - var(--sp-600))); }
}

.vx-marquee__item {
  font-family: var(--font-display);
  font-size: var(--text-h3);
  font-weight: var(--weight-black);
  letter-spacing: var(--tracking-display);
  text-transform: uppercase;
  color: var(--text-low);
  white-space: nowrap;
}

/* ------------------------------------------------------------
   滚动揭示 (配合 motion.js)
   默认状态即最终状态 —— 万一 JS 没加载, 内容照样可读可见。
   只有当 motion.js 挂上 vx-motion-ready 后才启用隐藏态。
   ------------------------------------------------------------ */
.vx-motion-ready .vx-reveal {
  opacity: 0;
  transform: translateY(var(--reveal-distance, 32px));
  transition:
    opacity var(--dur-slower) var(--ease-entrance),
    transform var(--dur-slower) var(--ease-entrance);
  transition-delay: var(--reveal-delay, 0s);
}

.vx-motion-ready .vx-reveal.is-visible {
  opacity: 1;
  transform: none;
}

/* mask 揭示: 内容像被一道光扫出来。
   说明来源: 这是本模板自行设计的手法, 不是原站的做法。
   原站的 mask-image 用于边缘渐隐 (见 .vx-fade-*), 入场动效则由
   GSAP ScrollTrigger 的 pin + scrub 驱动。此处保留是因为它用纯 CSS
   就能拿到接近的观感, 但不要把它当成"原站同款"。 */
.vx-motion-ready .vx-reveal--mask {
  opacity: 1;
  transform: none;
  -webkit-mask-image: linear-gradient(to top, transparent 0%, #000 0%);
  mask-image: linear-gradient(to top, transparent 0%, #000 0%);
  -webkit-mask-size: 100% 300%;
  mask-size: 100% 300%;
  -webkit-mask-position: 0 100%;
  mask-position: 0 100%;
  transition: -webkit-mask-position var(--dur-cinematic) var(--ease-entrance),
              mask-position var(--dur-cinematic) var(--ease-entrance);
  transition-delay: var(--reveal-delay, 0s);
}

.vx-motion-ready .vx-reveal--mask.is-visible {
  -webkit-mask-position: 0 0;
  mask-position: 0 0;
}

.vx-motion-ready .vx-reveal--scale {
  opacity: 0;
  transform: scale(0.94);
}
.vx-motion-ready .vx-reveal--scale.is-visible { opacity: 1; transform: none; }

/* 子元素依次入场 */
.vx-stagger > * { --reveal-delay: calc(var(--i, 0) * 0.08s); }

/* ------------------------------------------------------------
   页脚
   ------------------------------------------------------------ */
.vx-footer {
  padding-block: var(--sp-1200) var(--sp-600);
  background-image: var(--surface-0);
  border-top: var(--stroke-thin) solid var(--line-subtle);
}

.vx-footer__grid {
  display: grid;
  gap: var(--sp-800);
  grid-template-columns: repeat(auto-fit, minmax(min(14rem, 100%), 1fr));
  padding-bottom: var(--sp-800);
}

.vx-footer__title {
  font-size: var(--text-xs);
  font-weight: var(--weight-bold);
  letter-spacing: var(--tracking-label);
  text-transform: uppercase;
  color: var(--text-low);
  margin-bottom: var(--sp-300);
}

.vx-footer__list { list-style: none; margin: 0; padding: 0; }
.vx-footer__list li + li { margin-top: var(--sp-150); }

.vx-footer__list a {
  font-size: var(--text-sm);
  text-decoration: none;
  color: var(--text-mid);
  transition: color var(--dur-fast) var(--ease-standard);
}
.vx-footer__list a:hover { color: var(--accent); }

.vx-footer__bottom {
  display: flex;
  flex-wrap: wrap;
  gap: var(--sp-300);
  justify-content: space-between;
  align-items: center;
  padding-top: var(--sp-500);
  border-top: var(--stroke-thin) solid var(--line-subtle);
  font-size: var(--text-xs);
  color: var(--text-low);
}

/* ------------------------------------------------------------
   工具类
   ------------------------------------------------------------ */
.vx-center      { display: grid; place-items: center; }
.vx-row         { display: flex; align-items: center; gap: var(--sp-300); flex-wrap: wrap; }
/* .vx-sr-only 与 .vx-skip-link 定义在 tokens.css 的无障碍小节, 不在此重复 */

/* 文本截断。行数用变量给, 所以同一个类能截 1 行也能截 4 行:
     <p class="vx-clamp" style="--clamp: 3">
   -webkit-box 这套写法看着过时, 但它是目前唯一被全部主流浏览器
   支持的多行截断方案, 原站用的也是它。 */
.vx-clamp {
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: var(--clamp, 2);
  line-clamp: var(--clamp, 2);
  overflow: hidden;
}

/* 单行截断加省略号 */
.vx-truncate {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* text-wrap: balance 让多行标题各行长度尽量均匀, 不会出现
   "第二行只剩一个字"。只对短文本有效 (浏览器一般限制在 6 行内),
   长正文用 pretty —— 它只管避免末行孤字, 开销小得多。 */
.vx-balance { text-wrap: balance; }
.vx-pretty  { text-wrap: pretty; }

/* ------------------------------------------------------------
   扫光 (shimmer) —— 原站的加载动画
   ------------------------------------------------------------
   一条斜切的模糊光带斜向划过图形, 同时图形底色明暗起伏。
   三条 keyframes 并行, 周期都是 2.15s:
     位置  从 (-40%,-40%) 划到 (80%,105%), 92% 处到位, 剩下 8% 停住
           —— 这个停顿是节奏的关键, 匀速循环会显得像进度条卡住
     底色  0~22% 暗, 42% 亮, 92% 回暗
     透明  同上, 0.2 ↔ 1

   光带本身: 25% 宽 60% 高, skew(-45deg, 45deg) 切成平行四边形,
   blur(5px) 化开边缘。父元素上的 mask 把整套效果裁进图形轮廓里。

   用法 —— 裁成自己的标志:
     <div class="vx-shimmer" style="--shimmer-mask: url(logo.svg)"></div>
   不给 mask 就是个矩形光块, 可直接当骨架屏。
   ------------------------------------------------------------ */
.vx-shimmer {
  position: relative;
  width: var(--shimmer-size, 6rem);
  height: var(--shimmer-size, 6rem);
  overflow: hidden;
  background: var(--line-strong);
  animation: vx-shimmer-tone var(--dur-cinematic) var(--ease-in-out) infinite both;
  -webkit-mask-image: var(--shimmer-mask, linear-gradient(#000, #000));
  mask-image: var(--shimmer-mask, linear-gradient(#000, #000));
  -webkit-mask-size: contain;
  mask-size: contain;
  -webkit-mask-repeat: no-repeat;
  mask-repeat: no-repeat;
  -webkit-mask-position: center;
  mask-position: center;
}

.vx-shimmer::after {
  content: "";
  position: absolute;
  top: -40%;
  left: -40%;
  width: 25%;
  height: 60%;
  background: linear-gradient(232deg, var(--accent) 20%, var(--accent-alt) 70%);
  filter: blur(5px);
  transform: skew(-45deg, 45deg);
  animation: vx-shimmer-sweep var(--dur-cinematic) var(--ease-sweep) infinite both;
}

@keyframes vx-shimmer-sweep {
  0%   { top: -40%; left: -40%; }
  92%  { top: 80%;  left: 105%; }
  100% { top: 80%;  left: 105%; }
}

/* 原站把"底色起伏"和"辉光起伏"分给了两个叠放的元素。
   合并到一个元素上会让图形在暗档几乎隐形 —— 加载指示器消失是要命的,
   所以这里底色照原样起伏, 辉光交给下面的 ::before 单独承担。

   用 --line-strong → --text-low 这两个语义色而不是写死色阶索引:
   暗色模式下是 由暗变亮, 亮色模式下自动变成 由浅变深 —— 两种模式下
   "扫过时更醒目"这件事都成立。写死索引的话亮色模式会反过来。 */
@keyframes vx-shimmer-tone {
  0%,  22%  { background-color: var(--line-strong); }
  42%       { background-color: var(--text-low); }
  92%, 100% { background-color: var(--line-strong); }
}

/* 辉光层: 与光带同相位地亮起来, 对应原站那层带 drop-shadow 的背衬 */
.vx-shimmer::before {
  content: "";
  position: absolute;
  inset: 0;
  background: var(--accent);
  animation: vx-shimmer-bloom var(--dur-cinematic) var(--ease-in-out) infinite both;
}

@keyframes vx-shimmer-bloom {
  0%,  22%  { opacity: 0; }
  42%       { opacity: 0.18; }
  92%, 100% { opacity: 0; }
}

/* 骨架屏: 同一条光带, 铺在等待中的内容块上 */
.vx-skeleton {
  position: relative;
  overflow: hidden;
  border-radius: var(--radius-100);
  background: var(--glass-soft);
  min-height: 1em;
}
.vx-skeleton::after {
  content: "";
  position: absolute;
  inset: 0;
  transform: translateX(-100%);
  background: linear-gradient(90deg, transparent, var(--glass-hover), transparent);
  animation: vx-skeleton-sweep 1.6s var(--ease-in-out) infinite;
}
@keyframes vx-skeleton-sweep {
  100% { transform: translateX(100%); }
}

.vx-skeleton--text  { height: 1em; margin-block: 0.25em; }
.vx-skeleton--title { height: 2em; width: 60%; }
.vx-skeleton--media { aspect-ratio: var(--media-ratio, 16 / 9); min-height: 0; }

/* 全屏加载遮罩, 层级取自 z token 里专门留给加载态的两档 */
.vx-loading {
  position: fixed;
  inset: 0;
  z-index: var(--z-loading);
  display: grid;
  place-items: center;
  background: var(--bg-base);
  pointer-events: none;
  transition: opacity var(--dur-slow) var(--ease-standard),
              visibility var(--dur-slow) var(--ease-standard);
}
.vx-loading[hidden],
.vx-loading.is-done {
  opacity: 0;
  visibility: hidden;
  display: grid;              /* 覆盖 [hidden] 的 display:none, 好让它淡出 */
}

/* 减弱动效: 循环动画一律停掉, 但保留可见的静态形态 —— 直接
   隐藏会让用户不知道页面还在加载。 */
@media (prefers-reduced-motion: reduce) {
  .vx-shimmer,
  .vx-shimmer::before,
  .vx-shimmer::after,
  .vx-skeleton::after { animation: none; }
  .vx-shimmer::after,
  .vx-shimmer::before { opacity: 0; }
  .vx-shimmer         { background: var(--text-low); }
}

/* ------------------------------------------------------------
   横向滚动画廊

   原站实测有 14 处 `horizontal: true` 的 ScrollTrigger, 但它们**不是**
   页面级的横向叙事 —— 每一处都带 `scroller: dialogContentRef.current`,
   也就是说横向滚动发生在一个对话框/灯箱的内容区里, 不是整页横过来。
   这个区别很重要: 整页横向滚动会破坏浏览器的滚动位置恢复、锚点跳转和
   键盘翻页; 局部横向容器不会。

   容器负责滚动, motion.js 给每个 item 写 --h-progress(0 → 1):
     0 = item 左边缘刚到容器右边缘(即将进入)
     1 = item 右边缘刚离开容器左边缘(已经走完)
   下面的效果类都读这一个变量, 可以任意叠加。
   ------------------------------------------------------------ */
.vx-hscroll {
  display: flex;
  gap: var(--hscroll-gap, var(--sp-400));
  overflow-x: auto;
  overflow-y: hidden;
  /* 横向滚到头时不要把整页也一起带走 */
  overscroll-behavior-x: contain;
  scroll-snap-type: x mandatory;
  padding-inline: var(--page-gutter);
  scrollbar-width: none;
}

.vx-hscroll::-webkit-scrollbar { display: none; }

.vx-hscroll__item {
  flex: 0 0 var(--hscroll-item, min(78vw, 44rem));
  scroll-snap-align: center;
  position: relative;
  overflow: clip;
  isolation: isolate;
  aspect-ratio: var(--media-ratio, 16 / 9);
  border-radius: var(--radius-300);
}

/* 图层要绝对定位, 否则 <img> 的固有高度会顶掉 aspect-ratio
   (跟 .vx-dolly__layer 是同一个坑) */
.vx-hscroll__item > img,
.vx-hscroll__item > video,
.vx-hscroll__item > picture > img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* ------------------------------------------------------------
   横移裁切窗口 —— 横向滚动的第二条通路

   原站在这些横向 ScrollTrigger 里做的不是移动元素, 而是移动**裁切窗口**:

     const { start, end, posY } = getParallaxObjectPosition(img, 0.98);
     gsap.fromTo(img, { objectPosition: `${start}% ${posY}%` },
                      { objectPosition: `${end}% ${posY}%`, ease: "none", … });

   比 transform 位移强在: 窗口永远在图**内部**平移, 不存在露边问题,
   也就不需要 overscale, 图不会被放大导致糊。观感是摄影机横摇(pan),
   而不是一张图整体在滑。

   注: getParallaxObjectPosition 的实现不在抓到的 chunk 里(被打包成
   压缩符号), 下面的取值是从调用面推出来的语义 —— 参数 0.95/0.98 作为
   "用掉多少可移动范围", 两端各留 (1-ratio)/2。这一点是推断, 不是原文。
   ------------------------------------------------------------ */
.vx-pan {
  --pan-range: var(--pan-ratio, 0.98);
  --pan-from: calc((1 - var(--pan-range)) / 2 * 100%);
  --pan-to:   calc(100% - var(--pan-from));
  object-fit: cover;
  object-position:
    calc(var(--pan-from) + (var(--pan-to) - var(--pan-from)) * var(--h-progress, 0))
    var(--pan-y, 50%);
}

/* 反向横摇 —— 相邻两项一正一反, 观感比全部同向丰富 */
.vx-pan--reverse {
  object-position:
    calc(var(--pan-to) - (var(--pan-to) - var(--pan-from)) * var(--h-progress, 0))
    var(--pan-y, 50%);
}

/* ------------------------------------------------------------
   横向 hero 位移 —— 原站 .itiptr5 的配方

   这条走的是另一条路: 不放大, 而是让图**比容器宽**
   (width:auto + height:100% + max-width:200%), 再横移。
   同轴反向 —— 位移 −5% → +5% 的同时 scale 1.05 → 1.00,
   两者叠加后边缘始终在容器外, 所以也不会露边。
   ------------------------------------------------------------ */
.vx-hpan {
  --hp: var(--h-progress, 0);
  width: auto;
  max-width: 200%;
  height: 100%;
  object-fit: cover;
  transform:
    translate3d(calc(-1 * var(--hpan-shift, 5%) + var(--hpan-shift, 5%) * 2 * var(--hp)), 0, 0)
    scale(calc(var(--hpan-scale, 1.05) - (var(--hpan-scale, 1.05) - 1) * var(--hp)));
  will-change: transform;
}

/* 进出淡入淡出。原站的 start/end 是 "left right+=20%" / "left right-=20%",
   即在进入和离开的两端各留 20% 行程做过渡, 中段全不透明。 */
.vx-hfade {
  opacity: var(--h-visible, 1);
  /* 这个 opacity 每帧都在变, 值得提前提层。原站 31 条 will-change 里
     最常见的组合就是 transform, opacity(10 次) —— 但那是分散在整站的,
     不是堆在一处。will-change 每加一条就多一个合成层, 只给**持续跟随
     滚动**的元素加; 一次性入场动画(.vx-reveal)不加, 否则动画演完了
     合成层还赖着不走。 */
  will-change: opacity;
}

/* 不再变化时撤掉提层 —— 留着只是白占显存 */
[data-experience="lofi"] .vx-hfade,
.vx-hscroll.is-static .vx-hfade { will-change: auto; }

@media (prefers-reduced-motion: reduce) {
  .vx-hfade { will-change: auto; }
}
