/* ── LAYER CONTRACT ───────────────────────────────────────────
   改 DOM 前先读这里。视差靠类名绑定，不靠结构位置。v3：四个钟点从纵向堆叠改为
   钉住的横向平移（.night-pan / .pan-track / .beat），全站滚动动效引擎从 CSS
   animation-timeline 换成自托管 GSAP + ScrollTrigger。ScrollSmoother 已彻底移除：
   真机实测 isTouch 恒为非零（触屏能力探测，不是"当前是不是用手指滚"），
   `!ScrollTrigger.isTouch` 在该设备的桌面和手机上都是假，等于它从没在任何设备上
   启用过，纯粹的死代码，删掉后 body 直属 main/footer，不再需要两层包裹。
   见 devDocs/spec-v3-gsap.md 与 DECISIONS.md D12。v4：颜色弧线从 .ground-veil
   （全页 fixed 层）降级为 .pan-sky（只活在 .night-pan 内部的 absolute 层），
   见 devDocs/spec-v4-color.md 与 DECISIONS.md D13/D13.1。

   z-index 白名单（v4 修订，不准出现其它数值）：
   .ground-veil               z-index -1   fixed 层，退回 v2 原职责：只做全页
                                            opacity 明暗过渡（script.js 里的普通
                                            scrubTween），不再持有任何 --beat-* 弧线
   .pan-sky                   z-index -1   .night-pan 内部的 absolute 层（不是
                                            fixed），钟点段颜色弧线现在的家，见下方
                                            "v4 color arc" 说明
   .layer-far / .beat-ghost /
   .stage-sweep               z-index  0   Z-far 与幽灵层，最慢 / 静态背景
   (无类名)                    z-index  2   正文内容（.hero-copy / .beat-copy /
                                            .statement-copy / .wio-inner / .footer），不加 transform
   .layer-near / .stage-cat /
   .beat-cat                  z-index  3   Z-near，最快 / 前景主体
   .topbar                    z-index 10   固定顶栏，时钟 + 语言切换
   .scroll-line               z-index 20   scaleX 进度线，GSAP ScrollTrigger 驱动

   .statement-head（S4 sticky 标题）不在这张表里：它用纯 position: sticky 实现（M3），
   零 JS、零 transform 动画，跟 .layer-near 的滚动漂移是两套不同机制，故不复用该类名。

   .topbar 在 hero 区内保持 position: absolute（D11：无遮罩的 fixed 顶栏会撞上滚动
   经过的 hero 文案）。但页面拉长之后，滚出 hero 后整页都够不到主题/语言切换，
   所以 script.js 用一个不带门控的 ScrollTrigger（trigger: .hero, start: "bottom top",
   toggleClass）在滚出 hero 时给 .topbar 加 is-fixed 类，切到 position: fixed 并
   带一层纯 --ground 底色的实色遮罩（不透明，颜色走 token，不是新的透明度概念）。
   这是可用性修复不是装饰性动效，所以不挂在 isMobile/reduceMotion 分支下，两种
   偏好下都要能摸到这两个开关。

   动效编排全部搬进 script.js（GSAP ScrollTrigger / gsap.matchMedia），CSS 不再持有
   任何 animation-timeline / animation-range / 具名时间线。振幅数值的唯一入口是
   script.js 里的 MOTION 对象（heroFar/heroNear/stageScale/stageSweep/beatGhost/
   beatCat/mobileScale/panScrollMultiplier/lampBurstPeak），不在 CSS 里重复一份。
   颜色则相反：颜色绝对不进 JS。v4 的 .pan-sky 弧线是这条规则下最干净的一版实现：
   GSAP 只补间 .pan-sky-arc / .pan-sky-glow 的 background-position 和 .pan-sky-lamp
   的 opacity/scale，从不触碰任何一个 background-image 字符串（那会每帧重新解析、
   掉帧），也从不在 JS 里读取或插值任何色值本身：颜色的定义、四个 --beat-* 停止点、
   渐变的方向和范围全部写死在这里的 CSS 规则里，JS 只负责让已经画好的层动起来。

   v4 color arc（.pan-sky 专用，见上方 z-index 白名单）：把 --beat-* 挪出
   .ground-veil 之后，四个色不用再兼顾"页脚灰字读得出"这个约束，只需要在
   .pan-sky 上过 .hour-line（大字，>=3:1）和 .beat-time（11px，>=4.5:1）两条线，
   色域因此宽出许多。色值由主会话先算后冻（spec-v4-color.md 2.1.1），实施方
   复算核对，不自行选色；对比度按合成后的底色算（.pan-sky-glow 以 --veil-glow
   的透明度叠在 .pan-sky-arc 上，前景文字实际读到的是合成色，不是纯 arc 色），
   实测结果在交付报告里。

   .pan-sky 现在是三层：.pan-sky-arc（--beat-* 四色横向 linear-gradient 扫过，
   background-position 唯一由 GSAP 补间的属性）、.pan-sky-glow（结构与 arc 相同，
   但换成 --glow-* / --light-glow-* 四色，即"该时刻色的高光版"，用 mask-image
   罩成一团光晕的形状，漂移速率比 arc 慢，制造纵深）、.pan-sky-lamp（--lamp 高光
   爆发层，只在 03:17 那一帧起落）。三层都只补间 background-position / opacity /
   scale，从不重写 background-image 或在 JS 里碰颜色本身。

   .night-pan 的横向平移用 GSAP 的 containerAnimation 模式：.pan-track 的位移是
   主 tween，.beat-ghost / .beat-cat 各自的面板内二级视差是挂在同一个 pin 的
   ScrollTrigger 上的从属 tween（start/end 用 left/right 而不是 top/bottom，
   这是 GSAP 横向容器动画的约定写法）。ScrollTrigger 的 start 必须是 "top top"：
   写成 "top center" 会在面板钉住之前就开始平移，用户会看到半张面板。

   .stage-wrap（S2 钉住舞台）继续用纯 CSS position: sticky 钉住 .stage，这不是
   动画所以不受 prefers-reduced-motion 影响；只有 .stage-cat 的缩放与 .stage-sweep
   的横扫这两个 GSAP scrub tween 挂在 gsap.matchMedia 的 "(prefers-reduced-motion:
   no-preference)" 分支下。

   Hero 视差同理不用具名时间线：GSAP trigger 用 .hero，但 end 是绝对像素
   "+=" + window.innerHeight（一屏的高度），不是 .hero 自身的行高或文档总长度。
   理由见 DECISIONS.md D3：一个基于元素自身 cover 区间的方案会让首屏还没滚动
   就已经漂移了一半。

   .night-pan 需要在 prefers-reduced-motion: reduce 下整体退化为纵向静态四块，
   这不能只在 JS 里跳过动画（那样布局仍是 overflow:hidden + flex 横排，用户会被
   困在第一块看不到其余三块）。所以横排 flex 布局本身也包在
   @media (prefers-reduced-motion: no-preference) 里，reduce 下 .pan-track 退回
   纯纵向 block 流，零 JS 也能读完整页面。

   主题层（两态，不再跟随系统）：深色是唯一的默认 :root，没有条件，
   不看 prefers-color-scheme。之前那条 @media (prefers-color-scheme: light) 网关
   已删除：深色对这个站是内容本身（一只猫在深夜面对唯一亮着的屏幕），不是配色
   偏好，不该让访客的系统设置决定他看到的是不是这个站最弱的版本（DECISIONS 这
   一轮的记录）。浅色 token 的字面值只在 --light-* 命名空间写一次，现在只有唯一
   一个网关会引用它：显式的 :root[data-theme="light"] {...}（用户点了切换按钮）。
   `<head>` 的防闪烁内联脚本只在 localStorage 存过显式选择时才写 data-theme，
   否则保持无属性，落到默认 :root（深色），所以首屏永远没有闪烁风险。
   --veil-far/--veil-ghost/--veil-sweep/--veil-dust 是原来写死在 .wordmark span /
   .beat-ghost / .stage-sweep span / .stardust 上的透明度，现在收进 token，两套主题
   各自取值不同（深底浅字 vs 浅底深字，同一数字观感强度不同）。断点里对
   --veil-ghost / --veil-sweep 的移动端覆盖必须用能与浅色网关打平优先级的选择器
   列表（见响应式区块：:root 与 :root[data-theme="light"] 两项，不再需要第三项），
   否则窄屏覆盖会被浅色网关的高优先级吃掉。--beat-* 这组夜色弧线 token 同理，
   浅色网关 alias 一次 --light-beat-*，弧线本身只写一处。
   ────────────────────────────────────────────────────────── */

