/* ==========================================================================
   Yixintong Technology — 北京壹鑫通科技有限公司
   Dark editorial redesign. Shared by / and /zh/.

     1.  Tokens
     2.  Reset & base
     3.  Typography
     4.  Layout
     5.  Buttons & pills
     6.  Glass surfaces
     7.  Atmosphere (starfield, glow)
     8.  Dock nav
     9.  Mobile menu
     10. Intro
     11. Hero & stack
     12. Section furniture
     13. Projects
     14. Solutions
     15. Capabilities
     16. Stats
     17. About
     18. News
     19. Contact
     20. Footer
     21. Legal pages
     22. Motion & print

   Breakpoints: 1080px, 720px.
   ========================================================================== */

/* 1. Tokens ============================================================== */

:root {
  /* --- Digital Factory palette ---
     Deep black, white, tech blue, logo gold. Four colours, no gradients on
     text, no neon, no glowing borders. The restraint is the point: the
     reference set is Apple Business / Linear / Stripe, where colour marks
     hierarchy and nothing else. */
  --void: #0a0a0c;
  --void-2: #101013;
  --void-3: #17171b;
  --deep: #14161c;
  --deep-2: #0f1116;

  --surface: #fafafa;

  /* Neutral greys, not blue-tinted. A cool cast on the text made the old
     scheme read as "screen"; industrial catalogues set type in true neutrals
     and let the one accent do the talking. */
  --mist: #f2f2f3;
  --mist-2: rgba(242, 242, 243, 0.72);
  /* Secondary body copy. 0.44 was fine over a flat near-black page, but the
     backdrop now paints behind it and that drops the measured contrast to
     3.5:1 — under the 4.5 minimum. Raised until it measures over 5. */
  --mist-3: rgba(242, 242, 243, 0.62);
  --mist-4: rgba(242, 242, 243, 0.34);

  --accent: #2563eb;
  --accent-hi: #5b8def;
  --accent-deep: #1b4bc4;

  --gold: #d4af37;

  /* Hairlines are near-neutral: at this weight a blue tint reads as a glow,
     and glowing borders are exactly what this brief rules out. */
  --line: rgba(255, 255, 255, 0.08);
  --line-2: rgba(255, 255, 255, 0.15);
  --glass: rgba(255, 255, 255, 0.03);
  --glass-2: rgba(255, 255, 255, 0.06);
  --grid: rgba(255, 255, 255, 0.032);

  /* 悬浮小面板的底色：鼠标划过背景图形时弹出的说明、方案图上的热点提示。
     它们此前各自写死了一个近黑的 rgba，而文字用的是 --mist —— 浅色主题下
     --mist 变成深蓝黑，压在近黑的盒子上等于看不见。做成 token，两套主题
     各给一次，就不会再出现"文字跟着主题走、底色没跟上"这种半边翻转。 */
  --float: rgba(10, 10, 12, 0.86);
  --float-2: rgba(14, 12, 21, 0.92);

  /* Source Han Sans is Noto Sans SC — same typeface, Adobe's name for it —
     so one family covers both pages and the CJK is authentic rather than a
     fallback. This retires the Playfair/Space Grotesk comparison: a display
     serif was never going to say "engineering firm", and the brief settles
     it. --display-style stays as a token because the rules that consume it
     are scattered through the file. */
  --font-display: "Source Han Sans SC", "Noto Sans SC", "PingFang SC",
    "Microsoft YaHei", Inter, -apple-system, sans-serif;
  --display-style: normal;
  --font-body: "Inter", "Noto Sans SC", "PingFang SC", "Microsoft YaHei",
    -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
  --font-mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Consolas,
    monospace;

  --dock-h: 58px;

  /* The round buttons at each end of the dock — the logo and the language
     link. They used to be --dock-h, i.e. two 72px circles pinned to the top
     corners, which read as heavier than the bar between them and drew the eye
     away from it. 54 keeps them comfortably above the 44px minimum touch
     target while letting the nav be the loudest thing up there. */
  --dock-btn: 44px;
  --gutter: 5vw;
  --maxw: 1440px;

  /* Space reserved on the right for the fixed module rail. Zero until the
     rail actually appears, so narrow screens keep the full measure. Without
     this the rail sits on top of right-aligned content on any viewport
     between the rail breakpoint and ~1800px. */
  --rail-space: 0px;

  --ease-out: cubic-bezier(0.22, 1, 0.36, 1);
  --ease-inout: cubic-bezier(0.76, 0, 0.24, 1);

  --r-pill: 999px;
  --r-card: 18px;
}

/* The data-type="tech" comparison flag is gone. The brief settles the
   question: Source Han Sans at 500. A display serif was never going to read
   as an engineering firm, and carrying two display families purely to keep
   the comparison alive was costing every visitor a font download. */

/* 2. Reset & base ======================================================== */

*,
*::before,
*::after {
  box-sizing: border-box;
}

html {
  /* Deliberately NOT `scroll-behavior: smooth` — it applies to programmatic
     scrolling too, which breaks ScrollTrigger's pinning. app.js smooths
     anchor clicks instead. */
  -webkit-text-size-adjust: 100%;
}

body {
  margin: 0;
  background: var(--void);
  color: var(--mist);
  font-family: var(--font-body);
  font-size: 16px;
  line-height: 1.65;
  overflow-x: hidden;
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
}

img,
svg,
canvas {
  display: block;
  max-width: 100%;
}

[hidden] {
  display: none !important;
}

::selection {
  background: var(--accent);
  color: var(--void);
}

::placeholder {
  color: var(--mist-4);
  opacity: 1;
}

:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
  border-radius: 4px;
}

.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
  border: 0;
}

.skip-link {
  position: absolute;
  top: -100px;
  left: var(--gutter);
  z-index: 400;
  background: var(--mist);
  color: var(--void);
  padding: 12px 20px;
  font-size: 14px;
  font-weight: 600;
  border-radius: var(--r-pill);
  transition: top 0.2s;
}

.skip-link:focus {
  top: 14px;
}

/* Reading-progress hairline. Replaces the old right-hand dot rail, which
   was clutter in this design. */
.progress {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  height: 2px;
  z-index: 300;
  transform-origin: 0 50%;
  transform: scaleX(0);
  background: var(--accent);
  pointer-events: none;
}

/* 3. Typography ========================================================== */

a {
  color: var(--mist);
  text-decoration: none;
}

h1,
h2,
h3,
h4 {
  margin: 0;
  font-weight: 400;
  line-height: 1.04;
  letter-spacing: -0.01em;
}

p {
  margin: 0;
}

/* Source Han Sans at 500 — the brief's weight, and the right one. This is a
   humanist sans with real CJK design behind it, so both languages are set in
   one authentic family rather than a Latin face with a CJK fallback bolted on.

   Tracking is slightly negative on Latin to give the display line density.
   CJK stays at zero: negative tracking collides the glyphs. */
.display {
  font-family: var(--font-display);
  font-style: var(--display-style);
  font-weight: 500;
  letter-spacing: -0.018em;
}

html[lang="zh-Hans"] .display {
  font-weight: 500;
  letter-spacing: 0;
  line-height: 1.24;
}

/* The brief specifies 48px. That is the desktop figure — holding it literally
   on a 390px phone overflows the headline, so it is the ceiling of a clamp
   rather than a fixed value. --lg is the hero; the section heads step down. */
.display--lg {
  font-size: clamp(30px, 4.6vw, 48px);
  line-height: 1.18;
}

/* Stepped down hard from the old editorial scale (74px / 40px). The brief
   asks for section headings that do not overpower the page, and an industrial
   catalogue sets its headings close to body size and lets the grid and the
   labels carry the structure instead. */
.display--md {
  font-size: clamp(24px, 2.6vw, 34px);
  line-height: 1.24;
}

.display--sm {
  font-size: clamp(18px, 1.5vw, 22px);
  line-height: 1.32;
}

/* Uppercase monospace micro-label. */
.micro {
  font-family: var(--font-mono);
  font-size: 10.5px;
  font-weight: 400;
  letter-spacing: 0.24em;
  text-transform: uppercase;
  color: var(--mist-3);
}

html[lang="zh-Hans"] .micro {
  letter-spacing: 0.18em;
}

.lede {
  font-size: 16px;
  line-height: 1.7;
  color: var(--mist-2);
}

.dim {
  color: var(--mist-2);
}

.gold {
  color: var(--gold);
}

/* Was gradient-clipped text. The brief prohibits gradient text outright, and
   it is right to: on a page selling engineering credibility, gradient ink is
   the single fastest way to look like a template. One flat accent word. */
.shimmer {
  color: var(--accent);
}

/* 4. Layout ============================================================== */

.wrap {
  max-width: var(--maxw);
  margin: 0 auto;
  padding-left: var(--gutter);
  padding-right: calc(var(--gutter) + var(--rail-space));
}

/* The rail-space media query lives at the end of this file — .hero, .rail and
   the footer set `padding` shorthand further down, which would otherwise
   override a padding-right declared up here. */

.section {
  position: relative;
  padding: clamp(96px, 12vh, 170px) 0;
  scroll-margin-top: 90px;
}

.section--hair {
  border-top: 1px solid var(--line);
}

/* 5. Buttons & pills ===================================================== */

/*
 * THE FILL SWEEPS UP FROM THE BOTTOM ON HOVER, and it is painted with the
 * element's own background rather than a ::before.
 *
 * That is not a stylistic preference, it is the only version that works here.
 * A button's label is a bare text node — there is no element around it to
 * raise above an absolutely-positioned pseudo-element, and a positioned
 * pseudo paints OVER inline text in the same stacking context. So the usual
 * recipe would sweep the fill straight across the label and hide it.
 * `z-index: -1` does not rescue it either: that puts the pseudo behind the
 * button's own background, where it cannot be seen at all. Wrapping every
 * label in a span would fix it and would mean editing every template that
 * has ever rendered a button.
 *
 * background-size on a gradient sidesteps the whole problem. The fill is the
 * element's background, so the text is always above it, and one property
 * animates. It also composites cheaply.
 *
 * The easing is the point of the exercise: a long, decelerating curve that
 * arrives rather than stops. 0.55s sounds slow written down and reads as
 * "smooth" under a pointer; the previous 0.3s linear-ish fade read as a
 * state toggle.
 */
.btn {
  --btn-fill: var(--mist);
  --btn-sheen: rgba(255, 255, 255, 0.20);
  --btn-ease: cubic-bezier(0.22, 1, 0.36, 1);

  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 9px;
  font-family: inherit;
  font-size: 14.5px;
  font-weight: 500;
  line-height: 1;
  padding: 15px 26px;
  border-radius: var(--r-pill);
  border: 1px solid transparent;
  cursor: pointer;
  white-space: nowrap;

  /* Grows from a 0-height strip pinned to the bottom edge to the full pill.
     no-repeat matters — without it the gradient tiles upward and the button
     fills instantly instead of wiping. */
  /* 两层背景，各管一件事：
     第 1 层是一道窄高光，悬停时从左扫到右；
     第 2 层是主填充，从底部上移。
     用背景层而不是伪元素，是因为按钮文字是裸文本节点，没有元素能叠在伪
     元素之上（填充当初改用 background-size 也是同一个理由）。 */
  background-image:
    linear-gradient(115deg, transparent 42%, var(--btn-sheen) 50%, transparent 58%),
    linear-gradient(var(--btn-fill), var(--btn-fill));
  background-repeat: no-repeat, no-repeat;
  background-position: -140% 0, 50% 100%;
  background-size: 130% 100%, 100% 0%;

  /* 0.34s, not 0.55s. The slower version was chosen on the theory that longer
     reads as smoother; under an actual pointer it reads as lag, because the
     button is still filling after the cursor has arrived and settled. A hover
     response wants to feel immediate and land softly, which is a short
     duration on a decelerating curve, not a long one.

     `color` is deliberately absent: nothing here changes it any more, and a
     transition on a property that never varies only costs a style
     recalculation. */
  transition:
    /* 0s：移出时高光瞬间归位。给它时长的话会倒着扫回来，一次悬停变成两次
       动作，比不做还差。 */
    background-position 0s,
    background-size 0.34s var(--btn-ease),
    background-color 0.3s var(--btn-ease),
    border-color 0.3s var(--btn-ease),
    box-shadow 0.4s var(--btn-ease),
    transform 0.34s var(--btn-ease);
}

.btn:hover,
.btn:focus-visible {
  background-position: 260% 0, 50% 100%;
  background-size: 130% 100%, 100% 100%;

  /* 只有进入方向给高光时长。0.55s 走完一趟：更快像闪光灯，更慢就拖沓。 */
  transition:
    background-position 0.55s var(--btn-ease),
    background-size 0.34s var(--btn-ease),
    background-color 0.3s var(--btn-ease),
    border-color 0.3s var(--btn-ease),
    box-shadow 0.4s var(--btn-ease),
    transform 0.34s var(--btn-ease);
}


.btn svg {
  flex: none;
  /* The arrow leans toward where it is going. Two pixels — enough to feel
     intentional, not enough to notice as a separate animation. */
  transition: transform 0.45s var(--btn-ease);
}

.btn:hover svg,
.btn:focus-visible svg {
  transform: translateX(2px);
}

/*
 * THE LABEL COLOUR NO LONGER CHANGES ON HOVER, and that is the whole fix.
 *
 * The first version swept a fill up behind the text AND flipped the text
 * colour at the same time. Those two cannot be reconciled: while the fill is
 * at fifty per cent the label's lower half sits on the new colour and its
 * upper half on the old one, so whichever colour the text is, half of it is
 * near-invisible. Every button went through a stretch of being unreadable on
 * the way in and again on the way out. Slowing the sweep down made it worse,
 * because it lengthened the unreadable part.
 *
 * So each variant now sweeps a fill that keeps the SAME label legible from
 * start to finish. The motion is unchanged; it just no longer fights itself.
 */
.btn--light {
  /* Light pill, warmer light fill. Dark label throughout. */
  --btn-fill: #ffffff;
  /* 浅色底上白色高光是看不见的，这里用一道极淡的暗光。 */
  --btn-sheen: rgba(10, 10, 12, 0.10);
  background-color: var(--mist);
  color: var(--void);
}

.btn--light:hover,
.btn--light:focus-visible {
  transform: translateY(-2px);
  box-shadow: 0 10px 26px rgba(0, 0, 0, 0.32);
}

.btn--ghost {
  /* Outline pill on a dark page. The fill is a translucent lift, not a solid
     white — so the label stays light and readable the entire way, and the
     button gains presence instead of inverting. */
  --btn-fill: rgba(242, 242, 243, 0.14);
  --btn-sheen: rgba(255, 255, 255, 0.22);
  background-color: transparent;
  color: var(--mist);
  border-color: var(--line-2);
}

.btn--ghost:hover,
.btn--ghost:focus-visible {
  border-color: var(--mist-4);
  transform: translateY(-2px);
  box-shadow: 0 10px 26px rgba(0, 0, 0, 0.28);
}

.btn--accent {
  --btn-fill: #f0c96f;
  --btn-sheen: rgba(255, 255, 255, 0.30);
  background-color: var(--accent);
  color: var(--void);
}

.btn--accent:hover,
.btn--accent:focus-visible {
  transform: translateY(-2px);
  box-shadow: 0 10px 26px rgba(226, 185, 94, 0.22);
}

/* Pressed. A button that lifts on hover and does nothing on click feels
   unhooked from the pointer; dropping it back to rest is the whole fix, and
   the fast curve is deliberate — a press should answer immediately even
   though the hover arrives slowly. */
.btn:active {
  transform: translateY(0);
  transition-duration: 0.08s;
}

.btn[disabled] {
  opacity: 0.5;
  cursor: default;
  transform: none;
  background-size: 100% 0%;
  box-shadow: none;
}

/*
 * Reduced motion keeps the COLOUR and drops the TRAVEL — the same split the
 * opening and the rest of the site use. The fill still arrives, because a
 * background changing is not vestibular motion; the lift and the arrow nudge
 * go, because they are things moving across the screen.
 */
@media (prefers-reduced-motion: reduce) {
  .btn:hover,
  .btn:focus-visible,
  .btn:active {
    transform: none;
  }

  .btn:hover svg,
  .btn:focus-visible svg {
    transform: none;
  }
}

/* Badge above the headline: small chip + label, in one pill. */
.badge {
  display: inline-flex;
  align-items: center;
  gap: 10px;
  padding: 5px 16px 5px 5px;
  border-radius: var(--r-pill);
  border: 1px solid var(--line-2);
  background: var(--glass);
  backdrop-filter: blur(14px);
  -webkit-backdrop-filter: blur(14px);
  font-size: 12.5px;
  color: var(--mist-2);
}

.badge b {
  background: var(--mist);
  color: var(--void);
  font-size: 10.5px;
  font-weight: 600;
  letter-spacing: 0.04em;
  padding: 4px 11px;
  border-radius: var(--r-pill);
}

.chip {
  display: inline-block;
  font-family: var(--font-mono);
  font-size: 10px;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  padding: 5px 11px;
  border-radius: var(--r-pill);
  border: 1px solid var(--line);
  color: var(--mist-3);
}

/* Circular icon button (carousel arrows). */
.iconbtn {
  width: 44px;
  height: 44px;
  flex: none;
  border-radius: 50%;
  border: 1px solid var(--line-2);
  background: transparent;
  color: var(--mist-2);
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  transition: background 0.25s, color 0.25s, border-color 0.25s;
}

.iconbtn:hover {
  background: var(--mist);
  border-color: var(--mist);
  color: var(--void);
}

.iconbtn[disabled] {
  opacity: 0.3;
  cursor: default;
}

.iconbtn[disabled]:hover {
  background: transparent;
  color: var(--mist-2);
  border-color: var(--line-2);
}

/* Text link with a rule that wipes in from the left. */
.tlink {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  font-size: 13.5px;
  color: var(--mist-2);
  background: none;
  border: 0;
  padding: 0 0 3px;
  font-family: inherit;
  cursor: pointer;
  position: relative;
}

.tlink::after {
  content: "";
  position: absolute;
  left: 0;
  bottom: 0;
  height: 1px;
  width: 100%;
  background: var(--accent);
  transform: scaleX(0);
  transform-origin: left;
  transition: transform 0.4s var(--ease-out);
}

.tlink:hover {
  color: var(--mist);
}

.tlink:hover::after {
  transform: scaleX(1);
}

/* 6. Glass surfaces ====================================================== */

