/* ═══════════════════════════════════════════════════════════
   portal.css — 玄关页（落地 → 序引 → 双门幕 + 年龄门）
   接在 Vertex 模板（themes → tokens → components）之上的覆盖层。
   规则：不改 vendor 三件、不新增 vx- 前缀类、只消费语义变量。
   架构依据：_build/research/architecture-decision.md（v2 已拍板）
   门前段（年龄门之前）只允许 L0 资源：logo + 排印 + CSS 光晕。
   ═══════════════════════════════════════════════════════════ */

/* ── 占位（业务事实待品牌方，红线 3） ── */
.ph { color: var(--text-low); font-size: calc(12px * var(--ratio, 1)); letter-spacing: .04em; }
.ph::before { content: "〔待品牌方：" attr(data-ph) "〕"; }
/* i18n 接上后由 applyLang 写 data-ph-label（前缀跟语言走，占位项名保持中文——
   读者是品牌方）。没有 JS 时上面那条就是终态，两者中文写法刻意一致，不会闪一下 */
.ph[data-ph-label]::before { content: "〔" attr(data-ph-label) "〕"; }

/* ═══════════ 拍 1 · 落地 ═══════════ */

.portal-arrival { text-align: center; }

.arrival-logo {
  width: clamp(120px, calc(var(--logo-size, 2.1rem) * 5), 210px);
  margin: 0 auto calc(30px * var(--ratio, 1));
}

.arrival-line {
  font-family: var(--font-display);
  font-size: clamp(22px, 3.2vw, 34px);
  font-weight: 400;
  letter-spacing: .3em;
  text-indent: .3em;   /* 抵消末字符字距，视觉居中 */
}

.arrival-sub {
  margin-top: calc(14px * var(--ratio, 1));
  font-size: calc(12px * var(--ratio, 1));
  letter-spacing: .5em;
  text-indent: .5em;
  color: var(--text-mid);
}

/* ── 落地屏的满幅画面（2026-08-08）────────────────────────────
   ⚠ 门前（body.locked）这两层整个不存在：display:none，且 img 的 src 由 portal.js
     在 unlock 时才写。**门前的合规面一个字节没变**，改的只是「过了门之后」。
   为什么要改：年龄门的意义是「过了门就能看」。而在此之前，过完门（以及回访者
   一进站就是 unlocked）落地屏还是那团 CSS 光——门后第一屏与门前长得一模一样，
   一道门白过。真正性张力最强的两张脸躺在 3185px 处，首屏是一间空房。
   满幅只在门后成立：门前语法（研究 §2.9）禁止满幅，这里靠 body.locked 隔开。 */
body.locked .arrival-media,
body.locked .arrival-scrim { display: none; }

/* 极缓推近。**动的是外壳不是图**：motion.js 把视差写成 --parallax 变量、
   由 .vx-hero__media > img 的 transform 消费，图自己的 transform 已被占用；
   在外壳上做 scale 两者互不打架（外壳没有 transform 规则）。
   26s 一个来回、幅度 8%——慢到读者说不出画面在动，只觉得这一屏没有停住。 */
.arrival-media {
  animation: arrival-push 26s var(--ease-in-out, ease-in-out) infinite alternate;
}
@keyframes arrival-push {
  from { transform: scale(1.005); }
  to   { transform: scale(1.055); }
}

/* 压暗层：不做中心径向。第一版做了，等于在她脸上糊一块灰——
   画面被压暗的地方恰好是最该被看见的地方，字倒是读出来了，人没了。
   改成**电影海报的排法**：上面留给画面，下面沉成黑地，字落在黑地上。
   顶上那道浅的是给导航的，不是给字的。 */
   ⚠ 顶上那道必须**摊得够长**。第一版 .58→.20 只用了 15% 的高度，落差太陡，
     在照片上现出一条横杠，看起来像导航自己带了个黑底（实测导航在 scrollY=0 是
     全透明的，rgba(0,0,0,0)——那条杠是我这层画出来的，差点错怪了 vendor）。 */
.arrival-scrim {
  background:
    linear-gradient(to bottom, rgb(0 0 0 / .62) 0%, rgb(0 0 0 / .34) 10%, rgb(0 0 0 / .12) 26%, transparent 44%),
    linear-gradient(to top, var(--scrim-solid) 0%, rgb(0 0 0 / .84) 20%, rgb(0 0 0 / .44) 44%, transparent 72%);
}

/* 门后：光晕退到画面之后当氛围，不再是主角（opacity .4 压在照片上会把肤色洗白） */
body.unlocked .portal-arrival .vx-hero__glow { opacity: .16; }

/* 门后：logo 退位。它在门前是这一屏唯一的实心物件，只能当主角；
   一旦画面到场，一枚居中的大金标就落在她锁骨上，成了贴在照片上的贴纸。
   缩小 + 压低不透明度，退回「在场但不抢」。
   ⚠ 不能直接 display:none：portal-hero [2] 有一条「logo 落在 30–60% 高度」的
     版式判据靠它定位，藏掉会让那条断言读到 0% 而变红——那是真判据，不是噪声。 */
body.unlocked .arrival-logo {
  width: clamp(52px, calc(var(--logo-size, 2.1rem) * 2), 78px);
  margin-bottom: calc(26px * var(--ratio, 1));
  opacity: .55;
}

/* 门后：那句问句撤掉粉金渐变，换成近白。
   渐变字在纯黑底上是装饰，压在照片上就变成了「看不清的装饰」——
   照片本身已经有色温，字再带一层色，两个都弱。 */
body.unlocked .arrival-line {
  background-image: none;
  -webkit-text-fill-color: var(--text-hi);
  color: var(--text-hi);
  filter: none;
}

/* 门后：字压在照片上要自带底。只做「贴地阴影」不做发光——
   发光在皮肤高光上会糊成一团。 */
body.unlocked .arrival-line,
body.unlocked .arrival-sub,
body.unlocked .portal-cue .cue-txt {
  text-shadow: 0 1px 3px rgb(0 0 0 / .6), 0 2px 20px rgb(0 0 0 / .8);
}

@media (prefers-reduced-motion: reduce) {
  .arrival-media { animation: none; transform: scale(1.04); }
}