@font-face {
  font-family: "Bricolage Grotesque";
  src: url("assets/fonts/bricolage-grotesque-latin.woff2") format("woff2");
  font-weight: 400 800;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: "Geist Mono";
  src: url("assets/fonts/geist-mono-latin.woff2") format("woff2");
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: "Departure Mono";
  src: url("assets/fonts/departure-mono.woff2") format("woff2");
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}

:root {
  color-scheme: dark;

  --ground:      #17171D;
  --ground-end:  #030304;
  --ink:         #EDEDEA;
  --ink-dim:     #8A8A8F;
  --ink-faint:   #4A4A4F;
  --hairline:    rgba(237, 237, 234, 0.10);
  --accent:      #74C3A4;
  --accent-ink:  #07120D;
  --lamp:        #F0B429;

  /* v4 color arc (S3 night-pan): lives in .pan-sky now, a layer scoped to
     .night-pan, not the full-page .ground-veil. That is the whole point of
     the v4 rework (see DECISIONS D13/D13.1): a full-page veil has to stay
     dark enough for --ink-dim body copy in .statement/.wio/.footer to read
     on top of it, which capped every beat color near black. Scoped to a
     layer with only large text and decorative numerals on it, the
     contrast floor drops to 3:1 (.hour-line) and 4.5:1 (.beat-time, 11px),
     and the colors get to actually be colors. Values below are pre-computed
     by the coordinator (spec-v4-color.md 2.1.1) and independently
     re-verified here (delivery report has the 16-way recompute); do not
     retune without redoing that math. */
  --beat-2340: #16314A;   /* cool blue, screen is still the only light */
  --beat-0105: #123A3A;   /* teal-black, deepest point of the night */
  --beat-0317: #4A2E10;   /* amber, the lamp is on */
  --beat-0550: #5A3A18;   /* morning gold, near dawn */

  /* .pan-sky-glow's own colors, one highlight per beat (spec 2.2: "the
     current moment's highlight version"). A single static token pair here
     was tried and reverted: spec 2.2 explicitly asks the halo to change
     color with the arc, and a fixed color also fights the whole point of
     this round (user's core complaint was "everything is black, grey, or
     white"; a static warm-white halo is one more grey/near-white constant,
     not more color). NOT --ink either: --ink flips lightness by design
     across themes (D7), so a glow built on it reads as light in the dark
     theme and a smudge in the light one. Each value here is that beat's
     own --beat-* hue with lightness pushed up (same hue, brighter, reads
     as light added). Values re-verified against the composited background
     (arc blended with this color at --veil-glow's alpha), not the raw arc
     color; see delivery report for the 16-item recompute. */
  --glow-2340: #3372AC;
  --glow-0105: #309B9B;
  --glow-0317: #B36F27;
  --glow-0550: #BF7B33;

  /* Decorative-layer opacities. Same number reads at different strength on a
     light ground than on a dark one, so they are tokens, not literals in the
     rules that use them (.wordmark span / .beat-ghost / .stage-sweep span /
     .stardust). */
  --veil-far:   .13;
  --veil-ghost: .12;
  --veil-sweep: .12;
  --veil-dust:  .28;
  /* .pan-sky-glow's opacity (v4, spec 2.2's "upper layer"). Not spec'd with
     an exact number, since it is an implementation detail the spec leaves
     to the renderer; kept in the same token family and the same dark/light
     ratio as the other --veil-* pairs rather than a literal in the rule
     that uses it. */
  --veil-glow: .10;

  /* Narrow screens show less of the giant ghost and sweep at once, so those
     two need more presence there. That is a ratio, not a target: applied as a
     multiplier so every theme keeps its own proportion and a future palette
     needs no second mobile tune. Only ghost and sweep take it. */
  --veil-boost: 1;

  /* Light theme palette, written once. The two gateway selectors below only
     alias these; see LAYER CONTRACT for why a single shared rule is not
     possible in plain CSS. */
  --light-ground:      #E8EBE7;
  --light-ground-end:  #F6F8F4;
  --light-ink:         #15181A;
  --light-ink-dim:     #575D60;
  --light-ink-faint:   #9298A0;
  --light-hairline:    rgba(21, 24, 26, .14);
  --light-accent:      #237046;
  --light-accent-ink:  #F3F8F5;
  /* v4: darkened from #8F5E08. The old value only cleared 3.73-4.03:1 against
     the new --light-beat-* arc below (fails 4.5:1); #7C5207 clears 4.59-4.95
     there and is also a strict improvement on the pre-existing uses, 4.63 ->
     5.70 on --light-ground and 5.21 -> 6.41 on --light-ground-end (both
     re-verified in the delivery report). Not a regression anywhere. */
  --light-lamp:        #7C5207;
  /* One highlight per beat, matching --glow-* above (not a single static
     token, see there for why). Not white (invisible on a pale ground), and
     not just a lighter --ink either (that is exactly the D7 mistake --ink
     stays cool/deep in the light theme to avoid). Each value is that beat's
     own --light-beat-* hue pushed more saturated at the same lightness:
     the light theme's "light" reads through saturation and color
     temperature contrast, not added brightness (brightness on an already
     pale ground is invisible; this was confirmed the round before this
     one). User testing described it as reading "between light and a
     stain," so saturation was pushed further still and each hue nudged a
     few degrees warmer, lightness held exactly constant throughout, since
     raising it is the one move already ruled out. Re-verified against the
     composited background, not the raw arc color; see delivery report. */
  --light-glow-2340: #B2E3FB;
  --light-glow-0105: #A3F5DA;
  --light-glow-0317: #FFDA99;
  --light-glow-0550: #FFCA8D;
  --light-veil-far:    .07;
  --light-veil-ghost:  .07;
  --light-veil-sweep:  .07;
  --light-veil-dust:   .16;
  --light-veil-glow:   .06;

  /* v4 light-theme color arc, scoped to .pan-sky like its dark counterpart
     above (see there for why). Pre-computed by the coordinator, re-verified
     here; --ink stays 11.9+ and --lamp (now #7C5207) stays 4.59+ against
     every stop, both above their respective floors (3:1 large text, 4.5:1
     for the 11px .beat-time label). */
  --light-beat-2340: #C3D8EA;
  --light-beat-0105: #BCDCD6;
  --light-beat-0317: #F0D9A8;
  --light-beat-0550: #F2CE9A;

  --font-display: "Bricolage Grotesque", "Segoe UI Variable Display", ui-sans-serif, system-ui, "PingFang SC", "Microsoft YaHei", sans-serif;
  --font-mono: "Geist Mono", ui-monospace, "Cascadia Code", "SFMono-Regular", Consolas, monospace;
  /* Third type family, one job: the giant ghost numerals in .beat-ghost only.
     Never used for body copy, labels, or anything else (see spec section 4). */
  --font-pixel: "Departure Mono", var(--font-mono);

  --radius-pill: 999px;
  --radius-image: 4px;

  /* Motion amplitude now lives in script.js's MOTION object (v3), not here.
     Colors stay CSS-only tokens; displacement/scale amounts stay JS-only. */

  --space-section: clamp(120px, 18vh, 220px);
}