.glass {
  background: var(--glass);
  border: 1px solid var(--line);
  border-radius: var(--r-card);
  backdrop-filter: blur(22px) saturate(140%);
  -webkit-backdrop-filter: blur(22px) saturate(140%);
}

/* A single hairline of light along the top edge — what sells the material. */
.glass--lit {
  position: relative;
  overflow: hidden;
}

.glass--lit::before {
  content: "";
  position: absolute;
  inset: 0 0 auto;
  height: 1px;
  background: linear-gradient(
    90deg,
    transparent,
    rgba(255, 255, 255, 0.34),
    transparent
  );
}

/* 7. Atmosphere ========================================================== */

.sky {
  position: absolute;
  inset: 0;
  overflow: hidden;
  pointer-events: none;
  z-index: 0;
}

/* One very slight lift behind the hero so the products are not floating on
   dead flat black — a softbox above the subject, which is how an industrial
   product is actually lit. Neutral, not blue: a coloured wash here reads as
   atmosphere, and atmosphere is what this brief rules out.

   The old cyan aurora and its warm counter-glow are gone with the lattice
   they were tinting. */
.sky::before {
  content: "";
  position: absolute;
  left: 50%;
  top: -36vh;
  width: 130vw;
  height: 105vh;
  transform: translateX(-50%);
  background: radial-gradient(
    ellipse at 50% 50%,
    rgba(255, 255, 255, 0.045) 0%,
    rgba(255, 255, 255, 0.014) 42%,
    transparent 72%
  );
}

/* --- the field --- REMOVED, and it must not come back under this name.

   These rules styled the container for the 3D node lattice in field.js, which
   has been unreferenced since the backdrop replaced it. The markup for it was
   deleted; the CSS was not, and there is no `<div class="field">` on either
   page for it to match.

   What it did match: every `<label class="field">` in the contact form. The
   form-field rule further down sets `display`, so that part won, but nothing
   there overrode `position: fixed; inset: 0; pointer-events: none`. So all
   six form fields were taken out of flow and stacked on top of each other
   filling the viewport, and none of them could be clicked. That is the whole
   of why the contact panel looked collapsed.

   If field.js is ever revived, give its container a name that is not a common
   noun already in use — `.field-bg`, not `.field`. */

/* --- blueprint grid ---
   A fixed drafting grid behind everything. Opaque surfaces sit on top of it,
   so it shows in the gutters and disappears under cards — which is what makes
   it read as a substrate rather than as wallpaper. Now the fallback for .field
   rather than the default background. */
.grid-bg {
  position: fixed;

  /*
   * 底部多留 200px，不是 inset: 0。
   *
   * initScrollFeedback 会把这一层随滚动上移最多 140px 做视差。inset: 0 时它
   * 正好是一屏高，往上挪 140px 就在视口底部露出一条没有网格的空带 ——
   * 场景开着时看不出来（粒子填着），场景一关就是一条明显的断口。
   * 200 > 140，留够余量。
   */
  inset: 0 0 -200px 0;
  z-index: 0;
  pointer-events: none;
  background-image: linear-gradient(to right, var(--grid) 1px, transparent 1px),
    linear-gradient(to bottom, var(--grid) 1px, transparent 1px);
  background-size: 78px 78px;
  will-change: transform;
}

/*
 * 网格向底部淡出，免得和页脚打架。
 *
 * 注意这是 fixed 层，所以淡出是按**视口**算的，不是按页面 —— 任何滚动位置，
 * 屏幕下半部分的网格都是淡的。原来这不碍事：场景开着，下半屏由粒子填着。
 *
 * 关于我们往后场景整个关掉之后，这条就露馅了：新闻动态那一带，下半屏既没有
 * 粒子也没有网格，就是一块纯色 —— 看起来像背景没加载出来。
 *
 * 所以起淡点做成变量：场景开着时照旧 55%，场景关掉时推到 92%，只在贴近屏幕
 * 底边的地方收一下，其余保持满格网格。此时网格就是唯一的背景，不能让它自己
 * 淡没。
 */
:root {
  --grid-fade: 55%;
}

:root[data-quiet="on"],
:root[data-stage="on"] {
  --grid-fade: 92%;
}

.grid-bg::after {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(
    to bottom,
    transparent 0%,
    transparent var(--grid-fade),
    var(--void) 100%
  );
}

/* Every page-level layer must clear the grid. */
main,
.footer,
.dock,
.progress {
  position: relative;
  z-index: 1;
}

/* 8. Dock nav ============================================================ */

.dock {
  position: fixed;
  top: 20px;
  left: 0;
  right: 0;
  z-index: 200;
  display: flex;
  align-items: center;
  gap: 14px;
  padding: 0 var(--gutter);
  pointer-events: none;
}

.dock > * {
  pointer-events: auto;
}

.mark {
  width: var(--dock-btn);
  height: var(--dock-btn);
  flex: none;
  border-radius: 50%;
  border: 1px solid var(--line-2);
  background: var(--glass);
  backdrop-filter: blur(18px);
  -webkit-backdrop-filter: blur(18px);
  display: flex;
  align-items: center;
  justify-content: center;
  transition: border-color 0.3s, background 0.3s;
}

/* The SVG carries width/height attributes in the markup for the no-CSS case;
   this scales it with the smaller button rather than editing both pages. */
.mark svg {
  width: 21px;
  height: 21px;
}

.mark:hover {
  border-color: var(--accent);
  background: var(--glass-2);
}

.dock__bar {
  margin: 0 auto;
  height: var(--dock-h);
  display: flex;
  align-items: center;
  gap: 2px;
  padding: 0 4px 0 8px;
  border-radius: var(--r-pill);
  border: 1px solid var(--line-2);
  background: rgba(12, 10, 18, 0.55);
  backdrop-filter: blur(20px) saturate(150%);
  -webkit-backdrop-filter: blur(20px) saturate(150%);
}

.dock__link {
  position: relative;
  font-size: 13px;
  color: var(--mist-2);
  padding: 9px 13px;
  border-radius: var(--r-pill);
  white-space: nowrap;
  /* Colour only. The chip behind it is a ::before that scales — see below. */
  transition: color 0.32s var(--ease-out);
}

/*
 * THE HOVER CHIP, AND WHY IT IS A PSEUDO-ELEMENT.
 *
 * This used to be `transition: background 0.25s` straight on the link, which
 * is what made it feel stiff: `background` interpolates as a flat colour fade
 * on a box that is already full size, so the chip switches on in place. There
 * is no arrival — nothing grows, nothing eases, and the default timing
 * function is a symmetric ease that lands as abruptly as it starts.
 *
 * A ::before that scales from 0.82 with --ease-out reads as the chip settling
 * under the cursor, and it costs nothing: transform and opacity are the two
 * properties the compositor handles without a repaint, whereas animating
 * `background` repaints the element every frame.
 *
 * ::before, not ::after — the dot for the current section owns ::after, and
 * the note above the active-pill rules explains why the inner highlight there
 * is an inset shadow rather than a third layer.
 */
.dock__link::before {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  background: var(--glass-2);
  opacity: 0;
  transform: scale(0.82);
  transition: opacity 0.34s var(--ease-out), transform 0.34s var(--ease-out);
}

/* The label rides above the chip. .dock__link > span is in the markup already;
   without a stacking position of its own it would sit under the ::before. */
.dock__link > span {
  position: relative;
  display: inline-block;
  transition: transform 0.34s var(--ease-out);
}

/*
 * Gated on (hover: hover). Without it a tap on a touch device leaves the chip
 * stuck on the last link touched until something else takes focus, because
 * :hover latches there — and this bar is the site's primary navigation on
 * exactly those devices.
 */
@media (hover: hover) {
  .dock__link:hover {
    color: var(--mist);
  }

  .dock__link:hover::before {
    opacity: 1;
    transform: scale(1);
  }

  /* A single pixel. Enough to read as the label lifting to meet the cursor,
     small enough that a row of eight of them does not look restless. */
  .dock__link:hover > span {
    transform: translateY(-1px);
  }
}

/* Keyboard users get the chip too — the focus ring alone does not show which
   item is armed as clearly as the fill does. */
.dock__link:focus-visible::before {
  opacity: 1;
  transform: scale(1);
}

@media (prefers-reduced-motion: reduce) {
  /* Keep the chip, drop the growth: the state change is information, the
     scaling is decoration. */
  .dock__link::before { transform: none; transition: opacity 0.2s; }
  .dock__link:hover::before { transform: none; }
  .dock__link > span,
  .dock__link:hover > span { transform: none; transition: none; }
}

/* Liquid-glass active pill.

   Four layers make it read as glass rather than as a grey chip, and dropping
   any one of them loses the effect:

     1. a translucent fill, so the backdrop is visibly behind it;
     2. backdrop-filter blur AND saturate — the saturate is what stops the
        blurred backdrop going muddy and is the part most often left out;
     3. a hairline border that is brighter at the top than the bottom, which is
        what suggests a lit edge rather than a flat outline;
     4. an inset highlight along the top edge plus a soft outer shadow, giving
        the pill a thickness.

   The inner highlight is an inset box-shadow rather than a ::before, because
   ::after is already spoken for by the dot below. */
.dock__link[aria-current="true"],
.dock__link[aria-current="page"] {
  color: var(--mist);
  background: linear-gradient(
    180deg,
    rgba(255, 255, 255, 0.14) 0%,
    rgba(255, 255, 255, 0.06) 100%
  );
  backdrop-filter: blur(14px) saturate(180%);
  -webkit-backdrop-filter: blur(14px) saturate(180%);
  border: 1px solid rgba(255, 255, 255, 0.16);
  border-top-color: rgba(255, 255, 255, 0.3);
  border-bottom-color: rgba(255, 255, 255, 0.08);
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.28),
    inset 0 -1px 2px rgba(255, 255, 255, 0.06),
    0 4px 14px -4px rgba(0, 0, 0, 0.55);

  /* The border would otherwise add 2px and nudge neighbouring links as the
     active item moves along the bar. */
  padding: 8px 12px;
}

/* Light theme needs the opposite lighting model: the glass sits on a pale
   backdrop, so a white fill disappears and the edge must darken instead. */
:root[data-theme="light"] .dock__link[aria-current="true"],
:root[data-theme="light"] .dock__link[aria-current="page"] {
  background: linear-gradient(
    180deg,
    rgba(255, 255, 255, 0.75) 0%,
    rgba(255, 255, 255, 0.45) 100%
  );
  border-color: rgba(16, 26, 47, 0.12);
  border-top-color: rgba(255, 255, 255, 0.9);
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.95),
    0 4px 14px -6px rgba(16, 26, 47, 0.28);
}

/* backdrop-filter is the one layer with no graceful degradation — without it
   the fill is just a pale rectangle. Where it is unsupported, lean on opacity. */
@supports not (backdrop-filter: blur(1px)) {
  .dock__link[aria-current="true"],
  .dock__link[aria-current="page"] {
    background: rgba(255, 255, 255, 0.12);
  }
}

/* Dot marks the section you are in. */
.dock__link[aria-current="true"]::after,
.dock__link[aria-current="page"]::after {
  content: "";
  position: absolute;
  left: 50%;
  bottom: 3px;
  width: 3px;
  height: 3px;
  margin-left: -1.5px;
  border-radius: 50%;
  background: var(--accent);
}

.dock__cta {
  font-size: 12.5px;
  font-weight: 500;
  padding: 10px 16px;
  border-radius: var(--r-pill);
  background: var(--mist);
  color: var(--void);
  display: inline-flex;
  align-items: center;
  gap: 7px;
  white-space: nowrap;
  transition: background 0.25s, transform 0.25s var(--ease-out);
}

.dock__cta:hover {
  background: #fff;
  color: var(--void);
  transform: translateY(-1px);
}

.dock__lang {
  width: var(--dock-btn);
  height: var(--dock-btn);
  flex: none;
  border-radius: 50%;
  border: 1px solid var(--line-2);
  background: var(--glass);
  backdrop-filter: blur(18px);
  -webkit-backdrop-filter: blur(18px);
  display: flex;
  align-items: center;
  justify-content: center;
  font-family: var(--font-mono);
  font-size: 11px;
  color: var(--mist-2);
  transition: border-color 0.3s, color 0.3s;
}

.dock__lang:hover {
  border-color: var(--accent);
  color: var(--mist);
}

.burger {
  display: none;
  width: var(--dock-h);
  height: var(--dock-h);
  flex: none;
  border-radius: 50%;
  border: 1px solid var(--line-2);
  background: var(--glass);
  backdrop-filter: blur(18px);
  -webkit-backdrop-filter: blur(18px);
  cursor: pointer;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 5px;
}

.burger span {
  width: 17px;
  height: 1.5px;
  background: var(--mist);
  display: block;
}

.burger span:last-child {
  width: 11px;
  background: var(--accent);
}

@media (max-width: 1079px) {
  .dock__bar,
  .dock__lang {
    display: none;
  }
  .burger {
    display: flex;
  }
  .dock {
    justify-content: space-between;
  }
}

/* 8b. Module rail ========================================================
   Fixed index of the page's modules. The active row is driven by
   ScrollTrigger, and a gold beam slides between rows as sections change. */

.modules {
  position: fixed;
  right: 30px;
  top: 50%;
  transform: translateY(-50%);
  z-index: 150;
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: 16px;
  /* The projects strip is full-bleed and slides underneath, so the rail has
     to stay legible over arbitrary content without carrying a visible panel
     that would weigh down the corner. */
  text-shadow: 0 0 10px var(--void), 0 0 4px var(--void);
}

.modules__hud {
  display: flex;
  align-items: baseline;
  gap: 3px;
  font-family: var(--font-mono);
  color: var(--mist-3);
  padding-right: 2px;
}

.modules__pct {
  font-size: 15px;
  color: var(--mist);
  font-variant-numeric: tabular-nums;
  letter-spacing: 0.02em;
}

.modules__pctmark {
  font-size: 9px;
  color: var(--mist-4);
}

.modules__list {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  /* The rail's spine. */
  border-right: 1px solid var(--line);
  padding-right: 14px;
}

/* Slides to whichever row is active. */
.modules__beam {
  position: absolute;
  right: -1px;
  top: 0;
  width: 1px;
  height: 26px;
  background: var(--accent);
  box-shadow: 0 0 10px 1px rgba(77, 157, 255, 0.6);
  will-change: transform;
}

.modules__item {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: 10px;
  height: 26px;
  color: var(--mist-4);
  white-space: nowrap;
  transition: color 0.35s var(--ease-out);
}

.modules__item:hover {
  color: var(--mist-2);
}

.modules__name {
  font-family: var(--font-mono);
  font-size: 9.5px;
  letter-spacing: 0.2em;
  text-transform: uppercase;
  /* Held back until active, so the rail reads as one live row plus context. */
  opacity: 0.42;
  transform: translateX(4px);
  transition: opacity 0.35s var(--ease-out), transform 0.45s var(--ease-out);
}

html[lang="zh-Hans"] .modules__name {
  letter-spacing: 0.14em;
  font-family: var(--font-body);
  font-size: 11px;
}

.modules__idx {
  font-family: var(--font-mono);
  font-size: 8.5px;
  color: var(--mist-4);
  transition: color 0.35s;
}

.modules__tick {
  width: 12px;
  height: 1px;
  background: var(--line-2);
  display: block;
  transition: width 0.45s var(--ease-out), background 0.35s;
}

.modules__item[aria-current="true"] {
  color: var(--mist);
}

.modules__item[aria-current="true"] .modules__name {
  opacity: 1;
  transform: none;
}

.modules__item[aria-current="true"] .modules__idx {
  color: var(--accent);
}

.modules__item[aria-current="true"] .modules__tick {
  width: 26px;
  background: var(--accent);
}

/* Below the dock breakpoint the rail would crowd the content. */
@media (max-width: 1279px) {
  .modules {
    display: none;
  }
}

/* 9. Mobile menu ========================================================= */

/* THE PANEL IS DARK IN BOTH THEMES, SO ITS TOKENS MUST BE TOO.

   `background` here is a hardcoded near-black and always has been — this is a
   full-screen overlay, and it is meant to read as one in either theme. But
   everything inside it drew its colour from the page tokens, which flip. In
   the light theme --mist becomes #101a2f, so the nav labels rendered as dark
   navy on a near-black plate: about 1.3:1, effectively invisible, which is
   what the menu was doing.

   Redeclaring the tokens on .menu fixes every descendant at once — .micro,
   .display, the hairlines, .btn--ghost — because each of them reads the token
   rather than a literal. That is the whole reason to scope custom properties
   to a component instead of patching each rule.

   Same principle as .dev__screen in section 14 and the CAPTCHA plate: if an
   element's background does not follow the theme, its foreground must not
   either. */
.menu {
  --mist: #f2f2f3;
  --mist-2: rgba(242, 242, 243, 0.72);
  --mist-3: rgba(242, 242, 243, 0.62);
  --mist-4: rgba(242, 242, 243, 0.34);
  --line: rgba(255, 255, 255, 0.08);
  --line-2: rgba(255, 255, 255, 0.15);
  --glass: rgba(255, 255, 255, 0.03);
  --glass-2: rgba(255, 255, 255, 0.06);
  /* The light theme darkens accent-hi so it holds up on a pale plate; on this
     plate it has to go back to the lighter one. */
  --accent-hi: #5b8def;

  /* --void is INK here, not background, and leaving it out was a bug.

     `.btn--light` is `background: var(--mist); color: var(--void)` — a pale
     pill with the page's darkest colour as its label. Redeclaring --mist
     without --void gave the 开始合作 button a near-white plate and, in the
     light theme where --void is #eef2f9, near-white text on it: an
     apparently empty pill.

     The lesson generalises. When scoping a palette to a component, redeclare
     the whole set the component's descendants might read, not just the ones
     whose role you happen to be thinking about. A token named for a
     background is still a foreground somewhere. */
  --void: #0a0a0c;
  --void-2: #101013;
  --deep: #14161c;
  --deep-2: #0f1116;

  position: fixed;
  inset: 0;
  z-index: 250;
  background: rgba(8, 7, 13, 0.9);
  backdrop-filter: blur(26px);
  -webkit-backdrop-filter: blur(26px);
  display: flex;
  flex-direction: column;

  /* THE PANEL MUST SCROLL ITSELF, and for a while it did not.

     `inset: 0` makes this exactly one viewport tall, and open() sets
     `document.body.style.overflow = "hidden"` so the page behind is frozen.
     With no overflow rule here, neither surface scrolled: eight menu rows at
     ~71px each (30px display type plus 17px padding top and bottom) come to
     ~568px, and with the header, the footer's two buttons and the vertical
     padding the panel wants ~860px. On any phone shorter than that — which is
     all of them — .menu__foot sat below the fold with no way to reach it.
     The language switch and "Start a project" were simply gone.

     It looked fine on a desktop browser resized to a narrow window, because
     that window is tall. Checking a mobile layout by narrowing a desktop
     window tests the width and hides exactly this class of bug.

     overscroll-behavior: contain stops the scroll chaining to the frozen page
     behind once this list hits its end — without it the rubber-band on iOS
     drags the locked body around under the panel. */
  overflow-y: auto;
  overscroll-behavior: contain;
  -webkit-overflow-scrolling: touch;

  /* The bottom pad clears the iOS home indicator, which otherwise sits on top
     of the last button — the one this fix exists to make reachable. max()
     keeps the original 40px everywhere the inset is 0 or unsupported. */
  padding: 24px 7vw max(40px, env(safe-area-inset-bottom, 0px) + 24px);
}