/* 撤掉按钮后，落地屏的排字会掉下来压住底部那个箭头。
   原因不是「居中所以下移」——实测 .vx-hero__body 是**底边钉在屏底**的：
   改 padding-bottom，内容 1:1 跟着上移（108→200 时字块整体上移 92px，箭头纹丝不动）。
   所以按钮腾出的高度不会自动变成留白，得显式把箭头那一块（约 145px）连同呼吸区让出来。
   数值由实测定：pb=108 时副标题与箭头重叠 37px，pb=200 时留出 55px。
   ⚠ 改这个值必须重测——判据是 .arrival-sub 底边与 #cue-down 顶边的间距 ≥40px，
     _build/e2e/portal-hero.mjs 有断言看着。 */
.portal-arrival .vx-hero__body { padding-bottom: calc(180px * var(--ratio, 1)); }

/* ── 落地屏唯一的去处（原「进入」按钮已撤，2026-08-05） ──
   它现在是 <button>，得先把浏览器给按钮的默认外观清掉，
   否则 Safari 自带的背景和内边距会跟 vendor 的 .vx-hero__scroll-cue 定位打架。
   布局（grid / justify-items / gap / 绝对定位 / vx-bob）全在 vendor 那条里，此处不重复。 */
.portal-cue {
  appearance: none;
  background: none;
  border: 0;
  padding: 8px 18px 4px;
  cursor: pointer;
  color: inherit;
  font: inherit;
}

.portal-cue .cue-txt {
  display: block;
  font-size: calc(15.5px * var(--ratio, 1));
  font-weight: 600;
  letter-spacing: .4em;
  text-indent: .4em;   /* 抵消末字符字距，视觉居中 */
  color: var(--text-hi);
  transition: color var(--dur-base, .3s);
}

/* 箭头用描边不用实心：落地屏是门前 L0 段，整屏只有 logo 是实心金，
   再来一个实心块会跟它抢；描边在暗底上够清楚又不夺焦。 */
.portal-cue .cue-arrow {
  display: block;
  width: calc(26px * var(--ratio, 1));
  height: auto;
  margin-top: calc(12px * var(--ratio, 1));
  overflow: visible;
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
  color: var(--text-mid);
  transition: color var(--dur-base, .3s), translate var(--dur-base, .3s);
}

/* 竖杆比箭头头细一档：等粗的话小尺寸下糊成一根棒槌 */
.portal-cue .cue-stem { stroke-width: 1.5; opacity: .75; }