/* ── Theme gateway (explicit light only) ───────────────────────
   Dark is the unconditional default :root above: there is no
   prefers-color-scheme gateway. Only an explicit data-theme="light"
   choice (the toggle) switches these tokens over. */
:root[data-theme="light"] {
  color-scheme: light;
  --ground: var(--light-ground);
  --ground-end: var(--light-ground-end);
  --ink: var(--light-ink);
  --ink-dim: var(--light-ink-dim);
  --ink-faint: var(--light-ink-faint);
  --hairline: var(--light-hairline);
  --accent: var(--light-accent);
  --accent-ink: var(--light-accent-ink);
  --lamp: var(--light-lamp);
  --glow-2340: var(--light-glow-2340);
  --glow-0105: var(--light-glow-0105);
  --glow-0317: var(--light-glow-0317);
  --glow-0550: var(--light-glow-0550);
  --veil-far: var(--light-veil-far);
  --veil-ghost: var(--light-veil-ghost);
  --veil-sweep: var(--light-veil-sweep);
  --veil-dust: var(--light-veil-dust);
  --veil-glow: var(--light-veil-glow);
  --beat-2340: var(--light-beat-2340);
  --beat-0105: var(--light-beat-0105);
  --beat-0317: var(--light-beat-0317);
  --beat-0550: var(--light-beat-0550);
}