.menu__top {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 40px;
}

/* Matches the dock's round buttons, which came down from 72px — this one was
   left behind and read as the largest thing on the panel. */
.menu__close {
  width: var(--dock-btn);
  height: var(--dock-btn);
  border-radius: 50%;
  border: 1px solid var(--line-2);
  background: transparent;
  color: var(--mist);
  font-size: 20px;
  line-height: 1;
  cursor: pointer;
}

.menu__list {
  display: flex;
  flex-direction: column;
}

.menu__item {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 16px;
  padding: 17px 0;
  border-bottom: 1px solid var(--line);
}

.menu__item b {
  font-family: var(--font-display);
  font-style: var(--display-style);
  font-weight: 500;
  font-size: 30px;
  color: var(--mist);
}

html[lang="zh-Hans"] .menu__item b {
  font-style: normal;
  letter-spacing: 0.05em;
}

.menu__foot {
  margin-top: auto;
  display: flex;
  flex-direction: column;
  gap: 12px;
  padding-top: 30px;
}

/* 10. Intro ============================================================== */

/*
 * THE OPENING IS ALWAYS DARK, IN BOTH THEMES, so it redeclares the palette.
 *
 * This is the same fix .menu carries, and it is here for the same reason —
 * arrived at the same way, by seeing it broken. The curtains paint a fixed
 * near-black, but the wordmark inside reads `color: var(--mist)`, and in the
 * light theme --mist is #101a2f: dark navy. Dark navy type on a near-black
 * plate measures about 1.3:1, which is not "low contrast", it is invisible.
 *
 * It used to work by accident. `.intro` was `background: var(--void)`, and in
 * the light theme --void is #eef2f9 — so the whole opening went light and the
 * navy type was legible. Moving the paint onto the curtain halves (needed, so
 * they can part over the real page instead of over a plate) took that away
 * and left the type behind.
 *
 * Redeclaring the tokens here fixes every descendant at once rather than
 * patching each rule, and pins the opening to the gold-on-black it was
 * designed as. Redeclare the WHOLE set a descendant might read, not only the
 * ones whose role you happen to be thinking about — .menu's comment explains
 * what leaving one out costs.
 */
.intro {
  /* 标志与中缝的金色。深浅两套主题各一个值 —— #E2B95E 在浅色底
     (#eef2f9) 上对比度只有约 1.6:1，标志会糊成一团。 */
  --intro-mark: #e2b95e;

  /* 幕布接缝的投影。深色底上 55% 的黑看不出来，是纯粹的立体感；
     浅色底上同样的值会变成一道横穿标志的黑带 —— 截图里就是这个。 */
  --intro-edge: rgba(0, 0, 0, 0.55);

  /* 中缝的光晕。深色底靠它把 2px 的线撑出来；浅色底不需要，
     深金线本身就够清楚，加了光晕反而糊成一团。 */
  --intro-glow: 0 0 20px var(--intro-mark);

  position: fixed;
  inset: 0;
  z-index: 500;

  /* TRANSPARENT ON PURPOSE — the paint lives on .intro__curtain.
     The opening parts down the middle, and a background on this element
     would sit behind the halves and stay put while they separated, so the
     page would be revealed onto a black plate rather than onto itself.
     The failsafe below still hides everything, children included. */
  background: transparent;
  display: flex;
  align-items: center;
  justify-content: center;
  border: 0;
  width: 100%;
  cursor: pointer;
  font-family: inherit;

  /*
   * Failsafe. This overlay is opaque and covers the document, so if app.js
   * never runs — a 404, a parse error, a blocked script — nothing else would
   * remove it and the site is a black screen.
   *
   * IT IS SHORT BECAUSE app.js TURNS IT OFF, which is what lets those two
   * requirements stop fighting. The old value was six seconds: long enough
   * not to cut a healthy opening short, and therefore also six seconds of
   * black screen for anyone whose JavaScript failed. Every attempt to tune
   * one number for both cases makes one of them worse — and the opening got
   * longer when the curtain was added, so the safe number was rising.
   *
   * app.js adds .intro--live the moment it takes control, which cancels this.
   * So the timer now means only "JavaScript did not arrive", and 2s is a
   * reasonable time to wait before concluding that.
   */
  animation: intro-failsafe 0s linear 2s forwards;
}

@keyframes intro-failsafe {
  to {
    opacity: 0;
    visibility: hidden;
    pointer-events: none;
  }
}

/* app.js owns the overlay from here; the failsafe above must not fire mid
   animation. Added as the intro initialises, so it can only ever be absent
   when the script did not run. */
.intro--live {
  animation: none;
}

/* --- the curtain -----------------------------------------------------------
 *
 * Two halves carrying the opening's backdrop, parted at the end like a stage
 * curtain. The mark itself already has a vertical channel cut through it by
 * #m-intro, so the site's logo is a shape that is split down the middle — the
 * opening now does the same thing to the screen it is drawn on.
 *
 * WIDTH IS 50.4%, NOT 50%. At an odd viewport width two 50% halves round to a
 * seam of transparent pixels down the centre, through which the page shows as
 * a hairline before anything has moved. The overlap is invisible while closed
 * and gone the moment they part.
 */
.intro__curtain {
  position: absolute;
  top: 0;
  bottom: 0;
  width: 50.4%;

  /*
   * A BLACK CURTAIN OVER A BLACK PAGE IS AN INVISIBLE CURTAIN, and the first
   * version of this was exactly that: --void on the halves, --void on <body>,
   * so the panels parted across a page of the same colour and the only thing
   * anyone saw was the logo fading. The motion was running perfectly and
   * looked like nothing had changed.
   *
   * The fix is not a lighter curtain — that would flash a grey rectangle over
   * the hero before it moves. It is to make the EDGES legible: a gradient
   * that lifts slightly toward the join, so each half has visible form, and
   * the gold rule plus cast shadow below to mark where it ends. You read the
   * movement from the edge travelling, not from the fill.
   */
  /* 向接缝方向略微加深，让两半有厚度。83% 起变而不是 62%：过早起变会在
     浅色底上把中间三分之一整片压暗，正是黑带观感的另一半来源。 */
  background:
    linear-gradient(var(--edge-dir, to right), var(--void) 83%, var(--void-2) 100%);

  will-change: transform;
}

/*
 * The inner edge: a gold hairline that rides outward with its half as the
 * curtain opens. This is the part the eye actually tracks.
 *
 * HIDDEN UNTIL THE PARTING ACTUALLY STARTS, and that is the whole point of
 * the opacity below. These are pinned to the centre while the curtains are
 * closed, so at rest the two of them sit a fraction of a percent apart and
 * read as ONE STATIC GOLD LINE down the middle of the screen. In the full
 * tier that lasted a moment and then travelled. In the reduced tier the
 * curtains never move at all, so it simply stayed there — a permanent gold
 * bar through the opening that no timeline was ever going to clear, which is
 * exactly what it looked like.
 *
 * A line that marks an edge should not be visible before the edge moves.
 */
.intro__curtain::after {
  content: "";
  position: absolute;
  top: 0;
  bottom: 0;
  width: 1px;
  background: var(--intro-mark);
  opacity: 0;
  transition: opacity 0.25s ease-out;
}

/* Added by app.js the instant the halves begin to travel, so the edges light
   up as part of the movement rather than announcing it. A class rather than a
   tween because GSAP cannot target a pseudo-element directly. */
.intro--parting .intro__curtain::after {
  opacity: 1;
}

.intro__curtain--l {
  left: 0;
  --edge-dir: to right;
  box-shadow: 6px 0 24px var(--intro-edge);
}

.intro__curtain--l::after { right: 0; }

.intro__curtain--r {
  right: 0;
  --edge-dir: to left;
  box-shadow: -6px 0 24px var(--intro-edge);
}

.intro__curtain--r::after { left: 0; }

/* A gold hairline on the join, so the split reads as deliberate rather than
   as the panel tearing. It is the only thing on screen that marks where the
   curtain will open, and it is the same gold as the mark. */
.intro__seam {
  position: absolute;
  top: 0;
  bottom: 0;
  left: 50%;

  /* Above the curtains, below the stage. It used to rely on markup order
     alone, which works until someone reorders the three spans. */
  z-index: 1;

  width: 2px;

  /*
   * CENTRED WITH margin-left, NOT WITH translateX, and this is the bug that
   * made the line look like it was not there.
   *
   * It was `transform: translateX(-0.5px) scaleY(0)`. GSAP does not add to
   * the transform property, it OWNS it: the moment it animates scaleY it
   * rewrites the whole declaration from its own parsed record, and a
   * half-pixel nudge written only in the stylesheet is not reliably part of
   * that record. Combined with a 1px width at 55% alpha, the result was a
   * hairline that was sub-pixel, dim, and sometimes straddling two columns of
   * pixels — visible in principle and invisible in practice.
   *
   * Keeping the offset in the box model leaves `transform` entirely to GSAP,
   * which is the rule to follow anywhere the two might both write it.
   */
  margin-left: -1px;

  /* Origin at the middle is what makes it open UPWARD AND DOWNWARD AT ONCE
     from the centre, rather than dropping from the top. */
  transform: scaleY(0);
  transform-origin: 50% 50%;

  /* Brightest at the centre, where the split begins, fading out before the
     edges so it reads as a seam of light rather than a drawn border. */
  background: linear-gradient(
    to bottom,
    transparent,
    var(--intro-mark) 12%,
    var(--intro-mark) 50%,
    var(--intro-mark) 88%,
    transparent
  );

  /* The glow is what makes 2px legible against --void. Without it the line
     is technically painted and practically unnoticeable. */
  box-shadow: var(--intro-glow);
}

/* SVG 的 stroke/fill 是呈现属性，CSS 规则优先级更高 —— 所以这里能覆盖
   markup 里写死的 #E2B95E，不必去改两个 blade 模板。 */
.intro__tier { stroke: var(--intro-mark); }
.intro__fill { fill: var(--intro-mark); }

/* 浅色主题：金色压深，否则标志与中缝在浅底上几乎看不见。
   幕布与文字靠 --void / --mist 自动翻转，无需在此重复。 */
:root[data-theme="light"] .intro {
  --intro-mark: #96731a;

  /* 投影降到近乎无。深色底上 0.55 的黑是"接缝的厚度"，浅色底上是"一道
     脏痕" —— 同一个值在两套主题里读出来完全相反。 */
  --intro-edge: rgba(16, 26, 47, 0.07);

  /* 浅色底不需要光晕，深金线自身对比已经足够。 */
  --intro-glow: none;
}

.intro__stage {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 20px;

  /* Above the curtains AND above the seam. The stacking is load-bearing —
     the seam is a full-height bar through the middle of the panel, and at
     z-index 1 it would draw straight across the wordmark. */
  position: relative;
  z-index: 2;
}

/* 标语行同样带 letter-spacing(0.24em)，末字后的空隙会让它相对标志左移。
   开场这三行是叠在一起看的，任何一行偏出去都会显出来。 */
.intro__tag {
  margin-right: -0.24em;
}

.intro__mark {
  margin-bottom: 6px;
}

/* Start undrawn. Set here rather than by GSAP's .from() so the mark is never
   painted complete for a frame and then snapped back to empty. */
.intro__tier {
  stroke-dasharray: 1;
  stroke-dashoffset: 1;
}

.intro__fill {
  opacity: 0;
}

.intro__word {
  font-family: var(--font-display);
  font-size: clamp(30px, 5vw, 54px);
  color: var(--mist);
  letter-spacing: 0.3em;

  /* letter-spacing 会在最后一个字后面也留出 0.3em，居中时字形整体左移
     0.15em。这里用负右外边距把那段空隙从布局里减掉，字形就落在真正的
     中轴上。
     原来写的是 text-indent: 0.3em —— 那是往右推 0.3em，等于把 0.15em 的
     偏差补成了反方向的 0.15em，54px 字号下约 8px，正好是看着"怪怪的"
     那点错位。 */
  margin-right: -0.3em;
}

.intro__rule {
  width: min(240px, 50vw);
  height: 1px;
  background: var(--line-2);
  position: relative;
  overflow: hidden;
}

.intro__rule i {
  position: absolute;
  inset: 0;
  background: var(--intro-mark);
  transform: scaleX(0);

  /* 从中间向两侧展开，与中缝的开合方向一致。
     原本是 left —— 线从左端往右伸，和上方居中的标志不同轴，
     动画过程中一直是偏的，只有走完最后一帧才对齐。 */
  transform-origin: center;
}

/* 11. Hero & stack ======================================================= */

.hero {
  position: relative;
  min-height: 100vh;
  min-height: 100svh;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  text-align: center;
  padding: 150px var(--gutter) 60px;
  overflow: hidden;
}

/* The panel stack sits behind the copy and in front of the sky. */
.factory {
  position: absolute;
  left: 50%;
  top: 56%;
  width: min(115vh, 96vw);
  aspect-ratio: 1;
  transform: translate(-50%, -50%);
  z-index: 1;
  pointer-events: none;
}

/* Hover caption. IBM Plex Mono at 12px per the brief; fade + translateY, no
   scale and no glow. Positioned by app.js from the model's projected centre,
   so it tracks the product it names even as the scene parallaxes. */
.factory__label {
  position: fixed;
  z-index: 3;
  transform: translate(-50%, 6px);
  font-family: var(--font-mono);
  font-size: 12px;
  letter-spacing: 0.08em;
  text-transform: none;
  color: var(--mist);
  background: var(--float);
  border: 1px solid var(--line-2);
  padding: 7px 12px;
  border-radius: 4px;
  white-space: nowrap;
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.28s var(--ease-out), transform 0.28s var(--ease-out);
}

.factory__label[data-on="1"] {
  opacity: 1;
  transform: translate(-50%, -4px);
}

.factory canvas {
  width: 100%;
  height: 100%;
}

/* Shown if WebGL is unavailable or motion is reduced. The old fallback was a
   blurred gradient sphere; now that the hero is three panels, the stand-in
   has to be three panels too, or the reduced-motion visitor sees a completely
   different composition from everyone else. Static, no filters, three boxes. */
.factory__still {
  position: absolute;
  inset: 0;
}

.factory__still i {
  position: absolute;
  display: block;
  border: 1px solid var(--line-2);
  border-radius: 10px;
  background: linear-gradient(
    160deg,
    rgba(127, 176, 232, 0.09),
    rgba(127, 176, 232, 0.02)
  );
}

/* Proportions and offsets mirror the PANELS table in stack.js. */
.factory__still i:nth-child(1) {
  left: 20%;
  top: 30%;
  width: 41%;
  aspect-ratio: 16 / 10;
}

.factory__still i:nth-child(2) {
  left: 46%;
  top: 47%;
  width: 37%;
  aspect-ratio: 16 / 10;
}

.factory__still i:nth-child(3) {
  left: 12%;
  top: 45%;
  width: 12%;
  aspect-ratio: 1 / 1.92;
  border-color: rgba(77, 157, 255, 0.38);
}

.hero__inner {
  position: relative;
  z-index: 2;
  width: 100%;
  max-width: 1180px;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 26px;
}

.hero h1 {
  max-width: 15ch;
}

/* Each line is masked and rises into place. */
.line {
  display: block;
  overflow: hidden;
}

.line > span {
  display: block;
}

.hero__sub {
  max-width: 60ch;
  font-size: 15.5px;
  line-height: 1.75;
  color: var(--mist-2);
}

.hero__actions {
  display: flex;
  gap: 12px;
  flex-wrap: wrap;
  justify-content: center;
}

/* Follows however many tiles the panel is set to.

   Was repeat(2, …) — a hard two-column grid from when the tiles were two fixed
   slots in the markup. Now that they are rows an editor adds and removes, a
   third tile dropped onto its own line and sat there half-width beside a gap.

   flex-wrap rather than auto-fit: auto-fit reserves a full track per column and
   would leave an empty slot when the count does not divide evenly. Wrapping
   puts N tiles on one line while they fit and breaks cleanly when they do not,
   so 2, 3 or 5 all look deliberate. The 250px cap and 190px basis keep a lone
   tile from stretching the width of the hero. */
.hero__stats {
  display: flex;
  flex-wrap: wrap;
  gap: 16px;
  margin-top: 14px;
}

/* --per-row comes from the panel (首屏 ▸ 数据卡片 每行数量) and is set inline on
   the wrapper. The width is computed from it rather than being a fixed cap, so
   "3 per row" means exactly three — not "as many 250px tiles as happen to fit",
   which is what the previous max-width gave and why a fourth tile wrapped
   whatever the intent was.

   The gap is subtracted once per gutter, hence (n - 1). Falls back to 3 if the
   field is ever blank. */
.hero__stats > .hstat {
  --gap: 16px;
  --w: calc((100% - (var(--per-row, 3) - 1) * var(--gap)) / var(--per-row, 3));
  flex: 0 0 var(--w);
  max-width: var(--w);
  min-width: 0;
}

.hstat {
  padding: 20px 22px 18px;
  text-align: left;
  display: flex;
  flex-direction: column;
  gap: 10px;
}

.hstat__icon {
  color: var(--mist-3);
}

.hstat__n {
  font-family: var(--font-display);
  font-style: var(--display-style);
  font-size: 30px;
  line-height: 1;
  color: var(--mist);
}

html[lang="zh-Hans"] .hstat__n {
  font-style: normal;
}