.portal-cue:hover .cue-txt,
.portal-cue:focus-visible .cue-txt { color: var(--gold, #d0a840); }
.portal-cue:hover .cue-arrow,
.portal-cue:focus-visible .cue-arrow { color: var(--text-hi); translate: 0 3px; }

.portal-cue:focus-visible {
  outline: 2px solid var(--gold, #d0a840);
  outline-offset: 6px;
  border-radius: 10px;
}

/* vendor 在横屏手机上把 scroll-cue 整个 display:none——那是它还只是装饰提示时的处置。
   现在它是本屏唯一的点击路径，藏掉等于横屏手机用户只能靠滚动去撞年龄门。
   vendor 不可改，在这里收回来；横屏纵向空间紧，缩一档并改为随内容排。 */
@media (max-width: 767.98px) and (orientation: landscape) {
  .portal-arrival .portal-cue {
    display: grid;
    position: static;
    translate: none;
    margin: 18px auto 0;
  }
  .portal-arrival .portal-cue .cue-arrow { width: 18px; margin-top: 5px; }
  /* 箭头改成随内容排之后就不再压任何东西，上面那块预留反倒成了空洞 */
  .portal-arrival .vx-hero__body { padding-bottom: calc(28px * var(--ratio, 1)); }
}

/* 减弱动效：vx-bob 归 vendor 管，这里只保证悬停位移不再跳 */
@media (prefers-reduced-motion: reduce) {
  .portal-cue .cue-arrow { transition: color var(--dur-base, .3s); }
  .portal-cue:hover .cue-arrow,
  .portal-cue:focus-visible .cue-arrow { translate: none; }
}

/* ═══════════ 拍 2 · 序引 ═══════════ */

.portal-intro { text-align: center; }

.intro-line {
  font-size: clamp(16px, 2vw, 21px);
  font-weight: 300;
  letter-spacing: .22em;
  text-indent: .22em;
  line-height: 2.2;
  color: var(--text-hi);
}
.intro-line--dim { color: var(--text-mid); }

/* ═══════════ 拍 3 · 双人物卡（GTA6 式角色卡） ═══════════ */

/* 未解锁时整段不可见亦不可达（门前 DOM 干净：img 走 data-src 惰性注入）。
   外壳一起藏：.doors-pin 带着 70svh 驻留行程，只藏门不藏壳会在门前留下一段空跑；
   器物段的 -70svh 接力边距在门前同样收回（下面 .portal-pieces 那条）。 */
body.locked .doors-pin { display: none; }
body.locked .portal-pieces { margin-top: 0; }

/* 门的外壳：-80svh 咬进洗刷行程的最后一屏（原值从 .portal-doors 挪来，
   卡片在行程 60% 处入画——转身揭示段（64%-73%）开始时卡片刚冒头）。
   ::after 的 70svh 是门的**驻留行程**：门 sticky 钉住后，外壳还要
   再滚 70svh 才放它走——这段行程就是门「慢慢消失」的淡出窗口，
   器物段用 -70svh 边距同步从门身上滑上来（接力语法与视频→门那一幕相同）。
   ⚠ 行程必须给在**内容盒**里（::after 撑开内容高度），不能用 padding-bottom：
     sticky 的活动范围按祖先**内容盒**算，padding 撑出来的行程钉不住（实测门照滚）。 */
.doors-pin {
  position: relative;
  z-index: 2;
  margin-top: -80svh;
}
.doors-pin::after { content: ""; display: block; height: 70svh; }

/* 门钉在外壳里：滚过门界（门顶到视口顶）后不再被滚走，边驻留边按 --doors-o
   淡出（portal.js 接力驱动写入；--doors-pe 在淡尽后把点击也关掉，免得
   透明的门还拦着器物段的点击）。减动效档退回相对定位，不钉不淡。 */
.portal-doors {
  position: sticky;
  top: 0;
  background: transparent;
  padding-top: 4svh;
  /* 底距不能吃 .vx-section 的 --sp-2000（这一档实测 ~180px）：门底到器物段头
     隔着近半屏的静止空白，器物在余光里不存在（品牌方 2026-08-12 实拍判词
     「注意不到」）。 */
  padding-bottom: clamp(24px, 4.5svh, 48px);
  opacity: var(--doors-o, 1);
  pointer-events: var(--doors-pe, auto);
  /* 擦洗缝（--doors-cut 由 portal.js 写入：器物段顶相对门盒的 px 位置）：
     器物段每往上盖一截，门就在**同一高度**先溶掉一截——接缝就是过渡本身，
     不是两幕各演各的。与视频→门的均匀溶出同宗（咬合+按滚动溶出），
     但手势是门自己的：一条从下往上收的溶解缝。默认 999px（缝在门底之下）＝不裁。 */
  -webkit-mask-image: linear-gradient(to bottom, #000 calc(var(--doors-cut, 999px) - 64px), transparent var(--doors-cut, 999px));
  mask-image: linear-gradient(to bottom, #000 calc(var(--doors-cut, 999px) - 64px), transparent var(--doors-cut, 999px));
  will-change: opacity;
}
@media (prefers-reduced-motion: reduce) {
  .portal-doors { position: relative; }
}

/* 双开门（2026-07-30 改）：两张卡合成**一个物件**，不再是并排两幅。
   为什么这么定：研究 §1.4.1 要「不并排」，理由是同屏两幅互相稀释；但玄关这一屏的
   语义是「选哪一个」，选择天然需要对照——改成上下两满幅，用户为了选上面那个还得滚回去，
   把交互成本加在了叙事收益上。真正该消除的不是「并排」，是「读起来像两个东西」。
   ⇒ 合成一扇双开门：共用门框、中间一道门缝、只有外侧两角有圆角、整体满幅（实测 96.8vw）。
     一个物件 = 一幅画面 ⇒「一屏一幅」在这一屏成立，对照也还在。
     门框与门缝同料（同一层浅色），所以缝看着是门缝不是版式留白。
   尺寸给在门（容器）上而不是给在叶（卡片）上：叶宽由 flex 分配、hover 时会变，
   把比例挂在叶上会让两叶高度不等、整扇门跟着抽搐。
   ⚠ 门高只能写 height，不能用 aspect-ratio + max-height：那组合在高度被 max 夹住时
     会把**宽度也一起缩回去**（实测 1440 档只剩 71.7vw、1920 档 64.5vw，满幅直接落空）。
     72vw 是「两叶各自约 2:3 竖幅」反推的：叶宽≈48vw ⇒ 叶高≈72vw。 */
.portal-doors-grid {
  --door-seam: clamp(3px, .34vw, 6px);
  --door-frame: clamp(3px, .34vw, 6px);
  display: flex;                 /* 用 flex 不用 grid：flex-grow 的过渡到处都稳，
                                    grid-template-columns 的过渡支持面窄一档 */
  align-items: stretch;
  gap: var(--door-seam);
  margin-inline: clamp(10px, 1.6vw, 26px);
  padding: var(--door-frame);
  height: min(86svh, var(--doors-h, 72vw));
  border-radius: calc(var(--radius-xl, 24px) + var(--door-frame));
  background: color-mix(in oklab, var(--text-hi) 6%, transparent);
}

/* 门叶：宽度由 flex 分，高度由门框给。焦点仍是数据接口（--fy 写在 HTML 上） */
.door-card {
  position: relative;
  display: block;
  flex: 1 1 0;
  min-width: 0;
  overflow: hidden;
  isolation: isolate;
  outline: none;
  background: var(--bg-inset);
  transition: flex-grow var(--dur-slow, .5s) var(--ease-out, ease),
              box-shadow var(--dur-slow, .5s) var(--ease-out, ease);
}
/* 圆角只给外侧两角：中缝那侧是直边，两叶合起来才是一个圆角矩形 */
.door-card:first-child { border-radius: var(--radius-xl, 24px) 0 0 var(--radius-xl, 24px); }
.door-card:last-child  { border-radius: 0 var(--radius-xl, 24px) var(--radius-xl, 24px) 0; }

/* hover：这一叶让开、另一叶自动收窄（flex 同一份空间），中缝随之偏移＝门被你推开。
   不再用 translateY 抬起——一叶浮起会立刻把「一个物件」读回「两张卡」。 */
.door-card:hover,
.door-card:focus-visible {
  flex-grow: 1.22;
  z-index: 1;
  box-shadow: 0 0 0 1px color-mix(in oklab, var(--accent) 55%, transparent),
              0 26px 74px -32px var(--accent);
}

/* 中缝收敛：两叶内缘各自压暗，收敛到同一个近黑值，再各透一道本世界的光。
   ── 为什么需要 ──
   两张素材在接缝处一边是暖米色室内墙、一边是冷灰城市窗，明度接近但色温相反。
   硬切时眼睛会拿这两根亮柱直接互比，于是整扇门读成「两张不相干的图拼在一起」，
   那条 5px 暗缝反而像裁切留下的边（实证：/tmp/jb-doors/before-seam.png 100% 原像素）。
   把交界处两侧都压到同一个近黑值，可比较的对象就消失了——这是关键，
   不是「让缝变好看」，是**把色温差最大的地方变成信息量最少的地方**。
   然后在最内缘各留一道主题色微光：两道光夹着那条缝，缝就有了来由，
   读成门缝里漏出来的光。两叶的 --accent 不同（data-theme 各自一套），
   于是中缝天然是「两个世界的光在门缝里相遇」，比统一成一种颜色更贴叙事。
   ── 为什么做在叶上而不是做一个居中的缝元素 ──
   hover 时 flex-grow 变化会让中缝左右平移，居中定位的元素跟不上；
   镜像做在两叶内缘则天然跟着走，一行 JS 都不用。

   ── 为什么光只有一条 2px 发丝，不做成宽晕 ──
   第一版给了 22px 的主题色晕，实测（亮度剖面 `_build/e2e/doors-seam.mjs` + lum 采样）
   接缝处右叶从 197 压到 83 是对的，但那道晕**盖在压暗之上**，把最该收敛的几个像素
   又提回 140——缝变成「亮→更亮→暗→亮」的乱结构，比没做还busy。
   ⇒ 压暗必须独占最后那几十像素，光只留一条压在近黑上的发丝线，
     它才读得出「一条缝里透光」而不是「两张卡各自描了个边」。 */
.door-card::after {
  content: "";
  position: absolute;
  top: 0; bottom: 0;
  width: clamp(72px, 15%, 230px);
  z-index: 2;              /* 压在图与底部 scrim 之上、文字(3)与光线(4)之下 */
  pointer-events: none;
}
/* ::after = 压暗（缓入避免留出可见的"起点线"，最后 30% 急收到近黑） */
.door-card:first-child::after {
  right: 0;
  background: linear-gradient(to right, rgb(0 0 0 / 0) 0%, rgb(0 0 0 / .12) 45%,
    rgb(0 0 0 / .40) 70%, rgb(0 0 0 / .74) 88%, rgb(0 0 0 / .93) 100%);
}
.door-card:last-child::after {
  left: 0;
  background: linear-gradient(to left, rgb(0 0 0 / 0) 0%, rgb(0 0 0 / .12) 45%,
    rgb(0 0 0 / .40) 70%, rgb(0 0 0 / .74) 88%, rgb(0 0 0 / .93) 100%);
}

/* ::before = 缝光本体。单独一层是为了能沿**高度**渐隐——
   一条通高等亮度的线读成 UI 描边（第二版就是这样），中段强、两端隐才读成光。
   背景层做不到这件事：background 的多层无法各自加遮罩，只能整条一起遮。 */
.door-card::before {
  content: "";
  position: absolute;
  top: 0; bottom: 0;
  width: 2px;
  z-index: 3;
  pointer-events: none;
  background: color-mix(in oklab, var(--accent) 64%, transparent);
  -webkit-mask-image: linear-gradient(to bottom, transparent 0%, #000 20%, #000 80%, transparent 100%);
          mask-image: linear-gradient(to bottom, transparent 0%, #000 20%, #000 80%, transparent 100%);
  box-shadow: 0 0 26px color-mix(in oklab, var(--accent) 30%, transparent);
  transition: background var(--dur-slow, .5s) var(--ease-out, ease);
}
.door-card:first-child::before { right: 0; }
.door-card:last-child::before  { left: 0; }
/* 推开哪一叶，哪一叶的缝光就亮起来——门被推动时缝里透进更多光 */
.door-card:hover::before,
.door-card:focus-visible::before { background: color-mix(in oklab, var(--accent) 92%, transparent); }

/* 卡内媒体：dolly 线性推近（滚动经过时画面缓缓推向你，原站运镜同款曲线） */
.card-media {
  position: absolute; inset: 0;
  display: block;
  overflow: hidden;
  /* vendor .vx-dolly 自带 aspect-ratio 16:9 定框——卡片媒体由 inset 拉满，必须归零，
     否则图只占卡片上半截、下半露底色（实测截图：下半几乎全黑的根因） */
  aspect-ratio: auto;
}

/* 焦点纵坐标 --fy 是每张卡的数据（写在 HTML 上），hover 相对上移——换素材只改 --fy */
.card-focal { position: absolute; inset: 0; display: block; --focal: 50% var(--fy, 30%); }

.card-img {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  object-fit: cover;
  /* 焦点由 .vx-focal 的 --focal 继承（§16）；hover 焦点微移 = 最廉价的镜头微移 */
  transition: filter var(--dur-slow, .5s) var(--ease-out, ease),
              object-position .35s var(--ease-out, ease);
  filter: brightness(.86) saturate(.96);
}

.door-card:hover .card-img,
.door-card:focus-visible .card-img { filter: brightness(1) saturate(1); }

/* hover 镜头轻轻抬向脸部（改的是裁切窗口，不动 transform，不会顶出边） */
.door-card:hover .card-focal { --focal: 50% calc(var(--fy, 30%) - 6%); }

/* 卡片底部压暗（模板语义变量：卡片文字对比度的来源） */
.card-scrim {
  position: absolute; inset: 0;
  z-index: 2;
  /* 只压底部文字区，画面主体不蒙灰（全幅 --gradient-scrim 顶部也带 28% 黑，弃用） */
  /* 20% 处原写 .30，副标题「冷的那个。」实测 4.42（门槛 4.5）——差的这一点点
     取决于素材那一块有多亮，换素材就会翻车，所以按「最亮的卡也过」定深度 */
  background: linear-gradient(to top, rgba(0,0,0,.82) 0%, rgba(0,0,0,.52) 22%, transparent 48%);
  pointer-events: none;
}

/* 底缘主题色光线：暗卡上的一道世界之光，hover 变亮 */
.card-glowline {
  position: absolute;
  left: 8%; right: 8%; bottom: 0;
  height: 2px;
  z-index: 4;
  background: var(--accent);
  opacity: .35;
  box-shadow: 0 0 18px var(--accent);
  transition: opacity var(--dur-base, .3s);
}
.door-card:hover .card-glowline { opacity: .9; }

.card-label {
  position: absolute;
  left: 0; right: 0; bottom: 0;
  z-index: 3;
  text-align: center;
  padding: 0 16px clamp(26px, 4.5vh, 46px);
  display: block;
}

.card-name {
  display: block;
  font-family: var(--font-display);
  font-size: clamp(26px, 3.6vw, 44px);
  font-weight: 300;
  letter-spacing: .5em;
  text-indent: .5em;
  text-transform: uppercase;
  color: var(--text-hi);
}

.card-sub {
  display: block;
  margin-top: 10px;
  font-size: calc(12.5px * var(--ratio, 1));
  letter-spacing: .22em;
  text-indent: .22em;
  color: var(--text-mid);
}

.card-enter {
  display: inline-block;
  margin-top: 20px;
  font-size: calc(12px * var(--ratio, 1));
  letter-spacing: .34em;
  text-indent: .34em;
  color: var(--text-hi);
  border-bottom: 1px solid var(--line-base);
  padding-bottom: 4px;
  opacity: .75;
  transition: opacity var(--dur-base, .3s), border-color var(--dur-base, .3s);
}
.door-card:hover .card-enter { opacity: 1; border-color: var(--accent); }

/* 移动端：上下两张 */
@media (max-width: 720px) {
  /* 窄屏放不下双开门（两叶各 ~45vw 会瘦成条）：退回上下两张独立卡。
     一屏一幅在这里本来就成立，门的隐喻交给「推 门 →」那行字担着 */
  .portal-doors-grid {
    flex-direction: column;
    gap: 22px;
    height: auto;
    padding: 0;
    background: none;
    /* 退成卡片就按卡片留边：满幅 + 圆角是混合信号（角上会露底色）。
       门框/门缝那套只在双开门形态下成立 */
    margin-inline: clamp(14px, 4vw, 24px);
  }
  /* flex 必须回到 0 0 auto：列向排布时 flex-basis:0 是**高度**基准，
     配上 flex-grow 会把 aspect-ratio 算出来的高度压成 0（实测两张卡高度 0） */
  .door-card { flex: 0 0 auto; aspect-ratio: var(--card-ratio-mobile, 3 / 4); max-height: 74svh; }
  .door-card:first-child, .door-card:last-child { border-radius: var(--radius-xl, 24px); }
  /* 窄屏没有中缝（两叶上下排、各自独立成卡），内缘收敛与缝光都成了无来由的侧边装饰 */
  .door-card::after, .door-card::before { display: none; }
  .door-card:hover, .door-card:focus-visible { flex-grow: 1; }
}

/* reduced-motion：抬卡与镜头微移一次到位/停用 */
@media (prefers-reduced-motion: reduce) {
  .door-card, .card-img, .card-focal { transition: none; }
}

/* ═══════════ 年龄门 overlay（dvh：贴合此刻可视区） ═══════════ */

#gate {
  position: fixed; inset: 0;
  height: 100dvh;
  z-index: var(--z-modal, 121);
  display: grid;
  place-items: center;
  background: var(--scrim-solid, rgba(4,3,8,.9));
  backdrop-filter: blur(var(--blur-md, 13px));
  -webkit-backdrop-filter: blur(var(--blur-md, 13px));
  opacity: 0;
  visibility: hidden;
  transition: opacity var(--dur-base, .3s) var(--ease-out, ease), visibility 0s var(--dur-base, .3s);
}
#gate.open {
  opacity: 1;
  visibility: visible;
  transition: opacity var(--dur-base, .3s) var(--ease-out, ease);
}

.gate-card {
  width: min(92vw, 470px);
  text-align: center;
  padding: calc(38px * var(--ratio, 1)) calc(30px * var(--ratio, 1)) calc(28px * var(--ratio, 1));
  border: 1px solid var(--line-base);
  border-radius: var(--radius-lg, 16px);
  background: var(--bg-raised);
}

.gate-logo { width: 80px; margin: 0 auto 18px; }

.gate-title {
  font-size: calc(16px * var(--ratio, 1));
  font-weight: 500;
  letter-spacing: .12em;
  color: var(--text-hi);
}

.gate-text {
  margin-top: 12px;
  font-size: calc(13.5px * var(--ratio, 1));
  line-height: var(--leading-body, 1.6);
  color: var(--text-mid);
}

.gate-card .ph { display: block; margin-top: 10px; }

/* 门上的法务告知。刻意比正文小、比说明淡——它必须在，但不该抢走
   「我已年满 18 岁」那个按钮的注意力：门的功能是让人做一个选择，
   不是让人读一段条款。所以字号 12.5px、行高放宽、颜色降一档。 */
.gate-legal {
  margin: 14px 0 0;
  font-size: 12.5px; line-height: 1.75;
  color: var(--text-low, rgba(255,255,255,.55));
  text-align: left;
}
.gate-links {
  margin: 8px 0 0;
  display: flex; gap: 14px; justify-content: center;
  font-size: 12.5px;
}
.gate-links .gate-link {
  color: var(--text-mid, rgba(255,255,255,.72));
  text-decoration: underline; text-underline-offset: 3px;
  transition: color .2s;
}
.gate-links .gate-link:hover { color: var(--text-hi, #fff); }

.gate-actions {
  margin-top: 24px;
  display: flex;
  gap: 12px;
  justify-content: center;
  flex-wrap: wrap;
}

.gate-denied { margin-top: 16px; font-size: calc(13px * var(--ratio, 1)); color: #c86a6a; }
[hidden] { display: none !important; }

.gate-b2b {
  display: inline-block;
  margin-top: 20px;
  font-size: calc(12px * var(--ratio, 1));
  letter-spacing: .08em;
  color: var(--text-low);
  border-bottom: 1px solid var(--line-subtle);
  padding-bottom: 2px;
  text-decoration: none;
}
.gate-b2b:hover { color: var(--text-hi); }

/* ═══════════ 回访 chip（记忆上次选择） ═══════════ */

/* 位置在右下而不是右上（2026-07-30 挪）：右上已被工具簇占据——旧值 top:74px
   是按当年 71px 高的导航调的，导航加高后 chip 顶着授权按钮贴脸，
   还与展开的下拉面板同层打架（品牌方实拍指出）。右下角无占用、
   语义也更对（「接着上次看」是恢复类动作，不是导航类）。 */
#last-chip {
  position: fixed;
  right: clamp(16px, 2.4vw, 28px);
  bottom: calc(clamp(32px, 7svh, 64px) + env(safe-area-inset-bottom, 0px));
  z-index: var(--z-nav, 110);
  padding: 10px 18px;
  border-radius: var(--radius-pill, 1000px);
  font-size: calc(12.5px * var(--ratio, 1));
  letter-spacing: .1em;
  border: 1px solid color-mix(in oklab, var(--accent) 46%, transparent);
  background: var(--glass, rgba(10,8,18,.7));
  backdrop-filter: blur(var(--blur-sm, 6.25px));
  -webkit-backdrop-filter: blur(var(--blur-sm, 6.25px));
  color: var(--text-hi);
  text-decoration: none;
  opacity: 0;
  transform: translateY(8px);
  transition: opacity var(--dur-slow, .5s) var(--ease-out, ease),
              transform var(--dur-slow, .5s) var(--ease-out, ease),
              border-color var(--dur-base, .3s),
              box-shadow var(--dur-base, .3s);
}
#last-chip.show { opacity: 1; transform: translateY(0); }
#last-chip:hover {
  border-color: var(--accent);
  box-shadow: 0 0 16px color-mix(in oklab, var(--accent) 28%, transparent);
}

/* ═══════════ 页脚 ═══════════ */

.portal-foot { text-align: center; }


/* ═══════════ 玻璃导航 ═══════════ */

.portal-brand img { width: 34px; display: block; }

/* 导航保底压暗（2026-07-30 全站 26 点对比度实测后加）：
   vendor 的 .vx-nav 默认全透明，滚动后也只加一层很淡的毛玻璃，链接色是 --text-mid。
   导航压在亮画面上时对比度实测掉到 1.08–1.13（玄关双门那扇天光窗、
   胶片横廊的白瓷浴缸、partner 的亮底幅）——那三个字在截图里根本认不出来。
   用渐变而非实底色：保留「悬浮在画面上」的观感，往下多铺 30px 淡出，不留硬边；
   底色取 --bg-base，跟着分区主题走（亮色模式下自然变成亮 scrim，方向仍然正确）。
   ::before 必须 position:absolute —— .vx-nav 是 flex 容器，
   静态定位的伪元素会变成 flex item，把品牌与链接挤开。 */
.vx-nav::before {
  content: "";
  position: absolute;
  /* 往下多铺 46px 而不是 30px：盒子越高，尾巴越长，亮画面上才不会看出一条硬边的黑带。
     链接行落在盒子 15%–41% 处（导航高约 71px），所以 44% 之前必须还是满强度。 */
  inset: 0 0 -46px 0;
  z-index: -1;
  pointer-events: none;
  background: linear-gradient(180deg,
    color-mix(in oklab, var(--bg-base) 92%, transparent) 0%,
    color-mix(in oklab, var(--bg-base) 84%, transparent) 44%,
    color-mix(in oklab, var(--bg-base) 34%, transparent) 72%,
    transparent 100%);
}

.portal-nav-links { display: flex; gap: clamp(16px, 3vw, 34px); }

.portal-nav-links a {
  font-size: calc(12.5px * var(--ratio, 1));
  letter-spacing: .28em;
  text-indent: .28em;
  text-transform: uppercase;
  color: var(--text-mid);
  text-decoration: none;
  transition: color var(--dur-base, .3s);
}
.portal-nav-links a:hover { color: var(--text-hi); }

/* 窄屏：右侧多了工具簇（navtools.css），三链按世界页同款收紧，否则 390 下挤出右缘 */
@media (max-width: 620px) {
  .portal-nav-links { gap: clamp(10px, 3vw, 18px); }
  .portal-nav-links a { font-size: calc(11.5px * var(--ratio, 1)); letter-spacing: .14em; text-indent: .14em; }
}

/* 门前 nav 只留 logo（世界链接是门后内容） */
body.locked .portal-nav-links { display: none; }

/* ═══════════ 扫光加载遮罩 ═══════════ */

.portal-shimmer {
  width: clamp(90px, 14vw, 140px);
  aspect-ratio: 1;
  display: block;
  /* 不走 --shimmer-mask 变量：自定义属性里的 url() Chrome 按«代入点所在样式表»
     （vendor 目录）解析——行内写法 404 在 css/vendor/assets/…，挪进本文件定义后
     仍 404 在 css/assets/…，两次实测证明变量里的相对路径不可控，
     logo 形状的光带因此从未生效过。直接写 mask-image：普通 url() 全浏览器
     一律按本样式表（css/）解析，无歧义；本文件在 components.css 之后加载，
     同权重后位覆盖 vendor 的 var() 行（2026-07-30 修） */
  -webkit-mask-image: url(../assets/img/logo.png);
  mask-image: url(../assets/img/logo.png);
}

/* ═══════════ 拍 3 · 窗（滚动洗刷幕） ═══════════ */

body.locked .portal-window { display: none; }

/* 桌面/横屏：横幅全屏（竖素材适配横屏是设计意图）。
   竖屏与静帧降级才用镜框形态（见下方 portrait / is-static 块） */
.mirror-frame {
  position: absolute;
  inset: 0;
  overflow: clip;
  will-change: transform, opacity;
}

/* 竖屏：镜框形态（上拱圆顶呼应素材里的拱形镜）。黑区压缩：框撑到 94vw / 88svh */
@media (orientation: portrait) {
  .portal-window { --frame-h: min(88svh, 88lvh, calc(94vw * 16 / 9)); }
  .mirror-frame {
    -webkit-mask-image: none;
    mask-image: none;
    inset: auto;
    left: 50%; top: 50%;
    translate: -50% -50%;
    height: var(--frame-h);
    aspect-ratio: 9 / 16;
    border-radius: 999px 999px 18px 18px;
    border: 1px solid color-mix(in oklab, var(--accent) 40%, transparent);
    box-shadow: 0 34px 110px -30px rgba(0, 0, 0, .65);
  }
  /* 框外暗场垫一层主题微光，不留死黑 */
  .portal-window .vx-scrub__stage {
    background: radial-gradient(ellipse 70% 58% at 50% 44%,
      color-mix(in oklab, var(--accent) 9%, transparent), transparent 70%);
  }
}

/* 滚动驱动分两层（全读 --scrub-progress，往上滚全程可逆）：
   镜框整体 → 溶出 + 下沉（92% 起快速退场：卡片此刻已盖住大半屏，物理遮挡与溶出同步。
     不许更早——她真身入镜在时间轴末段，早溶出会把揭示段压进半透明，实测被否。
     进场也不做溶入——进度在钉住前恒为 0，溶入=滚进来先见一屏黑，实测被否）
   框内画面 → 镜头推进 scale 1→1.09 线性（原站运镜同款），推近原点对准面部区 */
.portal-window .mirror-frame {
  opacity: calc(min(1, (1 - var(--scrub-progress, 0)) * 12.5));
  transform: translateY(calc(var(--scrub-progress, 0) * 1.5svh));
}
.portal-window .vx-scrub__media {
  width: 100%; height: 100%;
  object-fit: cover;
  /* 焦点策略（实测定稿）：前 73% 行程（镜中景+转身段）固定 32%——用户认可的原始构图，
     一帧不动；只在末段背影（行程 73% 起，主体下移出窗）让镜头缓缓下摇到 45% 收尾。
     全程摇镜被否：中段构图漂移，观感不如定焦 */
  object-position: 50% calc(32% + max(0%, (var(--scrub-progress, 0) - 0.73) * 48%));
  transform: scale(calc(1 + 0.09 * var(--scrub-progress, 0)));
  transform-origin: 50% 32%;
  will-change: transform;
}
/* 窗幕文字在卡片到来前让位，不与卡片同屏打架。
   ⚠ 系数从 2.6 提到 4.5（2026-08-08）：2.6 意味着 p=0.615 就开始淡，
     到 p=0.83 还挂在 45% ——**整整三分之一的行程里它既没走也读不清**。
     淡出是要让位，不是要含糊。4.5 让它在 p=0.778 之前保持满不透明，
     之后快速退场；卡片真正压上来的位置（p≈0.73 起咬入）它照样让得开。 */
.portal-window .vx-scrub__overlay {
  opacity: calc(min(1, (1 - var(--scrub-progress, 0)) * 4.5));
}

/* 左缘光衰：这一幕的底是白天的窗，竖排字站的那一侧恰好最亮。
   只靠加厚柔晕做到 2.37 就到顶了（大字门槛 3.0）——字号 29px、字距 .34em，
   字与字之间的空当占了取样框大半，柔晕再厚也拉不动中位背景。
   所以补的是「这一侧的光本来就该更暗」，不是给字加底板：
   渐变 30% 处即归零，画面主体（人在中右）一点没蒙灰。

   ⚠ 2026-08-08 改：这两道渐变原本挂在 .vx-scrub__overlay 自己身上，于是**与字幕
     共用同一个 opacity**——字淡到 45% 时，它脚下那块暗也一起淡到 45%，白天的窗
     重新亮回来。实测对比度 1.90（大字门槛 3.0），而且不是一闪而过：
     停在那个滚动位置就一直是这样。
     淡出本身没错（要给卡片让位），错在**让字和它的底同速淡出**——
     字退场的那段路，正是最需要底还在的时候。
     ⇒ 光衰挪到不带 opacity 的 stage 上，曲线放慢（×5，p 到 0.8 才开始退）。
     字先走、底后走，但底最终仍退干净，卡片段不会留下一道暗边
     （那正是当初把两者绑在一起的原因，这一点没有丢）。 */
.portal-window .vx-scrub__stage::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 1;                 /* 与 overlay 同层，但先于它入栈 ⇒ 字压在光衰之上 */
  pointer-events: none;
  opacity: calc(min(1, (1 - var(--scrub-progress, 0)) * 5));
  background:
    linear-gradient(90deg, rgb(4 3 10 / .58) 0%, rgb(4 3 10 / .32) 14%, transparent 30%),
    /* 底缘再压一道：滚动提示「慢慢往下滚」在画面正下方，左缘那道够不到它（实测 3.79/4.5） */
    linear-gradient(0deg, rgb(4 3 10 / .5) 0%, rgb(4 3 10 / .22) 10%, transparent 20%);
}
@media (orientation: portrait) {
  /* 竖屏时字幕挪到顶部居中（见下方 .window-line 的 portrait 覆盖），光衰跟着换成上缘 */
  .portal-window .vx-scrub__stage::after {
    background: linear-gradient(180deg, rgb(4 3 10 / .58) 0%, rgb(4 3 10 / .32) 12%, transparent 26%);
  }
}
.portal-window.is-static .vx-scrub__stage::after { opacity: 1; }
.portal-window.is-static .vx-scrub__overlay { opacity: 1; }

/* 触屏/减弱动效退静帧时不做驱动 */
.portal-window.is-static .mirror-frame {
  position: static; inset: auto; translate: none;
  height: auto; width: min(88vw, 56svh);
  margin: 7svh auto;
  border-radius: 999px 999px 18px 18px;
  border: 1px solid color-mix(in oklab, var(--accent) 40%, transparent);
  box-shadow: 0 34px 110px -30px rgba(0, 0, 0, .65);
  opacity: 1; transform: none;
}
.portal-window.is-static .vx-scrub__media { opacity: 1; transform: none; aspect-ratio: 9 / 16; object-position: 50% 50%; }
.portal-window.is-static + .doors-pin { margin-top: 0; }
@media (prefers-reduced-motion: reduce) {
  .portal-window .mirror-frame {
    position: static; inset: auto; translate: none;
    height: auto; width: min(88vw, 56svh);
    margin: 7svh auto;
    opacity: 1; transform: none;
  }
  .portal-window .vx-scrub__media { opacity: 1; transform: none; aspect-ratio: 9 / 16; object-position: 50% 50%; }
  .portal-window + .doors-pin { margin-top: 0; }
}

.portal-intro { padding-block: clamp(64px, 13svh, 150px); }

/* 主字幕：竖排立在画面左侧，素文字+柔晕（无容器） */
.window-line {
  writing-mode: vertical-rl;
  position: absolute;
  left: clamp(24px, 6.5vw, 104px);
  top: 50%;
  translate: 0 -50%;
  font-size: clamp(19px, 2.4vw, 29px);
  font-weight: 600;
  letter-spacing: .34em;
  color: var(--text-hi);
  /* 柔晕从两层加到三层，不是装饰是必需：这一幕的底是白天的窗，
     实测竖排字压在亮窗框上对比度只有 2.01（大字门槛 3.0）。
     设计定的是「素文字＋柔晕、无容器」，所以走加厚柔晕这条路而不是加底板——
     最外那层 34px 已等效一条软压暗带，但不会在画面上切出边界。
     量法说明：把文字挖空后柔晕仍在（阴影照字形几何画、不跟填充色走），
     所以这三层是真算进对比度里的，不是自欺。 */
  text-shadow:
    0 0 3px rgb(4 3 10 / .96),
    0 0 11px rgb(4 3 10 / .88),
    2px 2px 34px rgb(4 3 10 / .72);
  text-align: center;
}

@media (orientation: portrait) {
  .window-line {
    writing-mode: horizontal-tb;
    left: 50%;
    top: max(5svh, 72px);
    translate: -50% 0;
  }
}

.window-hint {
  margin-top: 12px;
  font-size: calc(12px * var(--ratio, 1));
  font-weight: 560;
  letter-spacing: .4em;
  text-indent: .4em;
  color: var(--text-hi);
  text-align: center;
  /* 同上：12px 单层柔晕在亮窗底下实测 3.06（小字门槛 4.5） */
  text-shadow: 0 0 3px rgb(0 0 0 / .92), 0 1px 14px rgb(0 0 0 / .85);
}

/* ── 器物：三件小件的独立一屏 ────────────────────────────────
   2026-08-01 重做。原来是门框底下一条纯文字窄带，品牌方的判词是
   「像是充话费送的，我差点没注意到」——一针见血：那条窄带零张图、用了 ALSO 这个
   脚注词、还长在 .portal-doors 内部，60px 对 86svh，比例上就是赠品。
   现在给它自己的一屏。与双开门的区分不靠"做小"，靠**语法相反**：
     门  = 一个满幅合体物件（无间隙、只有外侧两角圆角、height 86svh）
     器物 = 三个各自成立的独立卡（有间隙、四角都圆、3:2 横幅）
   三件本来就是分开卖的三样东西，所以"分开"才是它正确的形。 */
.portal-pieces {
  position: relative;
  z-index: 2;
  /* -70svh 吃掉 .doors-pin 的驻留行程：门钉住淡出的同一段滚动里，
     这一屏贴着门底往上滑，从门身上盖过去——「整娃往下滚的同时小件往上走」
     的差速就来自门钉、它不钉（08-12 品牌方定稿的接力）。
     门前（body.locked）这条边距收回，见 .doors-pin 规则上方。 */
  margin-top: -70svh;
  padding: clamp(40px, 7.5svh, 90px) clamp(14px, 2.4vw, 40px) clamp(74px, 15svh, 168px);
}

.pieces-head {
  width: min(100%, 1400px);
  margin: 0 auto clamp(28px, 4.6svh, 58px);
}
.pieces-k {
  display: block;
  font-size: calc(11px * var(--ratio, 1));
  font-weight: 560;
  letter-spacing: .5em;
  text-indent: .5em;
  color: var(--accent);
}
.pieces-t {
  margin: clamp(12px, 1.8svh, 20px) 0 0;
  font-size: clamp(27px, 3.5vw, 52px);
  font-weight: 300;
  letter-spacing: .06em;
  line-height: 1.28;
  color: var(--text-hi);
}
.pieces-s {
  margin: clamp(11px, 1.6svh, 18px) 0 0;
  /* 46ch（沿用别处的值）会把中文断在「房/间」中间——CJK 允许任意字间断行，
     宽度一卡不准就直接从词中间劈开。放到 64ch 让这句在常规视口里走一行，
     真要换行时 text-wrap: pretty 也会挑标点处断。 */
  max-width: 64ch;
  text-wrap: pretty;
  font-size: calc(13.5px * var(--ratio, 1));
  letter-spacing: .04em;
  line-height: 1.95;
  color: var(--text-mid);
}

.pieces-row {
  width: min(100%, 1400px);
  margin-inline: auto;
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: clamp(10px, 1.5vw, 22px);
}

/* 卡片比例锁 3:2：三张源图本来就是 3:2 满幅，卡片跟着它走就不必二次裁——
   ⚠ 这里不能用 object-fit: cover 配一个不同的 aspect-ratio，那等于让浏览器
     替我重新构图，验过的那一版就不再是屏幕上这一版（catalog.css 里记过同一条）。 */
.piece-card {
  position: relative;
  display: block;
  overflow: hidden;
  isolation: isolate;
  aspect-ratio: 3 / 2;
  border-radius: var(--radius-xl, 24px);
  background: var(--bg-inset);
  text-decoration: none;
  outline: none;
  transition: box-shadow var(--dur-slow, .5s) var(--ease-out, ease),
              translate var(--dur-slow, .5s) var(--ease-out, ease);
}
.piece-card:hover,
.piece-card:focus-visible {
  translate: 0 -6px;
  box-shadow: 0 18px 46px rgb(0 0 0 / .5),
              0 0 0 1px color-mix(in oklab, var(--accent) 50%, transparent);
}
.pc-media, .pc-focal { display: block; width: 100%; height: 100%; }
.pc-img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;                 /* 与卡片同为 3:2 ⇒ 实际不裁，只是兜底 */
  transition: scale var(--dur-slow, .5s) var(--ease-out, ease);
}
.piece-card:hover .pc-img,
.piece-card:focus-visible .pc-img { scale: 1.045; }

/* 压暗只压字所在的下缘：整幅压暗会把这三件本来就暗的画面压死 */
.pc-scrim {
  position: absolute;
  inset: 42% 0 0;
  background: linear-gradient(to bottom, transparent, rgb(4 3 10 / .82));
  pointer-events: none;
}
.pc-label {
  position: absolute;
  left: clamp(14px, 1.5vw, 24px);
  right: clamp(14px, 1.5vw, 24px);
  bottom: clamp(13px, 1.6svh, 20px);
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 6px 14px;
}
.pc-code {
  font-size: calc(17px * var(--ratio, 1));
  font-weight: 500;
  letter-spacing: .26em;
  text-indent: .26em;
  color: var(--text-hi);
  text-shadow: 0 0 3px rgb(4 3 10 / .95), 0 1px 14px rgb(4 3 10 / .85);
}
/* 08-01 文案从「唇 · 舌 · 通道」这类部位清单改成整句体感后，字距必须收回来：
   .2em 是给三个并列词排的，一整句拉成那样会散架，也会让人把句子读回清单。 */
.pc-form {
  font-size: calc(12.5px * var(--ratio, 1));
  letter-spacing: .04em;
  color: var(--text-hi);
  opacity: .88;
  text-shadow: 0 0 3px rgb(4 3 10 / .95), 0 1px 12px rgb(4 3 10 / .85);
}

.pieces-all {
  display: block;
  width: min(100%, 1400px);
  margin: clamp(22px, 3.4svh, 42px) auto 0;
  text-align: right;
  font-size: calc(12.5px * var(--ratio, 1));
  letter-spacing: .28em;
  text-indent: .28em;
  color: var(--text-mid);
  text-decoration: none;
  transition: color var(--dur-base, .3s);
}
.pieces-all:hover,
.pieces-all:focus-visible { color: var(--accent); }

@media (max-width: 860px) {
  /* 三列在 860 以下每张不到 260px，标签行先折。改两列 + 首件占满，
     保持"三个独立物件"的读法，不退化成一条缩略图带 */
  .pieces-row { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .piece-card:first-child { grid-column: 1 / -1; }
}
@media (max-width: 520px) {
  .pieces-row { grid-template-columns: 1fr; }
  .piece-card:first-child { grid-column: auto; }
}