@media (max-width: 900px) {
  :root,
  :root[data-theme="light"] {
    --space-section: clamp(90px, 14vh, 160px);
    /* Viewport-driven, theme-independent by construction. Only two selectors
       needed: bare :root (0,1,0) covers the default/explicit-dark case, and
       :root[data-theme="light"] (0,2,0) is listed separately because a bare
       :root here would lose to the light gateway's higher specificity, and
       media queries add no specificity of their own to close that gap. */
    --veil-boost: 1.65;
  }
}
@media (max-width: 560px) {
  :root {
    --space-section: clamp(80px, 12vh, 120px);
  }
}

* { box-sizing: border-box; }
html { scroll-behavior: smooth; }

body {
  margin: 0;
  min-height: 100dvh;
  background: var(--ground);
  color: var(--ink);
  font-family: var(--font-display);
  font-size: 17px;
  line-height: 1.72;
  overflow-x: hidden; /* fallback for browsers without overflow: clip */
  overflow-x: clip; /* does not create a scroll container, unlike hidden, so position: sticky in S2 keeps working */
}

a { color: inherit; }
.mono { font-family: var(--font-mono); }
.shell { width: min(1180px, calc(100% - 48px)); margin-inline: auto; position: relative; }

:focus-visible { outline: 2px solid var(--ink); outline-offset: 3px; }

/* ── Fixed base layers ─────────────────────────────────────── */

.ground-veil {
  position: fixed;
  inset: 0;
  z-index: -1;
  background: var(--ground-end);
  opacity: 0;
}

.scroll-line {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 2px;
  z-index: 20;
  background: var(--accent);
  transform-origin: left center;
  transform: scaleX(0);
}

/* .ground-veil opacity/background-color and .scroll-line scaleX are now GSAP
   scrub tweens (script.js), gated behind gsap.matchMedia's no-preference
   branch. Nothing to declare here beyond the rest states above: .ground-veil
   stays invisible and .scroll-line stays at scaleX(0) until GSAP takes over. */

/* ── Topbar ────────────────────────────────────────────────── */

/* Absolute, not fixed, inside the hero: a fixed bar with no scrim collides
   with the hero copy scrolling under it (D11). That reasoning only holds
   inside the hero, though, and the page is now long enough that a field
   report couldn't reach the theme/language switch at all once scrolled
   past it. script.js toggles .is-fixed (a plain ScrollTrigger toggleClass,
   no scroll listener) once .hero scrolls out, re-pinning the bar as fixed
   with a solid scrim so it stays reachable and readable everywhere else. */
.topbar {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  z-index: 10;
}
.topbar.is-fixed {
  position: fixed;
  background: var(--ground);
  border-bottom: 1px solid var(--hairline);
}
.topbar-inner {
  height: 64px;
  display: flex;
  align-items: center;
  justify-content: space-between;
}
.clock {
  font-size: 11px;
  letter-spacing: .08em;
  color: var(--ink-dim);
}
.switch-group {
  display: inline-flex;
  align-items: center;
  gap: 10px;
}
.language-switch {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  min-height: 32px;
  padding: 0 12px;
  border: 1px solid var(--hairline);
  border-radius: var(--radius-pill);
}
.lang-btn {
  appearance: none;
  border: 0;
  background: transparent;
  padding: 6px 1px;
  color: var(--ink-dim);
  font: inherit;
  font-family: var(--font-mono);
  font-size: 11px;
  letter-spacing: .04em;
  cursor: pointer;
}
.lang-btn:hover { color: var(--ink); }
.lang-btn.is-active { color: var(--ink); font-weight: 600; }
.lang-divider { color: var(--ink-faint); user-select: none; }

/* Reuses the language-switch pill language: same radius, same hairline
   border, same mono size. Text-only, no icon (this site has no icon set
   and hand-rolled SVG is not allowed). */
.theme-switch {
  appearance: none;
  display: inline-flex;
  align-items: center;
  min-height: 32px;
  padding: 0 14px;
  border: 1px solid var(--hairline);
  border-radius: var(--radius-pill);
  background: transparent;
  color: var(--ink-dim);
  font: inherit;
  font-family: var(--font-mono);
  font-size: 11px;
  letter-spacing: .04em;
  cursor: pointer;
}
.theme-switch:hover { color: var(--ink); }

/* ── Hero (S1: asymmetric split) ──────────────────────────── */

.hero {
  position: relative;
  min-height: 100dvh;
  display: grid;
  grid-template-columns: 1.1fr .9fr;
  align-items: center;
  gap: 6vw;
  padding-top: 6rem;
  padding-bottom: clamp(60px, 10vh, 120px);
}

.layer-far {
  position: absolute;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  overflow: hidden;
}
/* Pushed right so the Z-far wordmark sits behind the cat, not behind the H1.
   The cat overtaking it is what makes the depth read, and the outlined
   headline needs a clean ground to stay legible. Still true and unchanged:
   this rule stays right-anchored, never moves toward the H1. */