.hstat__label {
  font-size: 12px;
  color: var(--mist-3);
  line-height: 1.45;
}

.hero__clients {
  margin-top: 34px;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 16px;
}

.hero__sectors {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: clamp(18px, 3.4vw, 52px);
  list-style: none;
  margin: 0;
  padding: 0;
}

.hero__sectors li {
  font-family: var(--font-display);
  font-style: var(--display-style);
  font-size: clamp(15px, 1.5vw, 22px);
  color: var(--mist-3);
  transition: color 0.3s;
}

html[lang="zh-Hans"] .hero__sectors li {
  font-style: normal;
  letter-spacing: 0.08em;
}

.hero__sectors li:hover {
  color: var(--mist);
}

@media (max-width: 719px) {
  .hero {
    padding-top: 130px;
  }
  /* One per row on phones, whatever the count. grid-template-columns lived
     here and stopped meaning anything when the container became flex — it was
     silently ignored rather than erroring, so the tiles would have gone on
     sharing a cramped line. */
  .hero__stats {
    width: 100%;
    max-width: 320px;
  }
  /* Phones override the panel's choice: three 100px tiles is not a layout.
     Setting --per-row rather than restating the widths means the calc above
     stays the single place the arithmetic lives. */
  .hero__stats > .hstat {
    --per-row: 1;
  }
  .hero__actions .btn {
    flex: 1 1 100%;
  }
}

/* 12. Section furniture ================================================== */

.shead {
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  gap: 40px;
  margin-bottom: clamp(40px, 6vh, 72px);
}

.shead__copy {
  max-width: 62ch;
}

.shead .micro {
  display: block;
  margin-bottom: 18px;
}

.shead p {
  margin-top: 18px;
}

/*
 * The way out of a module to the page that holds all of it — "All updates",
 * "All case studies", "More about us".
 *
 * Named under .shead because that is where two of the three sit, but it is
 * used inside .about__copy as well, so it must not depend on that ancestor.
 * The arrow travels on hover, which is the same affordance .dock__cta uses.
 */
.shead__all {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  color: var(--mist-3);
  white-space: nowrap;
  transition: color 0.3s var(--ease-out);
}

.shead__all:hover { color: var(--accent-hi); }

.shead__all svg {
  transition: transform 0.35s var(--ease-out);
}

.shead__all:hover svg { transform: translate(2px, -2px); }

/* .shead .micro is display:block with an 18px gap under it — correct for the
   eyebrow above a heading, wrong for a link sitting beside one. */
.shead .shead__all {
  display: inline-flex;
  margin-bottom: 0;
}

@media (max-width: 1079px) {
  .shead {
    flex-direction: column;
    align-items: flex-start;
    gap: 24px;
  }
}

/* 13. Projects =========================================================== */

/* GSAP pins this section and drives .rail sideways. Without JS it stays a
   normal horizontal scroller, so nothing is lost. */
/* The perspective lives HERE, not on .rail.
   .rail is `width: max-content` — several thousand pixels wide — so a
   perspective declared on it would put its origin at the centre of the whole
   strip, thousands of pixels off screen, and every visible card would be
   viewed from an absurd angle. Declared on the viewport, the origin is the
   centre of what the visitor is actually looking at, which is the only place
   a vanishing point belongs. .rail then needs preserve-3d to pass the
   perspective through to the cards instead of flattening them. */
.projects__viewport {
  overflow-x: auto;
  overflow-y: hidden;
  scrollbar-width: none;
  scroll-padding-left: var(--gutter);
  scroll-snap-type: x proximity;
  perspective: 1600px;
}

.projects__viewport::-webkit-scrollbar {
  display: none;
}

body[data-pinned] .projects__viewport {
  overflow: hidden;
}

.rail {
  display: flex;
  gap: 28px;
  padding: 0 var(--gutter) 10px;
  width: max-content;
  will-change: transform;
  transform-style: preserve-3d;
}

/* Two nested 3D transforms, each with a single owner, so they can never fight
   over the same property: .card carries the ARC (scroll-driven — where this
   card sits on the curve, and how far out of focus it is), while .card__media
   carries the TILT (pointer-driven). Splitting them across two elements is
   what keeps the maths honest. */
.card {
  flex: none;
  width: min(430px, 76vw);
  scroll-snap-align: start;
  color: var(--mist);
  transform-style: preserve-3d;
  will-change: transform, filter, opacity;
}

.card__media {
  position: relative;
  aspect-ratio: 550 / 348;
  overflow: hidden;
  border-radius: var(--r-card);
  border: 1px solid var(--line);
  background: var(--void-2);
  transform-style: preserve-3d;
  transition: border-color 0.5s var(--ease-out), box-shadow 0.5s var(--ease-out);
}

/* --- why every card hover is written twice ------------------------------

   `.is-hover` is set by app.js from the pointer's coordinates, and it is
   paired with `:hover` on every rule below rather than replacing it.

   These cards sit in a preserve-3d subtree, rotated on an arc, inside a
   ScrollTrigger pin-spacer. Some browser/GPU combinations do not hit-test
   through that — the cards paint, but the pointer never lands on one, so
   `:hover` never matches and every rule here is silently dead. It presents as
   "hovering a project does nothing on my computer" while being perfect on the
   machine next to it.

   Native `:hover` stays first because where it does work it is the better
   answer: it follows the drawn shape rather than the bounding box, and it
   needs no JavaScript. The class is the floor under it. See initPeek(). */
.card:hover .card__media,
.card.is-hover .card__media {
  border-color: rgba(77, 157, 255, 0.55);
  box-shadow: 0 34px 80px -24px rgba(0, 0, 0, 0.8),
    0 0 70px -14px rgba(77, 157, 255, 0.4);
}

.card__media img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  opacity: 0.86;
  transition: transform 0.7s var(--ease-out), opacity 0.5s;
}

.card:hover .card__media img,
.card.is-hover .card__media img {
  transform: scale(1.05);
  opacity: 1;
}

/* Specular that tracks the pointer. --mx/--my are written by app.js; the
   fallback centre means it degrades to a static sheen if JS never runs. */
.card__media::after {
  content: "";
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: radial-gradient(
    420px circle at var(--mx, 50%) var(--my, 50%),
    rgba(168, 208, 255, 0.17),
    transparent 62%
  );
  opacity: 0;
  transition: opacity 0.45s var(--ease-out);
}

.card:hover .card__media::after,
.card.is-hover .card__media::after {
  opacity: 1;
}

.card__meta {
  transition: transform 0.5s var(--ease-out);
}

.card:hover .card__meta,
.card.is-hover .card__meta {
  transform: translateY(-4px);
}

/* --- the slide-up detail ------------------------------------------------

   Real markup, not a tooltip built in JS: it is indexable, it is there
   without JavaScript, and a keyboard reaches it. Hidden by transform rather
   than `display`, so the text stays in the accessibility tree and the panel
   can animate.

   The gradient runs to opaque at the bottom so the copy always has a plate
   under it whatever the card image turns out to be — these are placeholders
   today and will be real screenshots once the admin panel lands, and a
   caption that is legible over grey SVG and illegible over a photograph is a
   bug waiting to happen. */
/*
 * THE HOVER DESCRIPTION — A FADE THAT ALSO RISES, NOT A PANEL THAT SLIDES.
 *
 * This used to animate transform alone, from translateY(101%) to 0: the plate
 * was fully opaque the whole way, so what actually crossed the image was a
 * hard horizontal edge travelling half the height of the card. That edge is
 * the abruptness. Nothing was fading — there was no opacity in the transition
 * at all — so the panel arrived at full strength the instant any part of it
 * was on screen, and left the same way.
 *
 * Now opacity carries the change and the movement is a hint: 14% of the plate
 * height rather than 101%, which reads as the text settling into place rather
 * than a shutter closing over the picture.
 *
 * ASYMMETRIC, AND SLOWER LEAVING THAN ARRIVING. 0.6s in, 0.95s out.
 *
 * The first version had that backwards — 0.5s in and 0.34s out, on the general
 * principle that a hover state should get out of the way quickly. That is the
 * right default for a button and the wrong one for a paragraph: this is
 * something you were reading, and snatching it away faster than it arrived is
 * what made it feel abrupt even after the fade was added.
 *
 * THE CURVE MATTERS MORE THAN THE DURATION HERE, and this is the part a longer
 * duration alone would not have fixed. --ease-out is cubic-bezier(0.22, 1,
 * 0.36, 1): it front-loads the change, so roughly two thirds of the opacity is
 * gone in the first quarter of the time and the rest crawls out near zero. On
 * the way in that is exactly right — the panel arrives and settles. On the way
 * out it reads as a snap followed by a long tail, which is precisely the
 * complaint. The exit is linear instead, so the plate dissolves at one steady
 * rate the whole way down.
 *
 * The delays live on the hover state only, so the panel never hesitates before
 * it starts to go, and everything inside it leaves together as one piece
 * rather than in the reverse of the order it arrived.
 */
.card__detail {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  padding: 46px 20px 18px;
  opacity: 0;
  transform: translateY(14%);
  transition: opacity 0.95s linear, transform 0.95s var(--ease-out);
  background: linear-gradient(
    to bottom,
    transparent 0%,
    rgba(8, 7, 13, 0.82) 38%,
    rgba(8, 7, 13, 0.95) 100%
  );
}

.card:hover .card__detail,
.card.is-hover .card__detail,
.card:focus-within .card__detail {
  opacity: 1;
  transform: translateY(0);
  transition: opacity 0.6s var(--ease-out), transform 0.6s var(--ease-out);
}

/*
 * The words come in just behind their own backdrop — enough that the plate is
 * already dark by the time the sentence is legible, which is what stops the
 * text appearing to fade in over the photograph underneath it.
 *
 * Going out they share the plate's timing exactly, delay included (none), so
 * the whole panel dissolves as one object.
 */
.card__detail p,
.card__detail .card__chips {
  opacity: 0;
  transform: translateY(6px);
  transition: opacity 0.95s linear, transform 0.95s var(--ease-out);
}

.card:hover .card__detail p,
.card.is-hover .card__detail p,
.card:focus-within .card__detail p {
  opacity: 1;
  transform: none;
  transition: opacity 0.5s var(--ease-out) 0.12s, transform 0.5s var(--ease-out) 0.12s;
}

.card:hover .card__detail .card__chips,
.card.is-hover .card__detail .card__chips,
.card:focus-within .card__detail .card__chips {
  opacity: 1;
  transform: none;
  transition: opacity 0.5s var(--ease-out) 0.19s, transform 0.5s var(--ease-out) 0.19s;
}

/*
 * Reduced motion keeps the cross-fade and drops the rise — the same line drawn
 * everywhere else on this site. A description appearing where it already was
 * is not motion; a plate travelling up the card is.
 */
@media (prefers-reduced-motion: reduce) {
  .card__detail,
  .card__detail p,
  .card__detail .card__chips {
    transform: none;
  }

  .card:hover .card__detail,
  .card.is-hover .card__detail,
  .card:focus-within .card__detail,
  .card:hover .card__detail p,
  .card.is-hover .card__detail p,
  .card:focus-within .card__detail p,
  .card:hover .card__detail .card__chips,
  .card.is-hover .card__detail .card__chips,
  .card:focus-within .card__detail .card__chips {
    transform: none;
  }
}

.card__detail p {
  font-size: 13.5px;
  line-height: 1.6;
  /* Pinned light, not --mist-2: the plate above is a hardcoded near-black in
     both themes, so its ink cannot follow the theme either. Same rule as the
     mobile menu and the device screens. */
  color: rgba(242, 242, 243, 0.86);
}

.card__chips {
  display: block;
  margin-top: 10px;
  font-family: var(--font-mono);
  font-size: 9.5px;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: rgba(242, 242, 243, 0.5);
}

/* --- the cursor eye -----------------------------------------------------

   A lens that appears over a hovered card and follows the pointer. Fixed
   rather than absolute: the rail is transformed sideways by GSAP while
   pinned, and an absolutely positioned follower inside it would inherit that
   transform and lag behind its own card.

   pointer-events: none is load-bearing — without it the follower sits under
   the cursor and eats the hover it exists to indicate, so the card would
   flicker between hovered and not. */
.peek {
  position: fixed;
  top: 0;
  left: 0;
  z-index: 60;
  width: 84px;
  height: 84px;
  margin: -42px 0 0 -42px;
  border-radius: 50%;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 3px;
  pointer-events: none;
  opacity: 0;
  /* TRANSFORM IS OWNED BY JS, ENTIRELY.

     app.js writes translate and scale together in one transform string. The
     scale-in is not a CSS transition here, because a transition on transform
     would fight the per-frame translate the follower is built on — the lens
     would spring toward the cursor instead of tracking it. Only opacity
     transitions in CSS. */
  transition: opacity 0.28s var(--ease-out);

  /* Glass, not a solid disc. The card underneath stays readable through it,
     which is the point of a lens — an opaque puck just covers the thing it is
     asking you to look at.

     NO backdrop-filter here, deliberately, even though that is the obvious
     way to make glass and every other panel on this page uses it.

     Those panels are static. This one moves under the cursor every frame, and
     a moving backdrop-filter makes the browser re-sample and re-blur
     everything behind it on each of those frames — over a WebGL canvas, a
     tilting card and a dozen other blurred surfaces. It is the single most
     expensive thing that could be attached to a pointer follower, and the
     first version did exactly that.

     The look survives without it: a translucent tint plus a light top edge
     and a rim reads as glass because of the highlights, not the blur. What is
     lost is refraction of the card art, which at 84px was never legible
     anyway. */
  background: radial-gradient(
      120% 120% at 32% 22%,
      rgba(150, 190, 255, 0.34) 0%,
      rgba(56, 116, 245, 0.26) 46%,
      rgba(24, 74, 190, 0.3) 100%
    );
  border: 1px solid rgba(176, 206, 255, 0.42);

  /* Three shadows doing three jobs: the halo, a tight ring that keeps the
     edge defined against a light image, and an inset top highlight that reads
     as the light catching the rim of the glass. */
  box-shadow: 0 0 30px 8px rgba(37, 99, 235, 0.34),
    0 0 0 1px rgba(37, 99, 235, 0.22),
    inset 0 1px 0 rgba(255, 255, 255, 0.3);

  color: #fff;
  /* The plate is translucent, so the ink has to carry itself over whatever
     the card image happens to be. */
  text-shadow: 0 1px 8px rgba(4, 8, 20, 0.75);
  will-change: transform;
}

/* The halo proper — a soft bloom outside the disc, on its own element so it
   can be larger than the lens without inflating the hit area or clipping
   against the border-radius. Purely decorative and behind the icon. */
.peek::before {
  content: "";
  position: absolute;
  inset: -14px;
  border-radius: 50%;
  background: radial-gradient(
    circle,
    rgba(91, 141, 239, 0.34) 0%,
    rgba(91, 141, 239, 0.12) 45%,
    transparent 70%
  );
  pointer-events: none;
  z-index: -1;
}

/*
 * One icon at a time. All three ship in the markup and data-mode picks the
 * live one — see initPeek for why this is an attribute toggle rather than an
 * innerHTML swap on every pointermove.
 *
 * display:none rather than opacity, deliberately: the arrows and the eye are
 * different widths, and cross-fading them would slide the label under the
 * cursor every time the pointer crossed an edge zone.
 */
.peek__icon { display: none; }
.peek[data-mode="view"] .peek__icon--view,
.peek[data-mode="prev"] .peek__icon--prev,
.peek[data-mode="next"] .peek__icon--next { display: block; }

/* The arrow modes page the rail rather than opening a card, so they take the
   accent rather than the glass tint — the lens is a different control there,
   and it should not look like the same one pointing sideways. */
.peek[data-mode="prev"],
.peek[data-mode="next"] {
  background: radial-gradient(
      120% 120% at 32% 22%,
      rgba(226, 185, 94, 0.34) 0%,
      rgba(212, 175, 55, 0.28) 46%,
      rgba(150, 118, 24, 0.32) 100%
    );
  border-color: rgba(240, 214, 150, 0.46);
  box-shadow: 0 0 30px 8px rgba(212, 175, 55, 0.28),
    0 0 0 1px rgba(212, 175, 55, 0.22),
    inset 0 1px 0 rgba(255, 255, 255, 0.3);
}

.peek[data-on="1"] {
  opacity: 1;
}

.peek__label {
  font-family: var(--font-mono);
  font-size: 8.5px;
  letter-spacing: 0.16em;
  text-transform: uppercase;
}

/* No hover on touch, so the follower would only ever be a flash on tap. The
   detail panel is reachable there by tap via :focus-within. */
@media (hover: none), (pointer: coarse) {
  .peek {
    display: none;
  }
}

.card__num {
  position: absolute;
  top: 14px;
  right: 16px;
  font-family: var(--font-mono);
  font-size: 10.5px;
  color: var(--mist-2);
  background: rgba(8, 7, 13, 0.6);
  backdrop-filter: blur(10px);
  -webkit-backdrop-filter: blur(10px);
  padding: 4px 9px;
  border-radius: var(--r-pill);
}

.card__meta {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 16px;
  padding-top: 18px;
}

/* Same reason as .news__item: on /projects a card sits under the page h1 and
   is an h2. The level follows the document, the size does not. */
.card__meta h2,
.card__meta h3 {
  font-size: 22px;
}

.card__tag {
  font-family: var(--font-mono);
  font-size: 10px;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--mist-3);
  margin-top: 7px;
}

/* The shared edge highlight. Paired with the GSAP lift in initLift(), this is
   the page's one repeated interaction: approach a surface and it rises and
   its edge takes the accent. Kept out of `transform` on purpose — GSAP owns
   the transform on several of these elements. */
.stat,
.cap,
.news__item,
.hstat {
  transition: border-color 0.45s var(--ease-out), box-shadow 0.45s var(--ease-out);
}

.stat:hover,
.cap:hover,
.news__item:hover,
.hstat:hover {
  border-color: rgba(77, 157, 255, 0.45);
  box-shadow: 0 18px 44px -22px rgba(0, 0, 0, 0.8),
    0 0 40px -14px rgba(77, 157, 255, 0.32);
}

.railbar {
  height: 1px;
  background: var(--line);
  position: relative;
  margin-top: 40px;
}

.railbar i {
  position: absolute;
  inset: 0 auto 0 0;
  width: 14%;
  background: var(--accent);
}

/* 14. Showcase stage ===================================================== */