.wordmark {
  display: flex;
  /* User testing: with align-items: center, the vertically-centered cat
     (right column) and the vertically-centered word occupied the same
     horizontal band, so the cat's left edge covered the word's right
     portion and only "AW" ever showed. The word must be fully readable;
     the overlap it needs for depth is still there, just moved from a
     horizontal cut (word vs cat, left-right) to a vertical one (word
     mostly above the cat, cat's top edge crossing the word's own bottom
     edge). flex-start plus a top offset does that: this only repositions
     the word, .layer-far's overflow: hidden and the cat's own stacking are
     untouched. Desktop-only; see the max-width: 900px override below for
     why mobile keeps the old centered behavior. Needs human eyes: the
     offset is an estimate, not a measured pixel value. */
  align-items: flex-start;
  justify-content: flex-end;
  padding-top: 12vh;
}
.wordmark span {
  font-weight: 800;
  font-size: clamp(80px, 12vw, 240px);
  line-height: .8;
  letter-spacing: -.04em;
  color: var(--ink);
  opacity: var(--veil-far);
  white-space: nowrap;
}
.stardust {
  opacity: var(--veil-dust);
  background-image:
    radial-gradient(1.5px 1.5px at 10% 20%, var(--ink) 45%, transparent 46%),
    radial-gradient(1.5px 1.5px at 80% 60%, var(--ink) 45%, transparent 46%),
    radial-gradient(1px 1px at 40% 82%, var(--ink) 45%, transparent 46%),
    radial-gradient(1px 1px at 62% 14%, var(--ink) 45%, transparent 46%),
    radial-gradient(1.5px 1.5px at 25% 52%, var(--ink) 45%, transparent 46%),
    radial-gradient(1px 1px at 92% 32%, var(--ink) 45%, transparent 46%);
  background-repeat: repeat;
  background-size: 340px 340px;
}

/* Z-near plane. The class carries BOTH the stacking position and the drift,
   so anything given .layer-near gets exactly what the layer contract promises. */
.layer-near {
  position: relative;
  z-index: 3;
}

/* .layer-far / .layer-near drift is now a GSAP scrub tween (script.js MOTION.
   heroFar / heroNear), gated behind gsap.matchMedia's no-preference branch.
   End is an absolute "+= one viewport height" from .hero's own top, same
   reasoning DECISIONS D3 gave for the old scroll(root block) range: a
   cover-range named timeline would already read 50% at scroll 0. */

.hero-copy {
  position: relative;
  z-index: 2;
  max-width: 46ch;
}
.hero-copy h1 {
  margin: 0;
  font-weight: 800;
  font-size: clamp(64px, 11vw, 168px);
  line-height: .82;
  letter-spacing: -.045em;
}
.hero-copy h1 .outline {
  color: transparent;
  -webkit-text-stroke: 1.5px var(--ink);
}
.lead {
  margin: 28px 0 0;
  max-width: 32ch;
  font-weight: 500;
  font-size: clamp(20px, 2.2vw, 30px);
  line-height: 1.35;
  color: var(--ink-dim);
}
.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 10px;
  min-height: 52px;
  padding: 0 26px;
  border-radius: var(--radius-pill);
  font-family: var(--font-display);
  font-weight: 600;
  font-size: 15px;
  text-decoration: none;
  white-space: nowrap;
  transition: transform .2s ease;
}
.btn-primary { background: var(--accent); color: var(--accent-ink); }
.btn-primary:hover { transform: translateY(-2px); }
.btn-primary:active { transform: scale(.98); }

.cat-photo {
  display: block;
  width: 100%;
  /* height:auto is load-bearing. The img carries width/height attributes, which
     land as presentational hints; without this the height hint stays definite,
     aspect-ratio below is ignored, and object-fit crops the cat to a sliver. */
  height: auto;
  aspect-ratio: 1 / 1;
  object-fit: cover;
  border-radius: var(--radius-image);
}
.hero-cat {
  justify-self: end;
  width: min(100%, 480px);
}

/* ── Pinned stage (S2: sticky scale + giant time sweep) ─────
   .stage-wrap is the scrolling distance (320vh); .stage is the sticky
   viewport-sized window that stays put while that distance is consumed.
   Full-bleed on purpose (no .shell): the cat and the sweeping clock read as
   a cinematic frame, not body copy. The scale/sweep are GSAP scrub tweens
   (script.js MOTION.stageScale / MOTION.stageSweep) scoped to .stage-wrap's
   own scroll distance; position: sticky here is a layout mechanism, not an
   animation, so it keeps pinning even under prefers-reduced-motion. */

.stage-wrap {
  height: 320vh;
  position: relative;
  z-index: 2;
}
.stage {
  position: sticky;
  top: 0;
  height: 100dvh;
  display: grid;
  place-items: center;
  overflow: hidden;
}
.stage-sweep,
.stage-cat {
  grid-row: 1;
  grid-column: 1;
}
.stage-sweep {
  width: 100%;
  height: 100%;
  z-index: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  pointer-events: none;
}
.stage-sweep span {
  font-family: var(--font-mono);
  font-weight: 400;
  /* Mobile floor tuned down further in v3 (spec section 5 #1: the desktop
     sweep already reads correctly, 0 to 7 fully visible; only the phone
     amplitude and floor needed work) so a full "03:17" can travel across a
     phone screen instead of one huge digit sliding by. Travel distance for
     mobile lives in script.js's MOTION_MOBILE_STAGE.sweep, not here.
     Strength lives in --veil-sweep (theme-gated), not here either. */
  font-size: clamp(64px, 34vw, 640px);
  letter-spacing: -.02em;
  color: var(--ink);
  opacity: calc(var(--veil-sweep) * var(--veil-boost));
  white-space: nowrap;
}
.stage-cat {
  position: relative;
  z-index: 3;
  width: min(64vw, 560px);
  height: auto;
  aspect-ratio: 1 / 1;
  object-fit: cover;
  border-radius: var(--radius-image);
}