/* The three products as the objects they are, in front of the page instead
   of drawn in particles behind it. assets/js/showcase.js builds every node in
   here from SITE_CONFIG.showcase; nothing below assumes a fixed number of
   devices or a fixed kind of screen. */

.stage {
  position: relative;
  display: flex;
  flex-direction: column;
  justify-content: center;
  min-height: 78vh;
  padding: 0 var(--gutter);
}

/* The solutions module, now nested inside #showcase rather than sitting
   beside it. It keeps its own <section> element for the anchor and the
   heading semantics but not the .section class, which would double the
   padding it already inherits from #showcase. The hairline stands in for the
   section boundary that used to separate the two. */
.showcase__solutions {
  display: block;
  margin-top: clamp(80px, 11vh, 150px);
  padding-top: clamp(64px, 8vh, 110px);
  border-top: 1px solid var(--line);
}

/* Without JavaScript there are no devices, so the section states its three
   products in words instead of showing nothing. */
.stage__plain {
  list-style: none;
  margin: 0 auto;
  padding: 0;
  max-width: 62ch;
  display: flex;
  flex-direction: column;
  gap: 14px;
  color: var(--mist-2);
  font-size: 16px;
  line-height: 1.7;
}

/* The devices sit in a fixed-ratio box rather than flowing, because their
   positions are a composition — the phone overlapping the tablet's corner is
   the whole reason the group reads as one scene and not three pictures in a
   row. A ratio box keeps that composition identical at every width. */
.stage__devices {
  position: relative;
  width: 100%;
  max-width: 1180px;
  margin: 0 auto;
  aspect-ratio: 16 / 8.4;
}

/* --- device shells ------------------------------------------------------

   .dev__glass is the bezel, .dev__screen is the clipped aperture inside it.
   They are separate so that a client screenshot and a rendered UI get
   clipped and rounded identically without either having to know the bezel
   exists — which is what lets the admin panel swap one for the other. */

.dev {
  position: absolute;
  opacity: 1;
}

.dev__body {
  position: relative;
  width: 100%;
}

.dev__glass {
  position: relative;
  border: 1px solid var(--line-2);
  border-radius: 10px;
  background: var(--deep-2);
  padding: 6px;
  box-shadow: 0 30px 70px rgba(0, 0, 0, 0.55);
}

.dev__screen {
  position: relative;
  overflow: hidden;
  border-radius: 5px;
  background: var(--void-2);

  /* Everything inside a screen is sized in cqw, so one declaration here
     scales an entire interface with the device it is on. The alternative —
     a JS transform: scale() on a fixed-width layout — re-blurs text on every
     resize and needs a listener to stay correct. */
  container-type: inline-size;
}

/* --- laptop -------------------------------------------------------------

   The only real 3D in the section. Perspective is declared on .dev--laptop
   and the rotation happens on .dev__lid inside it: a perspective on the same
   element that carries the rotation has no effect, and one declared further
   up on .stage__devices would put the vanishing point at the centre of the
   whole composition rather than the centre of this machine. */
.dev--laptop {
  left: 0;
  bottom: 6%;
  width: 60%;
  perspective: 1500px;
}

.dev--laptop .dev__body {
  transform-style: preserve-3d;
}

/* --lid is the hinge angle, and it is the single source of truth for it.
   showcase.js writes it inline while scrubbing; the closed and open poses
   below are the same property, so the no-JS and reduced-motion paths need no
   separate rule. -92deg is just past flat, which reads as resting on the
   base rather than floating a degree above it. */
.dev__lid {
  transform-origin: 50% 100%;
  transform: rotateX(var(--lid, 0deg));
  will-change: transform;
}

/* The closed pose, and the only place it is written down. It covers the gap
   between the markup existing and the first scrub frame writing an inline
   --lid, which is otherwise a flash of an already-open laptop. Once the
   stage opens the rule stops matching and --lid falls back to its 0deg
   default, so nothing has to reset it. */
[data-showcase][data-state="closed"] .dev__lid {
  --lid: -92deg;
}

[data-showcase][data-state="closed"] .dev--tablet,
[data-showcase][data-state="closed"] .dev--phone {
  opacity: 0;
}

.dev--laptop .dev__screen {
  aspect-ratio: 16 / 10;
}

/* The base is drawn, not photographed: a plate laid back in the same
   perspective as the lid, with a hinge lip and a trackpad. Four elements is
   enough to read as a laptop once the lid is attached to it — more detail
   competes with the interface on the screen, which is the thing worth
   looking at. */
.dev__base {
  position: relative;
  height: 2.6%;
  margin-top: -1px;
  border-radius: 0 0 12px 12px;
  background: linear-gradient(180deg, var(--void-3), var(--void-2));
  border: 1px solid var(--line);
  border-top: 0;
  transform: rotateX(72deg);
  transform-origin: 50% 0;
  padding-bottom: 7%;
}

.dev__hinge {
  position: absolute;
  left: 50%;
  top: 0;
  width: 14%;
  height: 2px;
  transform: translateX(-50%);
  background: var(--line-2);
  border-radius: var(--r-pill);
}

.dev__pad {
  position: absolute;
  left: 50%;
  bottom: 14%;
  width: 26%;
  height: 46%;
  transform: translateX(-50%);
  border: 1px solid var(--line);
  border-radius: 4px;
}

/* --- tablet and phone ---------------------------------------------------

   Flat plates that travel in. Neither gets a hinge: nothing on a tablet
   opens, and inventing motion for it would be decoration. */
.dev--tablet {
  right: 3%;
  bottom: 20%;
  width: 31%;
}

.dev--tablet .dev__glass {
  border-radius: 14px;
  padding: 7px;
}

.dev--tablet .dev__screen {
  aspect-ratio: 4 / 3;
  border-radius: 8px;
}

.dev--phone {
  right: 20%;
  bottom: 0;
  width: 12.5%;
}

.dev--phone .dev__glass {
  border-radius: 18px;
  padding: 5px;
  box-shadow: 0 24px 50px rgba(0, 0, 0, 0.65);
}

.dev--phone .dev__screen {
  aspect-ratio: 9 / 19;
  border-radius: 14px;
}

/* --- captions -----------------------------------------------------------

   Real text under each device rather than a label baked into the render, so
   it translates for /zh/ and is announced rather than decorative. */
.dev__caption {
  position: absolute;
  left: 0;
  bottom: -30px;
  font-family: var(--font-mono);
  font-size: 10px;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--mist-3);
  white-space: nowrap;
}

.dev--tablet .dev__caption,
.dev--phone .dev__caption {
  left: auto;
  right: 0;
}

/* Hover is handed back only once the assembly has settled — a device that
   lifts under the pointer while the scrub is still moving it fights the
   timeline. */
[data-showcase][data-state="open"] .dev {
  transition: transform 0.4s var(--ease-out);
}

[data-showcase][data-state="open"] .dev:hover {
  transform: translateY(-6px);
}

/* The scrubbed hinge survives reduced motion — it only moves while the
   visitor moves it. This does not: it happens on its own, under the pointer.
   See the flag note at the top of showcase.js. */
[data-showcase][data-reduced="1"] .dev,
[data-showcase][data-reduced="1"] .dev:hover {
  transition: none;
  transform: none;
}

/* --- screen interfaces --------------------------------------------------

   Miniature but real: markup and tokens, not pictures of markup. Sizes are
   in cqw against .dev__screen, so the same template is legible on a 60%-wide
   laptop and a 12%-wide phone without a second set of rules.

   Density is held deliberately low. At this scale a realistic dashboard —
   twelve tiles, a dense grid, a scatter of status dots — collapses into
   texture and reads as noise rather than software. Three KPIs and one chart
   survive the reduction; a real screenshot does not, which is worth knowing
   before swapping one in.

   --- the screens carry their own palette, and must ---------------------

   These interfaces are deliberately NOT themed with the page. Two reasons,
   one of them a bug this fixes.

   The bug: the first version set `color: var(--mist)` on `background:
   var(--deep)`. In the light theme --mist flips to a dark navy but --deep is
   not overridden at all — it is one of the tokens the light theme leaves
   alone — so the screens rendered dark navy text on a near-black plate, about
   1.1:1. Unreadable, and the page defaults to light for half the day on the
   Shanghai clock, so this was the common case rather than an edge one.

   The reason it should stay unthemed anyway: a management console does not
   repaint itself because the marketing site around it switched theme. These
   are pictures of software, and software has its own palette. Screen tokens
   are therefore literal values, not references to page tokens — a page-token
   reference is exactly what broke, and would break again the next time the
   light theme overrides one of them. */

.dev__screen {
  --screen-bg: #14161c;
  --screen-bg-2: #0f1116;
  --screen-ink: #f2f2f3;
  --screen-ink-2: rgba(242, 242, 243, 0.74);
  --screen-ink-3: rgba(242, 242, 243, 0.46);
  --screen-line: rgba(255, 255, 255, 0.1);
  --screen-glass: rgba(255, 255, 255, 0.04);
  --screen-accent: #2563eb;
  --screen-accent-hi: #6f9bf2;
  --screen-warn: #d4af37;
}

.ui {
  width: 100%;
  height: 100%;
  font-family: var(--font-body);
  color: var(--screen-ink);
  background: var(--screen-bg);
}

.ui--image {
  display: block;
  object-fit: cover;
}

/* website ---------------------------------------------------------------- */

.ui--site {
  display: flex;
  flex-direction: column;
}

.ui__bar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 3cqw 4cqw;
  border-bottom: 1px solid var(--screen-line);
}

.ui__brand {
  font-size: 3.4cqw;
  font-weight: 600;
  letter-spacing: 0.02em;
}

.ui__nav {
  display: flex;
  gap: 3cqw;
}

.ui__navitem {
  font-size: 2.4cqw;
  color: var(--screen-ink-2);
}

.ui__hero {
  padding: 6cqw 4cqw 4cqw;
}

.ui__headline {
  margin: 0;
  font-family: var(--font-display);
  font-size: 6.6cqw;
  line-height: 1.15;
  font-weight: 600;
}

.ui__sub {
  margin: 2cqw 0 0;
  font-size: 2.6cqw;
  color: var(--screen-ink-2);
  max-width: 62%;
}

.ui__cta {
  display: inline-block;
  margin-top: 3.4cqw;
  padding: 1.6cqw 3.4cqw;
  border-radius: var(--r-pill);
  background: var(--screen-accent);
  color: #fff;
  font-size: 2.4cqw;
}

.ui__cards {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 2.4cqw;
  padding: 2cqw 4cqw 4cqw;
  margin-top: auto;
}

.ui__card {
  display: flex;
  flex-direction: column;
  gap: 1cqw;
  padding: 2.6cqw;
  border: 1px solid var(--screen-line);
  border-radius: 4px;
  background: var(--screen-glass);
}

.ui__card-title {
  font-size: 2.4cqw;
  font-weight: 600;
}

.ui__card-meta {
  font-size: 2cqw;
  color: var(--screen-ink-3);
}

/* dashboard -------------------------------------------------------------- */

.ui--dash {
  display: grid;
  grid-template-columns: 22% 1fr;
}

.ui__side {
  display: flex;
  flex-direction: column;
  gap: 2.2cqw;
  padding: 3cqw 2.4cqw;
  border-right: 1px solid var(--screen-line);
  background: var(--screen-bg-2);
}

.ui__side-brand {
  font-size: 2.6cqw;
  font-weight: 600;
  margin-bottom: 1.4cqw;
}

.ui__side-item {
  font-size: 2.2cqw;
  color: var(--screen-ink-3);
}

.ui__side-item[data-on="1"] {
  color: var(--screen-ink);
}

.ui__main {
  display: flex;
  flex-direction: column;
  gap: 2.6cqw;
  padding: 3cqw;
}

.ui__kpis {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 2cqw;
}

.ui__kpi {
  display: flex;
  flex-direction: column;
  gap: 0.6cqw;
  padding: 2.2cqw;
  border: 1px solid var(--screen-line);
  border-radius: 4px;
  background: var(--screen-glass);
}

.ui__kpi-value {
  font-size: 4cqw;
  font-weight: 600;
  letter-spacing: -0.01em;
}

.ui__kpi-label {
  font-size: 1.9cqw;
  color: var(--screen-ink-3);
}

.ui__chart {
  border: 1px solid var(--screen-line);
  border-radius: 4px;
  background: var(--screen-glass);
  padding: 2cqw;
}

.ui__chart svg {
  display: block;
  width: 100%;
  height: 14cqw;
}

.ui__chart-line {
  fill: none;
  stroke: var(--screen-accent-hi);
  stroke-width: 1.4;
  vector-effect: non-scaling-stroke;
}

.ui__chart-area {
  fill: var(--screen-accent);
  opacity: 0.16;
  stroke: none;
}

.ui__rows {
  display: flex;
  flex-direction: column;
}

.ui__row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 1.8cqw 0;
  border-top: 1px solid var(--screen-line);
  font-size: 2.1cqw;
}

.ui__row-name {
  color: var(--screen-ink-2);
}

.ui__row-state {
  color: var(--screen-ink-3);
}

.ui__row-state[data-tone="ok"] {
  color: var(--screen-accent-hi);
}

.ui__row-state[data-tone="warn"] {
  color: var(--screen-warn);
}

/* mini-program ----------------------------------------------------------- */

/* Mini-programs are light in the wild — WeChat's own chrome is white, and the
   reference renders in resources/ show the phones that way while the laptop
   and tablet stay dark. So this template overrides the screen palette instead
   of inheriting the dark one. Declaring the tokens on .ui--mini itself works
   because a custom property set on an element is visible to that element's
   own declarations, not just its descendants — which is the reason the
   palette lives on a per-screen element rather than on :root. */
.ui--mini {
  --screen-bg: #f7f8fa;
  --screen-bg-2: #ffffff;
  --screen-ink: #14161c;
  --screen-ink-2: rgba(20, 22, 28, 0.72);
  --screen-ink-3: rgba(20, 22, 28, 0.46);
  --screen-line: rgba(20, 22, 28, 0.1);
  --screen-glass: rgba(20, 22, 28, 0.035);
  --screen-accent-hi: #1b52e0;

  display: flex;
  flex-direction: column;
  gap: 3cqw;
  padding: 6cqw 4cqw 0;
}

.ui__mini-title {
  font-size: 4.4cqw;
  font-weight: 600;
}

.ui__mini-search {
  padding: 2.6cqw 3cqw;
  border-radius: var(--r-pill);
  background: var(--screen-glass);
  font-size: 3cqw;
  color: var(--screen-ink-3);
}

.ui__tiles {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 3cqw;
}

.ui__tile {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 1.6cqw;
  padding: 3.4cqw 1cqw;
  border-radius: 6px;
  background: var(--screen-glass);
  border: 1px solid var(--screen-line);
}

.ui__tile-dot {
  width: 7cqw;
  height: 7cqw;
  border-radius: 5px;
  background: var(--screen-accent);
  opacity: 0.85;
}

.ui__tile-label {
  font-size: 2.8cqw;
  color: var(--screen-ink-2);
}

.ui__list {
  display: flex;
  flex-direction: column;
}

.ui__list-row {
  display: flex;
  flex-direction: column;
  gap: 0.8cqw;
  padding: 3cqw 0;
  border-top: 1px solid var(--screen-line);
}

.ui__list-name {
  font-size: 3cqw;
}

.ui__list-meta {
  font-size: 2.4cqw;
  color: var(--screen-ink-3);
}

.ui__tabs {
  display: flex;
  justify-content: space-around;
  margin-top: auto;
  padding: 3cqw 0;
  border-top: 1px solid var(--screen-line);
  background: var(--screen-bg-2);
}

.ui__tab {
  font-size: 2.6cqw;
  color: var(--screen-ink-3);
}

.ui__tab[data-on="1"] {
  color: var(--screen-accent-hi);
}

/* --- stacked fallback ---------------------------------------------------

   Under 1080 showcase.js does not pin or animate, so the absolute
   composition has nothing driving it and would simply overlap. The devices
   become a normal column, all open. */
/* The tab strip is desktop-hidden, not desktop-absent — see the note in
   showcase.js about not splitting one decision across a JS matchMedia and a
   CSS breakpoint. */
.stage__tabs {
  display: none;
}

/* --- small screens: one device at a time --------------------------------

   Stacking all three full-width cost about three screens of scrolling to get
   past one section. They become a scroll-snap strip instead: one product
   visible, swipe or tap a tab for the next.

   Snap comes from the platform rather than a gesture library — momentum,
   rubber-banding, and a container a screen reader and a keyboard already
   understand, for no code. */
@media (max-width: 1079px) {
  .stage {
    min-height: 0;
    padding: 0;
  }

  .stage__tabs {
    display: flex;
    gap: 8px;
    margin: 0 var(--gutter) 22px;
    overflow-x: auto;
    scrollbar-width: none;
  }

  .stage__tabs::-webkit-scrollbar {
    display: none;
  }

  .stage__tab {
    flex: 0 0 auto;
    font-family: var(--font-mono);
    font-size: 10px;
    letter-spacing: 0.1em;
    text-transform: uppercase;
    color: var(--mist-3);
    background: var(--glass);
    border: 1px solid var(--line);
    border-radius: var(--r-pill);
    padding: 8px 14px;
    cursor: pointer;
    transition: color 0.2s, background 0.2s, border-color 0.2s;
  }

  .stage__tab[aria-selected="true"] {
    color: var(--mist);
    border-color: var(--accent);
    background: var(--glass-2);
  }

  .stage__devices {
    display: flex;
    align-items: center;
    gap: 0;
    aspect-ratio: auto;
    max-width: none;
    overflow-x: auto;
    scroll-snap-type: x mandatory;
    scrollbar-width: none;
    /* Padding rather than margin so the first and last device can still
       centre themselves in the viewport when snapped. */
    padding: 0 var(--gutter);
  }

  .stage__devices::-webkit-scrollbar {
    display: none;
  }

  .dev {
    position: relative;
    left: auto;
    right: auto;
    bottom: auto;
    flex: 0 0 100%;
    max-width: 100%;
    opacity: 1;
    scroll-snap-align: center;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    padding: 0 6px;
    /* The off-screen ones recede rather than sitting at full strength in the
       corner of the eye during a swipe. */
    transition: opacity 0.35s var(--ease-out), transform 0.35s var(--ease-out);
  }

  .dev[data-active="0"] {
    opacity: 0.45;
    transform: scale(0.94);
  }

  /* Widths are of the strip, so each product still reads at its own
     proportions instead of all three being stretched to the same box. */
  .dev--laptop .dev__body { width: 100%; }
  .dev--tablet .dev__body { width: 74%; }
  .dev--phone .dev__body { width: 46%; }

  .dev__caption {
    position: relative;
    left: auto;
    right: auto;
    bottom: auto;
    display: block;
    margin-top: 18px;
    text-align: center;
  }

  /* The hinge is a desktop affordance: it needs the scrubbed pin, which does
     not run here. Flat and open, so the screen is readable immediately. */
  .dev__lid {
    transform: none;
  }

  .dev__base {
    transform: rotateX(64deg);
  }
}