/* ── Night pan (S3: pinned horizontal scroll through 4 beats) ──────
   .night-pan is the pin/scroll distance, .pan-track is the GSAP tween
   target (translateX, script.js), .beat is one 100vw x 100dvh panel. Full
   flex/overflow horizontal layout only applies under prefers-reduced-motion:
   no-preference (see rule below and LAYER CONTRACT): under reduce this
   degrades to the plain vertical stack declared unconditionally here, fully
   readable with zero JS. */

.night-pan { position: relative; z-index: 2; }

/* .pan-sky (v4): the color arc lives here now, scoped to this section
   instead of the full-page .ground-veil (see the --beat-* comment in :root
   for why). Absolute within .night-pan, not fixed, so when GSAP pins the
   section it is carried along with it and reads as full-bleed for the
   whole pinned duration. Three flat sublayers, painted in DOM order, none
   of them carrying their own z-index (only the .pan-sky wrapper does, per
   the v4 whitelist) so stacking is just source order within it. */
.pan-sky {
  position: absolute;
  inset: 0;
  z-index: -1;
  overflow: hidden;
  pointer-events: none;
}
.pan-sky-arc,
.pan-sky-glow,
.pan-sky-lamp {
  position: absolute;
  inset: 0;
}
/* Base layer: a real flowing gradient, not four jump-cut colors. GSAP only
   ever tweens background-position-x on this (script.js): the gradient
   itself, its stops, and its size are declared once here and never
   rewritten at runtime, so nothing re-parses a background-image string on
   scroll (spec 2.2's explicit performance rule). background-size 400% over
   a 0%-100% background-position-x sweep means the position tween traverses
   the full four-stop gradient exactly once, start stop to end stop. */
.pan-sky-arc {
  background-image: linear-gradient(90deg,
    var(--beat-2340) 0%,
    var(--beat-0105) 33.33%,
    var(--beat-0317) 66.66%,
    var(--beat-0550) 100%);
  background-size: 400% 100%;
  background-position: 0% 50%;
}
/* Overlay halo: same four-stop sweeping-gradient structure as .pan-sky-arc
   above, not a single static color. Spec 2.2 asks the halo to show "the
   current moment's highlight version," which only a moving gradient can
   do; a single fixed token was tried in an earlier pass and reverted, it
   both contradicted that line and fought the round's whole point (a static
   near-white halo is one more grey/near-white constant on a page whose
   core complaint was "too much black, grey, and white"). The --glow-* /
   --light-glow-* colors are each beat's own hue, not --ink: --ink flips
   lightness by design across themes (D7), so a glow built on it reads as
   light in the dark theme and a smudge in the light one, the same token
   expressing opposite meanings. The halo's round shape comes from a mask,
   not the gradient type, so the gradient itself can stay a plain linear
   sweep: script.js only ever tweens background-position-x on it, same as
   the base layer, just a shorter 0%-35% range so it visibly lags behind
   the arc (a sense of depth within the section, not synced to "this
   panel's own color", spec 2.2 does not ask for exact per-panel sync). */
.pan-sky-glow {
  background-image: linear-gradient(90deg,
    var(--glow-2340) 0%,
    var(--glow-0105) 33.33%,
    var(--glow-0317) 66.66%,
    var(--glow-0550) 100%);
  background-size: 400% 100%;
  background-position: 0% 50%;
  -webkit-mask-image: radial-gradient(55% 55% at 50% 50%, black 0%, transparent 70%);
  mask-image: radial-gradient(55% 55% at 50% 50%, black 0%, transparent 70%);
  opacity: var(--veil-glow);
}
/* Amber burst (spec 2.3): the one new --lamp use site (3 of 3, alongside
   .beat-time and .beat-ghost's stroke). Rest state is off (opacity 0);
   script.js rises and falls it as the 03:17 panel crosses center, the only
   panel this layer ever lights up for. */
.pan-sky-lamp {
  background-image: radial-gradient(45% 45% at 50% 50%, var(--lamp) 0%, transparent 70%);
  opacity: 0;
}
.beat {
  position: relative;
  width: 100%;
  min-height: 100dvh;
  display: grid;
  /* Widened from the original 1.05fr/.95fr, then eased back from an
     intermediate 1.3fr/.7fr (that pass over-corrected: it was chasing
     "every line fits unwrapped" by squeezing the whole ratio toward text,
     but the hanging indent on .hour-line span, not column width, is now
     what actually solves the readability problem, so the image column gets
     some of that space back). .beat-cat's own max-width still caps it at
     420px regardless of how much room this column offers above that. */
  grid-template-columns: 1.2fr .8fr;
  align-items: center;
  gap: 6vw;
  padding: clamp(48px, 8vh, 96px) max(24px, calc((100% - 1180px) / 2));
  overflow: hidden;
}
.beat-right { grid-template-columns: .8fr 1.2fr; }
/* text-align: right removed (field report): the copy is poem-formatted now,
   not prose, and a ragged-right poem next to the next panel's ragged-left
   one read as inconsistent, not as rhythm. Image position still alternates
   via order below; only the text alignment was the complaint. */