@media (prefers-reduced-motion: reduce) {
  .dev {
    transition: none;
  }
}

/* 15. Solutions ========================================================== */

.sol {
  display: grid;
  grid-template-columns: 0.85fr 1.3fr 0.95fr;
  gap: 46px;
  align-items: start;
}

.sol__list {
  display: flex;
  flex-direction: column;
  border-top: 1px solid var(--line);
}

.sol__tab {
  display: flex;
  align-items: center;
  gap: 14px;
  padding: 16px 0;
  border: 0;
  border-bottom: 1px solid var(--line);
  background: none;
  cursor: pointer;
  color: var(--mist-3);
  font-family: inherit;
  text-align: left;
  width: 100%;
  transition: color 0.3s, padding-left 0.4s var(--ease-out);
}

.sol__tab:hover {
  color: var(--mist);
  padding-left: 10px;
}

.sol__tab-num {
  font-family: var(--font-mono);
  font-size: 10px;
  color: var(--mist-4);
}

.sol__tab-name {
  font-size: 14.5px;
}

.sol__tab-bar {
  margin-left: auto;
  width: 0;
  height: 1px;
  background: var(--accent);
  transition: width 0.4s var(--ease-out);
}

.sol__tab[aria-selected="true"] {
  color: var(--mist);
}

.sol__tab[aria-selected="true"] .sol__tab-num {
  color: var(--accent);
}

.sol__tab[aria-selected="true"] .sol__tab-bar {
  width: 44px;
}

.sol__figure {
  position: relative;
  margin: 0;
  border-radius: var(--r-card);
  border: 1px solid var(--line);
  overflow: hidden;
  aspect-ratio: 16 / 11;
  background: var(--void-2);
}

.sol__figure img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  opacity: 0.8;
}

/* --- the diagram markers ------------------------------------------------

   A "+" on the drawing that opens a note about that part of it.

   THREE THINGS WERE WRONG WITH THE FIRST VERSION and they are worth naming,
   because each one is invisible until you go looking:

   1. The "+" was the character "+", set in the body font. It sat a pixel or
      two off centre depending on the font, and it could not become anything
      else — the open state was a colour change alone, which is the weakest
      signal available and the only one a colourblind reader may not get. It
      is drawn now, from two bars, and it turns into a × when it opens.

   2. Nothing happened on hover except a 16% scale, so a marker read as
      decoration until you happened to click one. The ring answers the pointer
      before the click, which is what makes it read as a control.

   3. The note opened to a fixed side chosen by hand, per marker, in the
      markup. A long note near the right edge opened off the figure and was
      simply unreadable — and only for that one marker, so it survived every
      look at the page that did not happen to click it. app.js measures and
      places it now; see fitTip().

   --tip-x / --tip-y are that correction, in pixels. They default to zero, so
   the CSS alone still places the note correctly in the common case and this
   degrades to the old fixed offsets with no JavaScript. */

.hotspot {
  position: absolute;
  transform: translate(-50%, -50%);

  /* Above its siblings while open, so an overlapping note is never drawn
     underneath the next marker along. */
  z-index: 1;
}

.hotspot.is-open {
  z-index: 4;
}

.hotspot__dot {
  position: relative;
  width: 30px;
  height: 30px;
  border-radius: 50%;
  border: 1px solid var(--line-2);
  background: rgba(8, 7, 13, 0.7);
  backdrop-filter: blur(10px);
  -webkit-backdrop-filter: blur(10px);
  color: var(--mist);
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  font-family: inherit;
  line-height: 1;
  transition: transform 0.3s var(--ease-out), background 0.25s, color 0.25s,
    border-color 0.25s;
}

/* The ring. Drawn on the dot rather than as its own element so there is
   nothing extra in the markup, and pointer-events:none so it cannot eat the
   click it exists to invite — it is larger than the button it sits on. */
.hotspot__dot::after {
  content: "";
  position: absolute;
  inset: -6px;
  border-radius: 50%;
  border: 1px solid rgba(77, 157, 255, 0.45);
  opacity: 0;
  transform: scale(0.82);
  pointer-events: none;
  transition: opacity 0.3s, transform 0.35s var(--ease-out);
}

.hotspot__dot:hover,
.hotspot__dot:focus-visible {
  transform: scale(1.16);
  border-color: rgba(77, 157, 255, 0.5);
  outline: none;
}

.hotspot__dot:hover::after,
.hotspot__dot:focus-visible::after,
.hotspot__dot[aria-expanded="true"]::after {
  opacity: 1;
  transform: scale(1);
}

.hotspot__dot[aria-expanded="true"] {
  background: var(--accent);
  color: var(--void);
  border-color: var(--accent);
}

/* --- the + itself, drawn ------------------------------------------------

   Two bars from one element's pseudo-elements. Rotating the pair 45° is the
   whole of the open state: a + at 45° IS a ×, so one transform carries the
   change and there is no second glyph to cross-fade with. */
.hotspot__glyph {
  position: relative;
  display: block;
  width: 11px;
  height: 11px;
  transition: transform 0.4s var(--ease-out);
}

.hotspot__glyph::before,
.hotspot__glyph::after {
  content: "";
  position: absolute;
  left: 50%;
  top: 50%;
  background: currentColor;
  border-radius: 1px;
}

.hotspot__glyph::before {
  width: 11px;
  height: 1.5px;
  transform: translate(-50%, -50%);
}

.hotspot__glyph::after {
  width: 1.5px;
  height: 11px;
  transform: translate(-50%, -50%);
}

.hotspot__dot[aria-expanded="true"] .hotspot__glyph {
  transform: rotate(45deg);
}

.hotspot__tip {
  position: absolute;
  left: calc(40px + var(--tip-x, 0px));
  top: calc(-6px + var(--tip-y, 0px));
  width: 232px;
  padding: 14px 16px;
  border-radius: 14px;
  background: var(--float-2);
  border: 1px solid var(--line-2);
  backdrop-filter: blur(18px);
  -webkit-backdrop-filter: blur(18px);
  z-index: 3;

  /*
   * VISIBILITY, NOT THE `hidden` ATTRIBUTE, once JavaScript has taken over.
   *
   * display:none cannot be transitioned, so the note used to appear at full
   * strength the instant it existed and vanish the same way — the one part of
   * this page that snapped. visibility:hidden takes it out of the
   * accessibility tree and out of hit testing exactly as `hidden` did, and it
   * animates.
   *
   * The markup still ships `hidden`, which wins over all of this; app.js
   * removes it on init. So with no JavaScript the notes stay closed as before
   * rather than all fifteen rendering open on top of the drawing.
   */
  opacity: 0;
  visibility: hidden;
  transform: translateY(6px);
  transition: opacity 0.28s var(--ease-out), transform 0.28s var(--ease-out),
    visibility 0.28s;
}

.hotspot.is-open .hotspot__tip {
  opacity: 1;
  visibility: visible;
  transform: none;
}

.hotspot[data-flip="true"] .hotspot__tip {
  left: auto;
  right: calc(40px - var(--tip-x, 0px));
}

/* The note settling into place is the only travel here, and it is 6px. Under
   reduced motion it cross-fades in place — the same line drawn everywhere
   else on this site. */
@media (prefers-reduced-motion: reduce) {
  .hotspot__tip {
    transform: none;
  }

  .hotspot__glyph,
  .hotspot__dot {
    transition: background 0.25s, color 0.25s, border-color 0.25s;
  }

  .hotspot__dot:hover,
  .hotspot__dot:focus-visible {
    transform: none;
  }
}

.hotspot__tip dt {
  font-family: var(--font-mono);
  font-size: 9.5px;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--accent);
  margin-bottom: 6px;
}

.hotspot__tip dd {
  margin: 0;
  font-size: 12.5px;
  line-height: 1.6;
  color: var(--mist-2);
}

.sol__panel h3 {
  margin: 14px 0 16px;
}

.sol__panel > p {
  color: var(--mist-2);
  font-size: 14.5px;
}

.sol__points {
  display: flex;
  flex-direction: column;
  gap: 10px;
  margin: 22px 0 28px;
  padding: 0;
  list-style: none;
}

.sol__points li {
  display: flex;
  gap: 11px;
  align-items: baseline;
  font-size: 13.5px;
  color: var(--mist-2);
}

.sol__points li::before {
  content: "";
  width: 4px;
  height: 4px;
  flex: none;
  border-radius: 50%;
  background: var(--accent);
  transform: translateY(-2px);
}

.sol__nav {
  display: flex;
  gap: 10px;
  margin-top: 26px;
}

@media (max-width: 1079px) {
  .sol {
    grid-template-columns: 1fr;
    gap: 34px;
  }
  .hotspot__tip {
    width: 180px;
  }
}

/* 16. Capabilities ======================================================= */

.caps {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 1px;
  background: var(--line);
  border: 1px solid var(--line);
  border-radius: var(--r-card);
  overflow: hidden;
}

.cap {
  background: var(--void);
  padding: 38px 32px 42px;
  transition: background 0.4s;
}

.cap:hover {
  background: var(--void-2);
}

.cap__num {
  font-family: var(--font-mono);
  font-size: 10.5px;
  color: var(--accent);
  margin-bottom: 26px;
}

.cap h3 {
  font-size: 21px;
  margin-bottom: 12px;
}

.cap p {
  font-size: 13.5px;
  line-height: 1.65;
  color: var(--mist-2);
  margin-bottom: 20px;
}

.tags {
  display: flex;
  flex-wrap: wrap;
  gap: 7px;
  list-style: none;
  margin: 0;
  padding: 0;
}

@media (max-width: 1079px) {
  .caps {
    grid-template-columns: 1fr 1fr;
  }
}

@media (max-width: 719px) {
  .caps {
    grid-template-columns: 1fr;
  }
}

/* 17. Stats ============================================================== */

.stats {
  display: grid;
  grid-template-columns: 0.9fr 1.4fr;
  gap: 60px;
  align-items: start;
}

.stats__grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 28px 20px;
}

.stat__n {
  font-family: var(--font-display);
  font-style: var(--display-style);
  font-size: clamp(40px, 4vw, 66px);
  line-height: 1;
  font-variant-numeric: tabular-nums;
}

html[lang="zh-Hans"] .stat__n {
  font-style: normal;
}

.stat__n span {
  font-size: 0.52em;
}

.stat__label {
  font-size: 13px;
  color: var(--mist-2);
  margin-top: 12px;
}

.stat__tag {
  font-family: var(--font-mono);
  font-size: 9px;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--mist-4);
  margin-top: 5px;
}

@media (max-width: 1079px) {
  .stats {
    grid-template-columns: 1fr;
    gap: 40px;
  }
}

@media (max-width: 719px) {
  .stats__grid {
    grid-template-columns: 1fr 1fr;
  }
}

/* 18. About ============================================================== */

.about {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 70px;
  align-items: start;
}

.about__copy p + p {
  margin-top: 18px;
}

.timeline {
  list-style: none;
  margin: 0;
  padding: 0;
  border-top: 1px solid var(--line);
}

.timeline li {
  display: grid;
  grid-template-columns: 96px 1fr;
  gap: 24px;
  padding: 22px 0;
  border-bottom: 1px solid var(--line);
  align-items: baseline;
}

.timeline__year {
  font-family: var(--font-display);
  font-style: var(--display-style);
  font-size: 26px;
  color: var(--accent);
}

html[lang="zh-Hans"] .timeline__year {
  font-style: normal;
}

.timeline__text {
  font-size: 14px;
  color: var(--mist-2);
}

@media (max-width: 1079px) {
  .about {
    grid-template-columns: 1fr;
    gap: 44px;
  }
}

@media (max-width: 719px) {
  .timeline li {
    grid-template-columns: 66px 1fr;
    gap: 16px;
  }
}

/* 19. News =============================================================== */

.news {
  border-top: 1px solid var(--line);
}

.news__item {
  position: relative;
  display: grid;
  grid-template-columns: 130px 1fr auto;
  gap: 30px;
  align-items: baseline;
  padding: 28px 0;
  color: var(--mist);
  transition: padding-left 0.45s var(--ease-out), background 0.35s;

  /*
   * THE DIVIDER IS A PSEUDO-ELEMENT, NOT THIS BORDER, so it can draw itself in
   * from the left as the row lands — a border cannot be scaled.
   *
   * The border stays, at 1px transparent, for two reasons and both matter:
   * it holds the row's height so swapping the mechanism shifts nothing, and
   * the shared `.news__item:hover { border-color }` highlight further up still
   * has a border to colour. The ::after takes the same colour on hover, so the
   * two land on the same pixel row rather than showing as two lines.
   */
  border-bottom: 1px solid transparent;

  /* 1 = drawn. Default so a row is correct with no JavaScript at all; the
     reveal sets it to 0 and animates back. */
  --rule: 1;
}

.news__item::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  bottom: -1px;
  height: 1px;
  background: var(--line);
  transform: scaleX(var(--rule));
  transform-origin: left center;
}

.news__item:hover::after {
  background: rgba(77, 157, 255, 0.45);
}

.news__item:hover {
  padding-left: 18px;
  background: linear-gradient(90deg, var(--void-2), transparent);
}

/* --- the "show more" row ------------------------------------------------

   Styled as another line of the list rather than as a button under it: same
   full width, same hairline, same left-shift on hover as a real row. Opening
   the rest should read as the list continuing, and a pill floating below the
   last hairline reads as leaving it.

   It IS a <button> — this is a disclosure, and everything about how it
   behaves for a keyboard and a screen reader depends on that. Only the paint
   is borrowed from the rows. */
.news__more {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 9px;
  width: 100%;
  padding: 20px 0;
  border: 0;
  border-bottom: 1px solid var(--line);
  background: none;
  cursor: pointer;
  color: var(--mist-3);
  font-family: var(--font-mono);
  font-size: 10.5px;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  transition: color 0.3s, background 0.35s;
}

.news__more:hover {
  color: var(--mist);
  background: linear-gradient(90deg, var(--void-2), transparent);
}

.news__more-chev {
  transition: transform 0.35s var(--ease-out);
}

.news__more[aria-expanded="true"] .news__more-chev {
  transform: rotate(180deg);
}

/* The chevron turning is the only thing that moves here, and it turns in
   place — kept under reduced motion for the same reason the card's cross-fade
   is: it is a state changing, not something travelling across the screen. */

.news__date {
  font-family: var(--font-mono);
  font-size: 11px;
  color: var(--mist-3);
}

/* h2 as well as h3: on the homepage an update sits under the section's h2, on
   /news it sits directly under the page h1. The heading LEVEL follows the
   document it is in; the look must not. */
.news__item h2,
.news__item h3 {
  font-family: var(--font-body);
  font-size: 18px;
  font-weight: 500;
  line-height: 1.4;
  letter-spacing: 0;
}

.news__item p {
  font-size: 13.5px;
  color: var(--mist-3);
  margin-top: 7px;
}

@media (max-width: 719px) {
  .news__item {
    grid-template-columns: 1fr;
    gap: 10px;
  }
  .news__item .chip {
    justify-self: start;
  }
}

/* 20. Contact ============================================================ */

/* The form is the taller column and the one that has to be filled in, so it
   gets the extra width. An even 1fr 1fr split left the inputs narrower than
   the prose beside them, which is backwards — nobody has to type into a
   paragraph. */
.contact {
  display: grid;
  grid-template-columns: minmax(0, 0.85fr) minmax(0, 1.15fr);
  gap: clamp(40px, 5vw, 76px);
  align-items: start;
}

/* Was three inline style attributes on the heading and the lede. Moved here
   so the spacing is in one place and the two pages cannot drift. */
.contact__intro h2 {
  margin: 18px 0 22px;
}

.contact__lede {
  max-width: 46ch;
}

.contact__rows {
  margin: 34px 0 0;
  border-top: 1px solid var(--line);
}

.contact__row {
  display: grid;
  grid-template-columns: 110px 1fr;
  gap: 20px;
  padding: 16px 0;
  border-bottom: 1px solid var(--line);
  align-items: baseline;
}

.contact__row dt {
  font-family: var(--font-mono);
  font-size: 9.5px;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--mist-4);
}

.contact__row dd {
  margin: 0;
  font-size: 14.5px;
  color: var(--mist-2);
}

.form {
  padding: 34px;
  display: flex;
  flex-direction: column;
  gap: 18px;
}

/* Two short fields per row. minmax(0,1fr) rather than 1fr because a grid
   column defaults to min-content width, and a long placeholder would
   otherwise push the row wider than the form. */
.form__pair {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 18px;
}

@media (max-width: 620px) {
  .form__pair {
    grid-template-columns: minmax(0, 1fr);
  }
}

.field {
  display: flex;
  flex-direction: column;
  gap: 9px;
}

/* :first-child, not `.field > span`.

   The unscoped selector was also catching .field__error and now the hint,
   so validation messages rendered in uppercase 9.5px mono — technically
   styled, effectively unreadable, and part of why this panel looked messy. */
.field > span:first-child {
  font-family: var(--font-mono);
  font-size: 9.5px;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--mist-3);
}

/* Marks the required fields. The asterisk is aria-hidden and decorative —
   the `required` attribute is what actually announces, and a screen reader
   reading "star" after every label is noise. */
.field__req {
  color: var(--accent-hi);
  font-weight: 400;
}

.field__hint {
  font-size: 12px;
  line-height: 1.5;
  color: var(--mist-4);
}

.field input,
.field textarea {
  background: rgba(0, 0, 0, 0.25);
  border: 1px solid var(--line);
  border-radius: 11px;
  padding: 14px 16px;
  color: var(--mist);
  font-size: 14px;
  font-family: inherit;
  outline: none;
  transition: border-color 0.25s, background 0.25s;
}

.field input:focus,
.field textarea:focus {
  border-color: var(--accent);
  background: rgba(0, 0, 0, 0.4);
}