.beat-right .beat-copy { order: 2; }
.beat-right .beat-cat { order: 1; justify-self: start; }
.beat-ghost {
  position: absolute;
  inset: 0;
  z-index: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  font-family: var(--font-pixel);
  font-weight: 400;
  font-size: clamp(140px, 26vw, 420px);
  color: var(--ink);
  /* Row #2 fix (spec section 5): --lamp gets a second, still-restrained use
     here as a stroke on the panel's own ghost numeral, alongside .beat-time
     below. Deliberate v3 override of theme-spec's single-location rule for
     --lamp, see delivery report. Fill strength still lives in --veil-ghost
     (theme-gated), not here. Stroke width scales with the numeral itself
     (clamp, not a flat 1px): at a 420px font size a hairline stroke reads as
     nothing, which was the exact complaint this row exists to fix. Final
     thickness needs human eyes on an actual render. */
  -webkit-text-stroke: clamp(2px, 0.6vw, 5px) var(--lamp);
  opacity: calc(var(--veil-ghost) * var(--veil-boost));
  pointer-events: none;
}
/* max-width widened from 46ch alongside the column and font-size changes
   below: ch is font-relative, so shrinking .hour-line's font without also
   raising this would have made the cap itself shrink and become a new,
   accidental bottleneck instead of a harmless ceiling. 64ch stays well
   above the grid column's actual pixel width at every viewport this
   matters at, so the grid, not this value, is what actually constrains
   line length. */
.beat-copy { position: relative; z-index: 2; max-width: 64ch; }
.beat-time {
  margin: 0 0 14px;
  font-size: 11px;
  letter-spacing: .08em;
  /* One of two allowed --lamp locations in v3, see .beat-ghost above. */
  color: var(--lamp);
}
.hour-line {
  margin: 0;
  font-weight: 600;
  /* Direction changed (field report + a correction to the spec's own
     estimate of the longest lines): chasing a font-size that lets every
     sentence, including the two full-prose ones at 56/54 characters, fit
     on one unbroken line turned out to need sub-24px type next to a giant
     ghost numeral, which loses more than it fixes. The user has explicitly
     accepted in-sentence wrapping; the actual problem the screenshot showed
     was that a wrapped continuation line was visually indistinguishable
     from a new <span> line. That is what the hanging indent on
     .hour-line span (below) fixes, at any font size, so this ceiling only
     needs to serve the giant-line-next-to-giant-numeral balance again, not
     "does 56 characters fit." User testing found 34px still left occasional
     orphaned single words on their own wrapped, indented line, which read
     as an odd extra micro-line rather than part of the sentence above it;
     nudged down again to 31px alongside text-wrap: pretty below, which
     targets that exact problem directly. Floor stays 24px (mobile stacks
     full-width and was never the problem). */
  font-size: clamp(24px, 2.4vw, 31px);
  line-height: 1.25;
  color: var(--ink);
}
/* Hanging indent: each sentence is its own block (script.js wraps every
   hourLine* segment in its own <span>, not <br>, specifically so
   text-indent has a per-sentence first line to apply to; <br> gives no such
   hook). First line sits flush left (the negative text-indent cancels the
   padding); a wrapped continuation line lands indented under it. That makes
   "this is a new sentence" vs "this is the same sentence, still going"
   visually unambiguous at every viewport width, which is the actual fix:
   previously a wrapped line and a deliberate new line were typographically
   identical and unreadable together. 1.4em is a first pass, needs human
   eyes on a real render to confirm it reads as intentional and not just an
   odd gap. text-wrap: pretty asks the browser to avoid leaving a single
   orphaned word on a wrapped line by itself, which is exactly what user
   testing flagged: "one word wrapped down and indented looks like its own
   stray line." Progressive enhancement, unsupported browsers just fall
   back to normal wrapping, nothing breaks. */
.hour-line span {
  display: block;
  padding-left: 1.4em;
  text-indent: -1.4em;
  text-wrap: pretty;
}
.beat-cat {
  position: relative;
  z-index: 3;
  width: 100%;
  max-width: 420px;
  height: auto;
  aspect-ratio: 1 / 1;
  object-fit: cover;
  border-radius: var(--radius-image);
  justify-self: end;
}

@media (prefers-reduced-motion: no-preference) {
  .night-pan { overflow: hidden; }
  .pan-track { display: flex; height: 100dvh; }
  .beat { flex: 0 0 100vw; width: 100vw; min-height: auto; height: 100dvh; }
}

/* ── Statement (S4: sticky two-column) ───────────────────── */

.statement { position: relative; z-index: 2; padding-block: var(--space-section); }
/* User testing reported a jump when the pin releases at the fourth beat.
   This used to compress the top padding here on the reasoning that each
   .beat already centers its content, so the last one
   ends on half a screen of air and a full section gap on top of that
   counted the air twice. That reasoning belonged to the old vertically
   stacked hours; now .night-pan ends by releasing a pin, and cutting the
   padding here left no runway for that release, so scrolling past the
   fourth beat felt like a jump straight into the middle of .statement.
   Reverted to the section's normal padding-block (no override): the fix
   for a pin-release jump is breathing room, not less of it. */
.statement-grid {
  display: grid;
  grid-template-columns: 1.05fr .95fr;
  gap: 8vw;
  /* start (not the default stretch) is what gives the sticky head room to
     travel: the item stays content-height while its grid area spans the row. */
  align-items: start;
}
.statement-head { position: sticky; top: 22vh; }
.statement-head h2 {
  margin: 0;
  font-weight: 700;
  font-size: clamp(40px, 6vw, 88px);
  line-height: .95;
  letter-spacing: -.035em;
}
.statement-copy p {
  /* Narrow measure and generous gaps: the air is the point, and it also gives
     the sticky head on the left something to actually travel against. */
  max-width: 46ch;
  margin: 0 0 76px;
  font-size: 18px;
  line-height: 1.72;
  color: var(--ink-dim);
}
.statement-copy p:last-child { margin-bottom: 0; }

/* ── Reveal (M4: per-element scroll reveal) ───────────────── */

/* Rest state and no-JS/no-motion fallback: fully visible, no displacement.
   The lift-and-fade itself is now a GSAP fromTo scrub tween (script.js),
   gated behind gsap.matchMedia's no-preference branch, so nothing else is
   declared here. */
.reveal { opacity: 1; transform: none; }