.field textarea {
  resize: vertical;
  min-height: 118px;
}

.field[data-invalid="true"] input,
.field[data-invalid="true"] textarea {
  border-color: #e0705f;
}

.field__error {
  font-family: var(--font-body);
  font-size: 12px;
  line-height: 1.5;
  letter-spacing: 0;
  text-transform: none;
  color: #e0705f;
}

/* --- CAPTCHA -------------------------------------------------------------

   The image is drawn by captcha.js on a permanently pale plate, in both page
   themes, so it needs a border that reads against either rather than a
   background of its own. */
.captcha__row {
  display: flex;
  align-items: center;
  gap: 10px;
}

.captcha__image {
  display: block;
  width: 240px;
  height: 72px;
  max-width: 100%;
  border: 1px solid var(--line-2);
  border-radius: 8px;
  /* The plate is opaque, so nothing behind it can lower the contrast of the
     characters — the one property of this element that matters. */
  background: #eef1f6;
}

.captcha__reload {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 34px;
  height: 34px;
  flex: 0 0 auto;
  border: 1px solid var(--line-2);
  border-radius: 9px;
  background: var(--glass);
  color: var(--mist-2);
  cursor: pointer;
  transition: color 0.2s, border-color 0.2s, background 0.2s;
}

.captcha__reload:hover {
  color: var(--mist);
  border-color: var(--accent);
  background: var(--glass-2);
}

/* Half a second of feedback that a new image is on its way, for the server
   mode where fetching one is a round trip. */
.field--captcha[data-loading] .captcha__image {
  opacity: 0.45;
}

.field--captcha[data-loading] .captcha__reload {
  pointer-events: none;
  opacity: 0.5;
}

.field__error:empty {
  display: none;
}

.hp {
  position: absolute;
  left: -9999px;
  width: 1px;
  height: 1px;
  overflow: hidden;
}

.form__status {
  font-size: 13.5px;
  line-height: 1.55;
}

.form__status:empty {
  display: none;
}

.form__status[data-state="ok"] {
  color: #6fcf97;
}

.form__status[data-state="error"] {
  color: #e0705f;
}

.form__status a {
  color: inherit;
  text-decoration: underline;
}

@media (max-width: 1079px) {
  .contact {
    grid-template-columns: 1fr;
    gap: 44px;
  }
}

@media (max-width: 719px) {
  .form {
    padding: 24px;
  }
}

/* 21. Footer ============================================================= */

.footer {
  border-top: 1px solid var(--line);
  background: var(--void-2);
}

.footer__grid {
  max-width: var(--maxw);
  margin: 0 auto;
  padding: 80px var(--gutter) 44px;
  display: grid;
  grid-template-columns: 1.6fr 1fr 1fr 1.2fr;
  gap: 48px;
}

.footer__about {
  font-size: 13.5px;
  color: var(--mist-3);
  max-width: 34ch;
  margin: 20px 0 24px;
}

.footer__title {
  font-family: var(--font-mono);
  font-size: 9.5px;
  letter-spacing: 0.2em;
  text-transform: uppercase;
  color: var(--mist-4);
  margin-bottom: 20px;
}

.footer__links,
.footer__contact {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 12px;
}

.footer__links a {
  font-size: 13.5px;
  color: var(--mist-2);
  display: inline-block;
  transition: color 0.25s, transform 0.35s var(--ease-out);
}

.footer__links a:hover {
  color: var(--accent);
  transform: translateX(5px);
}

.footer__contact li {
  font-size: 13.5px;
  color: var(--mist-3);
}

.socials {
  display: flex;
  gap: 9px;
  list-style: none;
  margin: 0;
  padding: 0;
}

.socials a {
  width: 38px;
  height: 38px;
  border-radius: 50%;
  border: 1px solid var(--line);
  display: flex;
  align-items: center;
  justify-content: center;
  font-family: var(--font-mono);
  font-size: 9.5px;
  color: var(--mist-3);
  transition: border-color 0.25s, color 0.25s;
}

.socials a:hover {
  border-color: var(--accent);
  color: var(--accent);
}

.footer__bar {
  border-top: 1px solid var(--line);
}

.footer__bar-inner {
  max-width: var(--maxw);
  margin: 0 auto;
  padding: 22px var(--gutter);
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 20px;
  flex-wrap: wrap;
}

.footer__legal {
  display: flex;
  gap: 22px;
  flex-wrap: wrap;
  align-items: center;
  font-family: var(--font-mono);
  font-size: 10px;
  letter-spacing: 0.06em;
  color: var(--mist-4);
}

.footer__legal a {
  color: var(--mist-4);
}

.footer__legal a:hover {
  color: var(--mist);
}

/* --- 备案号 --------------------------------------------------------------
 *
 * 两个号码跟版权、隐私政策同排。父级 .footer__legal 是 flex，所以这里各自
 * 也是 flex：图标要和文字基线对齐，而 <img> 默认是 inline，会按基线摆放并
 * 在下方留出行内间隙 —— 表现为警徽比号码低两三个像素，且左右间距看起来不等。
 *
 * gap 而不是 margin：图标是可替换的（编辑可以换成公安部下发的官方 PNG），
 * 换一张尺寸略有出入的图时，gap 不受影响，margin 会。
 */
.footer__beian {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  white-space: nowrap;
}

.footer__beian-mark {
  /* 固定尺寸：官方警徽 PNG 通常是 20x20，占位 SVG 是 15x17。不锁死的话，
     换图那天页脚会跟着跳一下高度。 */
  width: 15px;
  height: 17px;
  flex: 0 0 auto;
  object-fit: contain;

  /* 两种图都不跟随主题：占位 SVG 是 <img> 引用的独立文档，拿不到页面的
     currentColor（所以文件里写死了一个中性灰）；官方警徽 PNG 是彩色位图。
     统一压一点不透明度，让它在页脚这排小字里不喧宾夺主。 */
  opacity: 0.85;
}

.footer__beian:hover .footer__beian-mark {
  opacity: 1;
}

@media (max-width: 1079px) {
  .footer__grid {
    grid-template-columns: 1fr 1fr;
  }
}

@media (max-width: 719px) {
  .footer__grid {
    grid-template-columns: 1fr;
  }
}

/* 22. Legal pages ======================================================== */

.legal {
  padding: 190px 0 120px;
}

.legal__inner {
  max-width: 72ch;
  margin: 0 auto;
  padding: 0 var(--gutter);
}

.legal__updated {
  margin: 16px 0 40px;
}

.legal h2 {
  font-family: var(--font-body);
  font-size: 19px;
  font-weight: 600;
  margin: 46px 0 14px;
}

.legal p,
.legal li {
  font-size: 15px;
  line-height: 1.75;
  color: var(--mist-2);
}

.legal p + p {
  margin-top: 14px;
}

.legal ul {
  margin: 14px 0 0;
  padding-left: 20px;
}

.legal li + li {
  margin-top: 9px;
}

.legal a {
  color: var(--accent);
  text-decoration: underline;
}

.legal__notice {
  border-left: 2px solid var(--accent);
  background: var(--void-2);
  border-radius: 0 14px 14px 0;
  padding: 20px 24px;
  margin-bottom: 40px;
}

.legal__notice p {
  color: var(--mist);
  font-size: 14px;
}

.nf {
  min-height: 100vh;
  min-height: 100svh;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  text-align: center;
  gap: 10px;
  padding: 40px var(--gutter);
  position: relative;
  z-index: 2;
}

/* 21b. Module-rail clearance =============================================
   Declared last on purpose: .hero, .rail, .footer__grid and
   .footer__bar-inner all set `padding` shorthand above, which would beat a
   padding-right declared earlier at equal specificity.

   Reserves the right margin once the rail appears, so the whole page shifts
   as one composition instead of the rail landing on top of right-aligned
   content. Above ~1800px the centred max-width leaves room on its own, but
   the reserve is harmless there. */

@media (min-width: 1280px) {
  :root {
    --rail-space: 136px;
  }

  .hero,
  .rail,
  .footer__grid,
  .footer__bar-inner {
    padding-right: calc(var(--gutter) + var(--rail-space));
  }
}

/* 22a. Scroll cue ========================================================
   A mouse outline whose wheel drifts down, under the showcase stage. The
   stage builds its devices from scroll position, so before you scroll it is
   an empty band — and arriving from the nav link, which jumps straight to it,
   that band reads as content that failed to load. See the partial. */

.scrollcue {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 10px;
  margin: 26px auto 0;
  color: var(--mist-3);

  /* It is advice, not furniture: once the stage has assembled it has nothing
     left to say, so it leaves. */
  transition: opacity 0.5s var(--ease-out), transform 0.5s var(--ease-out);
}

/*
 * Retired by initScrollCue, not by a sibling selector.
 *
 * `[data-showcase][data-state="open"] + .scrollcue` was the obvious rule and
 * it never matched once: ScrollTrigger's pin wraps the stage in a .pin-spacer
 * at runtime, so the two stop being siblings before the state ever changes.
 * app.js reads the attribute directly instead.
 */
.scrollcue[data-done="1"] {
  opacity: 0;
  transform: translateY(8px);
  pointer-events: none;
}

.scrollcue__mouse {
  position: relative;
  width: 26px;
  height: 42px;
  border: 1.5px solid var(--line-2);
  border-radius: 13px;
}

.scrollcue__wheel {
  position: absolute;
  left: 50%;
  top: 8px;
  width: 3px;
  height: 7px;
  margin-left: -1.5px;
  border-radius: 2px;
  background: var(--accent-hi);
  animation: scrollcue-wheel 1.9s var(--ease-out) infinite;
}

@keyframes scrollcue-wheel {
  0% { transform: translateY(0); opacity: 0; }
  22% { opacity: 1; }
  70% { transform: translateY(13px); opacity: 0; }
  100% { transform: translateY(13px); opacity: 0; }
}

.scrollcue__label {
  font-size: 9.5px;
  letter-spacing: 0.22em;
}

/*
 * Autoplaying, so reduced motion does not get it — the same line this project
 * draws everywhere else. The cue itself stays: it is the MESSAGE that matters
 * (there is more below), and the wheel simply holds still to deliver it.
 */
@media (prefers-reduced-motion: reduce) {
  .scrollcue__wheel {
    animation: none;
    opacity: 1;
    transform: translateY(6px);
  }
}

/* 22b. Inertial scrolling ================================================
   Required by Lenis (assets/js/smooth.js). Present unconditionally: these
   rules only take effect once Lenis puts its own classes on <html>, so on a
   reduced-motion visitor — or when the CDN is unreachable and Lenis never
   initialises — not one of them matches anything.

   The scroll-behavior override is the important one. `scroll-behavior: smooth`
   and a script driving the scroll position every frame are two things trying
   to own the same number, and the result is a page that fights the wheel. */

html.lenis,
html.lenis body {
  height: auto;
}

.lenis.lenis-smooth {
  scroll-behavior: auto !important;
}

/* Opt-out hook for anything with its own scroller inside it — a code block, a
   map, a modal body. Nothing uses it yet; it costs one line to have ready. */
.lenis.lenis-smooth [data-lenis-prevent] {
  overscroll-behavior: contain;
}

.lenis.lenis-stopped {
  overflow: hidden;
}

/* 23. Motion & print ===================================================== */

/* GSAP sets inline styles for reveals. This is the no-JS / pre-hydration
   state: everything visible. Elements are only hidden once app.js confirms
   GSAP loaded, so a CDN failure can never blank the page. */
[data-anim] {
  opacity: 1;
}

/* SplitText's masked lines — the wrapper clips while the line inside rises out
   of it. Only the moving half is promoted; promoting the clip as well would
   put a layer on every line of every heading for nothing. Mirrors .pg-line in
   page.css, which does the same job on the standalone pages. */
.anim-line {
  will-change: transform;
}

/*
 * ==========================================================================
 * REDUCED MOTION, SITE-WIDE — AND WHY THIS IS NOT THE USUAL SNIPPET
 * ==========================================================================
 *
 * This used to be the boilerplate every project copies:
 *
 *     *, *::before, *::after {
 *       animation-duration: 0.01ms !important;
 *       transition-duration: 0.01ms !important;
 *     }
 *
 * It is well meant and it was quietly wrong here, in a way that took a long
 * time to see: `transition-duration: 0.01ms !important` does not remove
 * MOTION, it removes every TRANSITION — including cross-fades, which move
 * nothing. On a machine with the OS setting on, every hover state on this site
 * snapped: the project description, the nav chip, the scroll cue, the whole
 * CSS tier of the page reveals. Tuning any of those durations had no effect at
 * all, because this rule overrode them with !important from three thousand
 * lines away.
 *
 * That contradicts the position the rest of this codebase takes everywhere
 * else — the intro overlay, glyph.js, the reveal tiers, the page art trace:
 * a reduced-motion visitor loses the MOTION, not the DESIGN. A panel sliding
 * up the card is motion. The same panel dissolving in place is not.
 *
 * So the blanket duration override is gone and replaced by a property filter.
 * Everything that moves or changes size still snaps, because it is absent from
 * this list; everything that only changes how visible or what colour something
 * is keeps whatever duration its own rule asked for.
 *
 * ANIMATIONS STAY OFF. Those are autoplaying by definition — they run at the
 * visitor rather than answering something the visitor did — which is exactly
 * the distinction this site draws in every other file.
 *
 * If you add a property here, ask one question: can it move anything, or
 * change its size? If yes, it does not belong.
 */
@media (prefers-reduced-motion: reduce) {
  html {
    scroll-behavior: auto;
  }

  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;

    /*
     * NOT transition-duration. The list below is what a transition is still
     * allowed to touch: opacity and colour, neither of which displaces
     * anything on screen. transform, width, height, inset, letter-spacing and
     * the rest are simply not in it, so they apply instantly.
     */
    transition-property: opacity, color, background-color, border-color,
      box-shadow, fill, stroke !important;
  }
}

@media print {
  .dock,
  .menu,
  .intro,
  .factory,
  .field,
  .sky,
  .progress,
  .railbar {
    display: none !important;
  }

  body {
    background: #fff;
    color: #000;
  }

  .section {
    padding: 20px 0;
    break-inside: avoid;
  }
}

/* 24. Light theme ========================================================

   Set data-theme="light" on <html>. Everything below is either a token
   override or one of the eleven places in this file that hardcodes a colour
   instead of reading a token — there are only eleven, which is the reason a
   second theme is a hundred lines rather than a second stylesheet.

   The palette is glyph.js's, deliberately: ink #1d2947, accent #2f6bff, plate
   #eef2f9. When the WebGL backdrop is running it paints the page background
   itself, so any drift between these values and the ones in glyph.js shows up
   as a visible seam where a glass panel meets the canvas behind it. Change
   both or neither.
   ====================================================================== */

:root[data-theme="light"] {
  --void: #eef2f9;
  --void-2: #e4ebf6;

  /* Text is a very dark navy rather than black. Pure black on a blue-tinted
     plate reads as a hole; carrying a little of the plate's hue into the type
     is what makes the page look composed rather than inverted. */
  --mist: #101a2f;
  --mist-2: rgba(16, 26, 47, 0.78);
  /* Secondary body copy. Carried straight over from the dark build at 0.52,
     where it sat on near-black and had plenty of contrast. On a light plate
     with a live scene behind it that lands around 2.7:1 — under the 4.5
     minimum and visibly hard to read. Raised until it measures over 5. */
  --mist-3: rgba(16, 26, 47, 0.76);
  --mist-4: rgba(16, 26, 47, 0.42);

  --accent: #2f6bff;
  /* On dark, --accent-hi is lighter than --accent. On light it has to go the
     other way or hover states lose contrast against the plate. */
  --accent-hi: #1b52e0;
  --accent-deep: #143fb5;

  --line: rgba(16, 26, 47, 0.11);
  --line-2: rgba(16, 26, 47, 0.2);

  /* Glass goes white and much more opaque. These panels sit over a moving
     particle field, and at the dark build's 3% fill the animation reads
     straight through the body copy. */
  --glass: rgba(255, 255, 255, 0.58);
  --glass-2: rgba(255, 255, 255, 0.78);

  --grid: rgba(16, 26, 47, 0.05);

  /* 浮层翻成近白。这些面板压在移动的粒子场上，半透明会让粒子直接从文字
     后面穿过去，所以比深色版更不透明。 */
  --float: rgba(252, 253, 255, 0.94);
  --float-2: rgba(252, 253, 255, 0.96);
}

/* --- the eleven hardcoded exceptions ---------------------------------- */

/* Primary buttons are dark-on-light here, so hovering to white inverts the
   contrast instead of strengthening it. Go to accent. */
:root[data-theme="light"] .btn--light:hover,
:root[data-theme="light"] .dock__cta:hover {
  background: var(--accent);
  color: #fff;
}

/* A white 1px top highlight is invisible on a white panel. On light the same
   job — separating the panel lid from its face — is done by a dark hairline. */
:root[data-theme="light"] .glass--lit::before {
  background: linear-gradient(
    90deg,
    transparent,
    rgba(16, 26, 47, 0.16),
    transparent
  );
}

/* The hero's white radial bloom does nothing over a pale plate. Retint it to
   the accent so the top of the page still lifts. */
:root[data-theme="light"] .sky::before {
  background: radial-gradient(
    ellipse at 50% 50%,
    rgba(47, 107, 255, 0.1) 0%,
    rgba(47, 107, 255, 0.035) 42%,
    transparent 72%
  );
}

/* 80%-black drop shadows are a dark-build device: they read as depth against
   near-black and as dirt against near-white. */
:root[data-theme="light"] .card:hover .card__media,
:root[data-theme="light"] .card.is-hover .card__media {
  border-color: rgba(47, 107, 255, 0.5);
  box-shadow: 0 34px 80px -30px rgba(16, 26, 47, 0.28),
    0 0 70px -20px rgba(47, 107, 255, 0.22);
}

:root[data-theme="light"] .stat:hover,
:root[data-theme="light"] .cap:hover,
:root[data-theme="light"] .news__item:hover,
:root[data-theme="light"] .hstat:hover {
  border-color: rgba(47, 107, 255, 0.42);
  box-shadow: 0 18px 44px -26px rgba(16, 26, 47, 0.26),
    0 0 40px -18px rgba(47, 107, 255, 0.18);
}

/* The rail beam's glow is a hardcoded blue. A 1px line's halo is small, but
   it is the one bit of chrome that sits directly against the particle field,
   so a stray hue there is more visible than its size suggests. */
:root[data-theme="light"] .modules__beam {
  box-shadow: 0 0 10px 1px rgba(47, 107, 255, 0.45);
}

/* Form fields were darker than their surroundings; on light they have to be
   lighter than the glass they sit in, or they read as disabled. */
:root[data-theme="light"] .field input,
:root[data-theme="light"] .field textarea {
  background: rgba(255, 255, 255, 0.72);
}

:root[data-theme="light"] .field input:focus,
:root[data-theme="light"] .field textarea:focus {
  background: #fff;
}

/* --- the WebGL backdrop ------------------------------------------------ */

/* Behind everything, above the root background fill. --void stays set on body
   so the page still looks intentional in the seconds before the canvas
   appears, and for anyone the capability probe turns the scene off for. */
.backdrop {
  position: fixed;
  inset: 0;
  z-index: -1;
  pointer-events: none;

  /*
   * 场景的存在感，一个总开关。默认 1 —— 也就是不做任何全局压暗。
   *
   * 这里踩过一次，记下来免得再犯：**这个值和遮罩是相乘的**。
   *
   * .backdrop::after 是本元素的子节点，蒙纱区（项目案例、解决方案、产品能力）
   * 它的不透明度是 0.8，也就是场景只露出 20%。那 20% 是当初实测调出来的
   * （见下面遮罩那条注释里"正文对比度高于 5:1"）。整层再乘一个 0.28，
   * 结果是 0.28 × 0.2 = 5.6% —— 页面上几乎什么都看不见了。
   *
   * 所以想让某一段更淡，改那一段的遮罩，不要动这个总开关。这个开关是留给
   * "整站都嫌吵"的情况，动它之前先算一遍乘出来是多少。
   *
   * 由 app.js 从 SITE_CONFIG.backdropStrength 写入，页面里可调。
   * 关于我们之前那段淡出是 app.js 用 scrub 跟着滚动改这个变量，不走 transition。
   */
  opacity: var(--backdrop-strength, 1);
  transition: opacity 1.1s var(--ease-out);
}

/* The showcase stage turns the backdrop off rather than veiling it.

   Everywhere else on the page the scene is quieted with `.backdrop::after`
   at 0.8 — a compromise the comment on that rule admits is one step from
   simply hiding it. Over #showcase there is nothing to compromise about:
   the section's whole argument is the real interface in front of the page,
   and a code-particle rendering of the same three products competing behind
   it is the exact clutter the veil exists to manage.

   showcase.js sets data-stage on <html> from the pin's onToggle, so the
   fade is tied to the section actually being on screen rather than to a
   scroll position that stops being true after a resize. */
:root[data-stage="on"] .backdrop {
  opacity: 0;
}

/*
 * 关于我们往后也照 #showcase 的办法处理：把场景关掉，而不是蒙一层。
 *
 * 这两段之前走的是 data-veil —— 背后是 --void 铺满、不透明度 0.8。那层纱
 * 的本意是"让场景还看得见、又不跟正文抢"，实际观感却是一堵没有信息的墙，
 * 而且和 #showcase 那段（场景整个关掉）看着几乎一样，只是多了一点点若隐若现
 * 的颗粒。既然两边看起来本来就该一样，就用同一个办法，而不是再造一层纹样。
 *
 * 关掉之后露出来的是 .grid-bg —— 站点自己的蓝图网格，z-index 0，一直在
 * .backdrop（-1）上面。所以这两段的底色是「--void + 78px 制图网格」，
 * 跟 #showcase 完全一致，不需要任何新的图案。
 *
 * app.js 用一个单独的 ScrollTrigger 管这个属性：从 #about 顶部一直到文档
 * 末尾（end: "max"），所以新闻动态和联系我们都在范围内，而不是靠"后面没有
 * 别的触发器所以属性会留着"这种默契 —— 那个默契在直接跳锚点（导航右上角
 * 的「联系我们」就是）时不成立，中间的触发器一个都不会进入。
 */
/*
 * data-quiet 不再负责关掉场景。
 *
 * 关不关现在由 app.js 的时间线写 --backdrop-strength 决定（淡出 -> 保持 ->
 * 淡入，全程跟着滚动）。这里如果还留一条 opacity: 0，它会压过还在渐变中的
 * 变量：淡出走到一半就被拽到全黑，接近联系我们时又不肯让粒子回来 ——
 * 前面铺的渐变全白做。
 *
 * 这个属性留着是给另外两件事用的：收掉背景的悬停说明，以及下面把蓝图网格的
 * 底部渐隐推到 92%（场景不显示时网格是唯一的背景，不能让它自己淡没）。
 */

:root[data-quiet="on"] .factory__label {
  opacity: 0;
}

/* The hover captions belong to the backdrop, so they go with it. app.js gates
   these in setFactoryLabel too; this is the guarantee that does not depend on
   a raycaster frame landing at the right moment. */
:root[data-stage="on"] .factory__label {
  opacity: 0;
}

.backdrop canvas {
  display: block;
}

/* The hero's own scene is redundant once the backdrop is carrying the three
   products across the whole page. */
:root[data-backdrop="on"] .factory {
  display: none;
}

/* --- intro --------------------------------------------------------------

   The mark's gold is a presentation attribute on the SVG in the markup, which
   CSS outranks. Setting it here rather than editing the attribute keeps the
   file a single source of truth for colour and lets the intro follow the
   theme. Gold survives the switch: it is the logo, not a theme colour, and it
   holds up on a pale plate. */
.intro__tier {
  stroke: var(--gold);
}

.intro__fill {
  fill: var(--gold);
}

/* --- backdrop veil ------------------------------------------------------

   A scrim between the WebGL canvas and the content. The camera's close-up
   stops put dense particles directly behind body copy; without this the
   section reads as clutter no matter how good the text contrast is.

   It sits inside .backdrop (so still behind all content) and is driven by
   data-veil on <html>, set per section by app.js. The hero stays clear —
   there is very little copy over it and it is the shot worth showing. */
.backdrop::after {
  content: "";
  position: absolute;
  inset: 0;
  background: var(--void);
  opacity: 0;
  transition: opacity 1s var(--ease-out);
  pointer-events: none;
}

:root[data-veil="on"] .backdrop::after {
  /* Enough to quiet the field and carry text, not so much that the scene
     disappears — at 1.0 you have simply turned the background off. Measured:
     this keeps worst-case body-copy contrast above 5:1. */
  opacity: 0.8;
}

@media (prefers-reduced-motion: reduce) {
  .backdrop::after { transition: none; }
  .backdrop { transition: none; }
}

/* --- theme switch -------------------------------------------------------

   Auto follows the clock in Asia/Shanghai, not the visitor's timezone — see
   the note in app.js for why. Light and dark pin it. */
/* This bar was the only thing in the dock without a plate behind it.

   .dock__bar, .mark and .dock__lang all sit on an opaque-ish fill plus a
   backdrop-filter; .theme had `background: var(--glass)` — rgba(255,255,255,
   0.03) — and no blur at all. So its 10px labels were rendered straight onto
   the WebGL backdrop, and the effective contrast changed from frame to frame
   as the particles moved beneath them. Measured against flat --void the text
   passes at ~7:1, which is exactly why this did not show up as a contrast
   bug: the number is fine, the background is not.

   Same plate as the nav bar it sits beside, so the whole dock now reads as
   one object rather than a bar with a floating group of words next to it. */
.theme {
  display: flex;
  gap: 2px;
  padding: 3px;
  border: 1px solid var(--line-2);
  border-radius: var(--r-pill);
  background: rgba(12, 10, 18, 0.55);
  backdrop-filter: blur(20px) saturate(150%);
  -webkit-backdrop-filter: blur(20px) saturate(150%);
}

/* 10px was small enough that the 0.62-alpha grey read as decoration rather
   than as three choosable options. --mist-2 and 11px, which is still quieter
   than the nav links beside it without disappearing into the plate. */
.theme__btn {
  font-family: var(--font-mono);
  font-size: 11px;
  letter-spacing: 0.06em;
  color: var(--mist-2);
  background: transparent;
  border: 0;
  border-radius: var(--r-pill);
  padding: 5px 9px;
  cursor: pointer;
  transition: color 0.2s, background 0.2s;
}

.theme__btn:hover { color: var(--mist); }

.theme__btn[aria-pressed="true"] {
  background: var(--accent);
  color: #fff;
}

/* The dock is tight on small screens; the language link and burger matter
   more there than a three-way theme switch. */
@media (max-width: 1080px) {
  .theme { display: none; }
}

/* --- detail links on cards ------------------------------------------------ */

/* The project card stays an <article>: app.js drags `.card` horizontally, and
   making the whole card a link turns every drag into a navigation. A discrete
   link inside it is the correct target. The news row has no drag and is a
   whole-row link instead. */
/* The hover lens is the visible affordance for opening a case study, so this
   link is not drawn — but it must still exist. The card navigates from a
   pointer handler, which a keyboard cannot trigger and a screen reader cannot
   announce; this anchor is what makes the destination reachable and readable.

   Not display:none and not aria-hidden — both would remove it from the
   accessibility tree, which is the entire point of keeping it. It becomes
   visible on keyboard focus so a sighted keyboard user can see where they are. */
.card__jump {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

.card__jump:focus-visible {
  position: static;
  width: auto;
  height: auto;
  margin: 10px 0 0;
  overflow: visible;
  clip-path: none;
  display: inline-block;
  font-size: 12.5px;
  color: var(--accent-hi);
  outline: 2px solid var(--accent-hi);
  outline-offset: 3px;
}

/* Only cards that actually go somewhere invite a click. */
.card[data-href] { cursor: pointer; }

/*
 * TOUCH HAS NO LENS, SO THE CARD NEEDS A VISIBLE WAY IN.
 *
 * On a fine pointer the eye that follows the cursor IS the affordance: it
 * appears over the card and says "this opens". A finger gets none of that,
 * and `cursor: pointer` above is equally invisible there — so a tappable
 * card looked exactly like an untappable one, with nothing on screen
 * distinguishing the projects that have a detail page from those that
 * don't.
 *
 * This un-hides the link that was already in the markup for screen readers,
 * which means touch gets a real <a>: it opens on tap without any JS, it
 * long-presses into "open in new tab", and it shows the destination in the
 * status bar. The JS path stays as the whole-card hit area.
 *
 * Scoped to [data-href] so it appears only on cards that lead somewhere.
 */
@media (hover: none), (pointer: coarse) {
  .card[data-href] .card__jump {
    position: static;
    width: auto;
    height: auto;
    margin: 12px 0 0;
    overflow: visible;
    clip-path: none;
    white-space: nowrap;
    display: inline-flex;
    align-items: center;
    gap: 6px;
    /* 44px of tappable height without 44px of visual weight: the padding is
       vertical only, so the text sits where the layout wants it. */
    padding: 11px 0;
    font-size: 12.5px;
    letter-spacing: 0.04em;
    color: var(--accent-hi);
    text-decoration: none;
  }

  .card[data-href] .card__jump::after {
    content: "";
    width: 14px;
    height: 1px;
    background: currentColor;
    opacity: 0.55;
  }
}

a.news__item { text-decoration: none; color: inherit; }
a.news__item:hover h3 { color: var(--accent-hi); }

/* --- module rail: liquid-glass selection ---------------------------------- */

/*
 * The selected row was marked only by a 1px beam at its right edge. This gives
 * the row itself a glass pane, in the iOS 26 idiom: the background is not
 * tinted plastic but the page *seen through* a lens.
 *
 * Four things do that, and dropping any one makes it read as flat translucency:
 *   1. backdrop-filter blur AND saturate — the saturation boost is what makes
 *      colour bleed through the pane rather than turning grey;
 *   2. a very low-alpha fill, so the pane carries almost no colour of its own;
 *   3. a brighter top edge than bottom, which is where the "lit glass" reading
 *      comes from — a uniform border looks like a box;
 *   4. an inner highlight along the top inside edge, the specular line.
 *
 * The beam stays: colour alone should not carry state, and the beam is the
 * part that survives forced-colours and low-transparency modes.
 */
.modules__item {
  position: relative;
  border-radius: 9px;
  isolation: isolate;
}

.modules__item::before {
  content: "";
  position: absolute;
  inset: -3px -8px;
  z-index: -1;
  border-radius: 10px;
  opacity: 0;
  transform: scale(0.97);
  background: linear-gradient(
    145deg,
    rgba(255, 255, 255, 0.11) 0%,
    rgba(255, 255, 255, 0.05) 42%,
    rgba(255, 255, 255, 0.02) 100%
  );
  border: 1px solid rgba(255, 255, 255, 0.06);
  border-top-color: rgba(255, 255, 255, 0.16);
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.14),
    0 4px 18px -6px rgba(0, 0, 0, 0.5);
  -webkit-backdrop-filter: blur(14px) saturate(180%);
  backdrop-filter: blur(14px) saturate(180%);
  transition: opacity 0.34s var(--ease-out), transform 0.34s var(--ease-out);
}

.modules__item[aria-current="true"]::before {
  opacity: 1;
  transform: scale(1);
}

/* Hover previews the pane, faintly — it reads as the same material. */
.modules__item:hover::before { opacity: 0.45; transform: scale(1); }
.modules__item[aria-current="true"]:hover::before { opacity: 1; }

:root[data-theme="light"] .modules__item::before {
  background: linear-gradient(
    145deg,
    rgba(255, 255, 255, 0.72) 0%,
    rgba(255, 255, 255, 0.46) 100%
  );
  border-color: rgba(16, 26, 47, 0.08);
  border-top-color: rgba(255, 255, 255, 0.9);
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.85),
    0 4px 16px -6px rgba(16, 26, 47, 0.22);
}

/* backdrop-filter is expensive and the pane is decorative: drop the blur when
   the reader has asked for less, keeping the fill so state stays visible. */
@media (prefers-reduced-transparency: reduce) {
  .modules__item::before {
    -webkit-backdrop-filter: none;
    backdrop-filter: none;
    background: rgba(255, 255, 255, 0.1);
  }
}

/* --- wordmark beside the dock logo ---------------------------------------- */

/* Hidden until the logo is hovered or focused, then it slides out. The dock is
   a fixed-width island and a permanent company name would push the nav links
   toward the edge on narrower screens; revealing it on demand keeps the
   resting layout while still answering "whose site is this".

   max-width rather than display, so the reveal can animate — display has no
   intermediate states to tween. */
/* The mark is a fixed-size circle (--dock-btn square, border-radius 50%), so
   the name cannot live inside it — laying it out as a flex sibling tried to
   expand the circle and got clipped instead. Absolute positioning puts it
   beside the circle without touching the circle's geometry or shifting the
   dock's other children. */
.mark {
  position: relative;
  overflow: visible;
}

.mark__name {
  position: absolute;
  left: calc(100% + 12px);
  top: 50%;
  transform: translateY(-50%) translateX(-6px);
  white-space: nowrap;
  pointer-events: none;
  opacity: 0;
  font-size: 12.5px;
  letter-spacing: 0.01em;
  color: var(--mist-2);
  transition: opacity 0.24s, transform 0.32s var(--ease-out);
}

.mark:hover .mark__name,
.mark:focus-visible .mark__name {
  opacity: 1;
  transform: translateY(-50%) translateX(0);
}

/* Touch devices have no hover, so there the name is simply always shown —
   there is no gesture that would otherwise reveal it. */
@media (hover: none) {
  .mark__name { opacity: 1; transform: translateY(-50%); }
}

@media (prefers-reduced-motion: reduce) {
  .mark__name { transition: none; }
}

/* --- social hover thumbnail ----------------------------------------------- */

/* A QR code cannot be a link — it has to be looked at. Uploading an image to a
   social row turns it into a popover instead of a destination.

   CSS only: :hover and :focus-within both open it, so a keyboard reaches it
   without a script. The panel decides which rows have one; the markup only
   carries .social--pop when an image was actually uploaded. */
.social { position: relative; }

.social__pop {
  position: absolute;
  top: calc(100% + 10px);
  left: 50%;
  transform: translateX(-50%) translateY(-4px) scale(0.96);
  transform-origin: top center;
  /* A definite width. Without it the popover shrink-to-fits its image
     while the global `img { max-width: 100% }` reset makes the image
     defer to the popover — each waiting on the other, and both collapse
     to the width of the tiny row behind them. Measured at 3px. */
  width: max-content;
  padding: 7px;
  background: var(--deep);
  border: 1px solid var(--line);
  border-radius: 12px;
  box-shadow: 0 18px 40px -12px rgba(0, 0, 0, 0.7);
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
  transition: opacity 0.18s, transform 0.22s var(--ease-out), visibility 0.18s;
  z-index: 20;
}

.social__pop img {
  display: block;
  width: 132px;
  height: 132px;
  object-fit: contain;
  border-radius: 6px;
  background: #fff;
}

/* A small tail, so the popover reads as attached to the row it belongs to
   rather than floating over the footer. */
.social__pop::after {
  content: "";
  position: absolute;
  bottom: 100%;
  left: 50%;
  margin-left: -6px;
  border: 6px solid transparent;
  border-bottom-color: var(--line);
}

.social--pop:hover .social__pop,
.social--pop:focus-within .social__pop {
  opacity: 1;
  visibility: visible;
  transform: translateX(-50%) translateY(0) scale(1);
}

@media (prefers-reduced-motion: reduce) {
  .social__pop { transition: opacity 0.18s, visibility 0.18s; transform: translateX(-50%); }
  .social--pop:hover .social__pop,
  .social--pop:focus-within .social__pop { transform: translateX(-50%); }
}

/* --- showcase device as a link ---------------------------------------------
 *
 * Only applied when the slot actually has a product page — showcase.js adds
 * these classes, so an unpublished device keeps the old, inert presentation
 * and does not advertise a destination it does not have.
 */
.dev--linked {
  cursor: pointer;
}

.dev__caption--link {
  color: inherit;
  text-decoration: none;
  border-bottom: 1px solid transparent;
  transition: border-color 0.4s cubic-bezier(0.22, 1, 0.36, 1),
    color 0.3s cubic-bezier(0.22, 1, 0.36, 1);
}

/* Hovering anywhere on the device lights the chip, so the whole slot reads as
   one target rather than as a device that happens to sit above a link. */
.dev--linked:hover .dev__caption--link,
.dev__caption--link:focus-visible {
  color: var(--mist);
  border-bottom-color: var(--accent-hi);
}