/* ── WIO Tracker (S5: full-width dark panel, hairline-bound) ── */

.wio {
  position: relative;
  z-index: 2;
  padding-block: var(--space-section);
  border-top: 1px solid var(--hairline);
  border-bottom: 1px solid var(--hairline);
}
.wio-inner { max-width: 720px; }
.wio-inner h2 {
  margin: 0 0 12px;
  font-weight: 700;
  font-size: clamp(40px, 6vw, 88px);
  line-height: .95;
  letter-spacing: -.035em;
}
.wio-tagline {
  margin: 0 0 28px;
  font-size: 11px;
  letter-spacing: .08em;
  text-transform: uppercase;
  color: var(--ink-dim);
}
.wio-desc {
  margin: 0 0 40px;
  max-width: 62ch;
  font-size: 18px;
  line-height: 1.72;
  color: var(--ink-dim);
}

/* ── Footer (S6: single line) ─────────────────────────────── */

.footer {
  position: relative;
  z-index: 2;
  min-height: 100px;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  padding-block: 32px;
  border-top: 1px solid var(--hairline);
  font-family: var(--font-mono);
  font-size: 11px;
  letter-spacing: .06em;
  color: var(--ink-dim);
}

/* ── Reduced motion (zero displacement, layout unaffected) ─── */

@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
}

/* ── Chinese typesetting: no tracking, slightly taller lines ── */

html[lang="zh-CN"] .lead,
html[lang="zh-CN"] .statement-copy p,
html[lang="zh-CN"] .wio-desc,
html[lang="zh-CN"] .hour-line {
  letter-spacing: 0;
  line-height: 1.8;
}

/* ── Responsive ────────────────────────────────────────────
   Every multi-column layout declares its own <768px collapse here. */

@media (max-width: 900px) {
  .shell { width: min(100% - 32px, 760px); }

  .hero {
    grid-template-columns: 1fr;
    gap: 40px;
    padding-top: 5.5rem;
    /* Stacked hero already overruns one screen, so min-height stops binding and
       the bottom padding just adds to the dead run before the stage arrives. */
    min-height: auto;
    padding-bottom: 40px;
  }
  .hero-cat { justify-self: stretch; width: 100%; max-width: 420px; margin-inline: auto; }

  /* Stacked hero: hero-copy and hero-cat are no longer side by side, they
     are in normal document flow one above the other, so the desktop fix
     above (moving the word above a horizontally-adjacent cat) does not
     apply to the same problem here; there is no report of a mobile
     readability issue with this word, and the fix was scoped to desktop
     ("桌面宽度下" in the original report). Restoring the original centered
     position keeps mobile exactly as it already was, rather than
     inheriting an offset tuned for a completely different layout. Needs
     human eyes to confirm this is still fine now that the desktop rule
     changed underneath it. */
  .wordmark { align-items: center; padding-top: 0; }

  .stage-wrap { height: 240vh; }
  .stage-cat { width: min(78vw, 420px); }

  /* Spec section 1 mobile rule: the horizontal pan stays, but each panel's
     internal layout switches from the desktop side-by-side grid to a
     stacked one (image on top, text below), and left/right alternation is
     dropped since a cramped narrow column reads worse than it adds rhythm. */
  .beat {
    grid-template-columns: 1fr;
    grid-template-rows: auto auto;
    gap: 24px;
    padding-block: clamp(40px, 6vh, 64px);
  }
  .beat-right { grid-template-columns: 1fr; }
  .beat-cat,
  .beat-right .beat-cat {
    order: -1;
    justify-self: center;
    width: min(70vw, 340px);
    max-width: 340px;
  }
  .beat-copy,
  .beat-right .beat-copy {
    order: 0;
    text-align: left;
    justify-self: stretch;
  }
  /* Field report: 1.4em scales off .hour-line's own font-size, which
     mobile clamps to its 24px floor, so the indent itself (33.6px) eats a
     bigger share of an already-narrow column than it does on desktop, and
     Chinese lines wrap more often at this width, multiplying how many
     indented continuation lines are on screen at once. Reduced, not
     removed: the narrower the column, the more this mechanism is actually
     needed (more wrapping, not less), so turning it off here would undo
     the hanging-indent fix exactly where it matters most. Value needs
     human eyes. */
  .hour-line span {
    padding-left: 0.9em;
    text-indent: -0.9em;
  }
  /* --veil-ghost / --veil-sweep are bumped for this breakpoint in the :root
     token block above, not here (opacity is a token, not a literal). */
  /* Same revert as the desktop .statement rule above, for the same reason
     (a compressed top gap here left no runway after the pin releases).
     Mobile .statement already falls back to this breakpoint's own
     --space-section via .statement's own padding-block, so no override is
     needed here either. */

  .statement-grid { grid-template-columns: 1fr; gap: 32px; }
  .statement-head { position: static; top: auto; }
  /* The wide paragraph gaps exist to give the sticky head something to travel
     against. The head is static at this width, so they are only dead air. */
  .statement-copy p { margin-bottom: 40px; }
}

@media (max-width: 560px) {
  .topbar-inner { height: 56px; }
  .hero { padding-top: 4.5rem; padding-bottom: 56px; }
  .hero-copy h1 { font-size: clamp(54px, 15vw, 96px); }
  .btn { width: 100%; }
}

@media (max-width: 380px) {
  .hero-copy h1 { font-size: clamp(48px, 15vw, 72px); }
  .lead { font-size: 19px; }
}

@media (max-width: 900px) and (max-height: 520px) and (orientation: landscape) {
  .hero { min-height: auto; padding-top: 4.5rem; padding-bottom: 40px; }
}
