/* ==========================================================================
   瑶瑶 20 岁生日网站 —— 样式与动画
   --------------------------------------------------------------------------
   设计方向：「深夜的信」
     · 几乎全片是近黑的靛蓝夜色，只有两种颜色：月光似的冷紫白 + 一点暖琥珀
     · 全页只有一处是「亮」的 —— 最底下那封信。划到那里，灯才亮起来
     · 字用衬线（宋体 / Cormorant），不用圆体无衬线，读起来像印出来的书
   ========================================================================== */

/* ---------- 字体：Latin 子集，只有 37KB，中文字仍然走系统字体 ---------- */
@font-face {
  font-family: 'Cormorant Garamond';
  src: url('fonts/cormorant-garamond.woff2') format('woff2');
  font-weight: 300 600;         /* 可变字体，一个字重文件覆盖整个区间 */
  font-style: normal;
  font-display: swap;           /* 字体没加载完先用后备字体显示，不白屏 */
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
                 U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193,
                 U+2212, U+2215, U+FEFF, U+FFFD;
}

/* ---------- 开场大标题的毛笔字体：柳建毛草 ----------
   中文艺术字体不需要联网、也不需要整包 —— 开场那一行只有四个字，
   所以这个文件是「只留下生日快乐的四个字形」裁出来的，2KB（完整版是 MB 级）。
   授权是 SIL OFL 1.1，可以自由使用和再分发，授权原文在 fonts/liujianmaocao-OFL.txt。
   unicode-range 把这四个字钉死，万一有谁把 --f-title 用到别处，
   别的字也会老实退回宋体，而不是变成方框。
   想换标题文字的话，就得重新裁一份 —— 见 README「关于字体」那一节 */
@font-face {
  font-family: 'Birthday Brush';
  src: url('fonts/liujianmaocao-4char.woff2') format('woff2');
  font-weight: 400;
  font-style: normal;
  font-display: swap;
  unicode-range: U+751F, U+65E5, U+5FEB, U+4E50;
}

:root {
  /* 夜 */
  --night:      #08060d;
  --night-2:    #100b1c;

  /* 月光（冷紫白）—— 全站文字的主色 */
  --moon:       #ebe4f8;
  --moon-2:     #b3a7d2;
  --moon-3:     #6e6290;

  /* 琥珀 —— 全站唯一的暖色，只用在极少数地方（分隔线、数字、印章） */
  --ember:      #e0913f;
  --ember-deep: #b8562c;

  /* 信纸 */
  --paper:      #f8f1e7;
  --paper-ink:  #2e2633;
  --paper-soft: #6a5a63;

  --f-latin: 'Cormorant Garamond', 'Songti SC', Georgia, 'Times New Roman', serif;
  /* 中文优先衬线；机器上没有中文衬线时退到无衬线，宁可朴素也不要烂字体 */
  --f-cn: "Songti SC", "Source Han Serif SC", "Noto Serif CJK SC",
          "Noto Serif SC", "STSong", "PingFang SC", "HarmonyOS Sans SC",
          "Microsoft YaHei", serif;
  /* 开场大标题专用的毛笔字体。只有「生日快乐」四个字形 ——
     字体没加载出来或者标题被改成别的字时，按后面的名单一路退到宋体 */
  --f-title: 'Birthday Brush', "Songti SC", "Source Han Serif SC",
             "Noto Serif CJK SC", "STSong", serif;
}

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

html {
  -webkit-text-size-adjust: 100%;
  overflow-x: hidden;
  overscroll-behavior-y: contain;      /* 防止划到顶部误触微信下拉刷新 */
  background: var(--night);
}

body {
  margin: 0;
  /* 这里千万不要写 background —— body 的背景画在负 z-index 元素「之上」，
     一旦给它上色，整个 .night 图层（光晕/颗粒/暗角）会被全部盖住。
     页面底色由 html 那一条负责，它会传给画布，橡皮筋回弹区也是这个色 */
  color: var(--moon);
  font-family: var(--f-cn);
  /* 这里千万不要写 overflow —— 横向裁剪只写在 html 上（上面那条）。
     只要 html 的 overflow 不是 visible，body 自己的 overflow 就不再上传给
     视口，body 会变成一个独立的滚动容器：手机上手指一划，手势全被它截住
     （它底下总有些没浮现的元素带着 translateY，撑出几十像素的假溢出），
     划到头就停，永远轮不到真正的页面滚动 —— 「第一页划不动」就是这么来的。
     body 不是滚动容器，手势才会顺着链路交给 html。横向裁剪交给 html 足够 */
  -webkit-tap-highlight-color: transparent;
  -webkit-touch-callout: none;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  padding-bottom: env(safe-area-inset-bottom);
}

::selection { background: rgba(224, 145, 63, .35); color: #fff; }

/* ---------- 夜色 ---------- */
.night {
  position: fixed;
  inset: 0;
  z-index: -3;
  overflow: hidden;
  /* 提到独立的合成层。iOS Safari 惯性滚动时，fixed 的整屏图层如果不单独成层，
     每帧都要重绘，会看到背景轻微抖动/撕裂 */
  transform: translateZ(0);
  background:
    radial-gradient(120% 80% at 50% 0%, #150e26 0%, #0b0716 45%, var(--night) 100%);
}

/* 用 radial-gradient 自身的柔和衰减代替 filter:blur()，
   这样动画只跑 transform，能走 GPU 合成，低端机也不掉帧 */
.night__bloom {
  position: absolute;
  border-radius: 50%;
  will-change: transform;
}

.night__bloom--a {
  width: 130vmax; height: 130vmax;
  top: -70vmax; left: -55vmax;
  background: radial-gradient(closest-side, rgba(150, 120, 220, .30), rgba(150, 120, 220, 0) 68%);
  animation: drift-a 52s ease-in-out infinite alternate;
}

.night__bloom--b {
  width: 120vmax; height: 120vmax;
  bottom: -62vmax; right: -52vmax;
  background: radial-gradient(closest-side, rgba(120, 95, 190, .26), rgba(120, 95, 190, 0) 68%);
  animation: drift-b 64s ease-in-out infinite alternate;
}

/* 唯一的暖光。滚动到后面会慢慢变亮 —— 像走近一盏灯。
   这个 opacity 由 main.js 按滚动进度逐帧写（直接在元素上写，不走 CSS 变量：
   往 :root 写自定义属性会让整棵文档树的样式失效，滚动时白白掉帧） */
.night__bloom--c {
  width: 105vmax; height: 105vmax;
  bottom: -42vmax; left: 50%;
  margin-left: -52vmax;
  background: radial-gradient(closest-side, rgba(224, 145, 63, .30), rgba(224, 145, 63, 0) 70%);
  opacity: .30;
  animation: drift-c 44s ease-in-out infinite alternate;
}

@keyframes drift-a {
  from { transform: translate3d(0, 0, 0) scale(1); }
  to   { transform: translate3d(9vmax, 7vmax, 0) scale(1.14); }
}
@keyframes drift-b {
  from { transform: translate3d(0, 0, 0) scale(1.1); }
  to   { transform: translate3d(-10vmax, -6vmax, 0) scale(1); }
}
@keyframes drift-c {
  from { transform: translate3d(-5vmax, 3vmax, 0) scale(1); }
  to   { transform: translate3d(5vmax, -4vmax, 0) scale(1.16); }
}

/* 胶片颗粒。没有它，整页就是一片干净的渐变，一眼假；
   有了它，暗部有了质感，像被灯光照着的旧纸。
   故意不用 mix-blend-mode —— 那会让合成器每帧重算全屏，手机上掉帧 */
.night__grain {
  position: absolute;
  inset: -10%;
  opacity: .05;
  background-image: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='160'%20height='160'%3E%3Cfilter%20id='n'%3E%3CfeTurbulence%20type='fractalNoise'%20baseFrequency='.8'%20numOctaves='4'%20stitchTiles='stitch'/%3E%3CfeColorMatrix%20type='saturate'%20values='0'/%3E%3C/filter%3E%3Crect%20width='160'%20height='160'%20filter='url(%23n)'/%3E%3C/svg%3E");
  background-size: 160px 160px;
}

/* 暗角：把注意力收到中间 */
.night__well {
  position: absolute;
  inset: 0;
  background: radial-gradient(115% 85% at 50% 45%, rgba(0, 0, 0, 0) 40%, rgba(0, 0, 0, .55) 100%);
}

/* ---------- 空气里的星尘：近 / 中 / 远三层 ----------
   三层拉开纵深。全部只动 transform 和 opacity，走 GPU 合成。
   越往下划近景越亮 —— 像越走越深，空气里的光越多。
   两层的 opacity 由 main.js 逐帧直接写在元素上（见 .night__bloom--c 的说明） */
.motes {
  position: fixed;
  inset: 0;
  z-index: -2;
  pointer-events: none;
  overflow: hidden;
  transform: translateZ(0);        /* 同上：iOS 上单独成层，滚动时不用重绘 */
}

.motes__layer { position: absolute; inset: 0; }

.motes__layer--far  { opacity: .85; }
.motes__layer--near { opacity: .60; }

.mote {
  position: absolute;
  top: 0;
  width: var(--s);
  height: var(--s);
  border-radius: 50%;
  background: radial-gradient(circle, rgba(235, 228, 248, .95), rgba(235, 228, 248, 0) 70%);
  opacity: 0;
  animation: mote-fall var(--dur) linear var(--delay) infinite;
}

/* 远景不用纯白，压成更暗的紫灰，才退得进去 */
.motes__layer--far .mote {
  background: radial-gradient(circle, rgba(150, 136, 186, .9), rgba(150, 136, 186, 0) 72%);
}

/* 近景是几点萤火，稍微给一点光是合理的；但颜色仍然锁在冷紫白里，
   绝不能混进琥珀 —— 那是留给数字和印章的 */
.motes__layer--near .mote {
  box-shadow: 0 0 7px rgba(188, 164, 255, .40);
}

@keyframes mote-fall {
  0%   { opacity: 0;                 transform: translate3d(0, -10vh, 0); }
  14%  { opacity: var(--o); }
  86%  { opacity: var(--o); }
  100% { opacity: 0;                 transform: translate3d(var(--dx), 110vh, 0); }
}

/* ---------- 开场粒子的画布 ----------
   夹在夜色(-3)、星尘(-2) 之上，正文(0) 之下。
   只在打开的那两三秒里有内容，之后 main.js 会把尺寸归零、隐藏掉 */
.stage {
  position: fixed;
  inset: 0;
  z-index: -1;
  display: block;
  pointer-events: none;
  transform: translateZ(0);
}

/* 生日那屏背景上的烟花 —— 全站第二块画布，由 main.js 第 10 节在她第一次
   划到那一屏时才建出来，整个划过去就摘掉、把显存还回去。
   z-index 跟 .stage 同层，也是刻意的：.sec 是 position:relative 但没有 z-index，
   不构成层叠上下文，所以负 z-index 的绝对定位子元素会落到「根」的负层 ——
   正好在夜色和星尘之上、在所有正文之下。
   好处是不用给 .bday 补 z-index，现有的那圈 .burst 火花也照样压在文字上面；
   要是改成 z-index:0，这块画布就会盖住「20」，还得连 .burst 一起返工 */
.fwork {
  position: absolute;
  inset: 0;
  z-index: -1;
  display: block;
  pointer-events: none;
  transform: translateZ(0);
}

/* ---------- 分屏 ---------- */
.sec {
  position: relative;
  min-height: 100vh;
  min-height: 100svh;             /* iOS Safari 地址栏收缩后仍然铺满 */
  display: flex;
  flex-direction: column;
  justify-content: center;        /* 用 flex 而不是 grid：不会有子元素被收成内容宽度的坑 */
  padding: 16vh 8vw;
  text-align: left;
}

/* ---------- 逐字浮现 ---------- */
.ch, .wd {
  display: inline-block;
  opacity: 0;
  transform: translateY(.28em);
  transition: opacity .9s ease, transform .95s cubic-bezier(.16, .84, .28, 1);
  transition-delay: calc(var(--i) * var(--step, 48ms));
}
.is-visible .ch,
.is-visible .wd { opacity: 1; transform: none; }

/* 段落级的整体浮现（信里的段落、生日那屏的副标题） */
.reveal {
  opacity: 0;
  transform: translateY(24px);
  transition: opacity 1s ease, transform 1s cubic-bezier(.16, .84, .28, 1);
  transition-delay: calc(var(--d, 0) * 130ms);
}
.reveal.is-visible { opacity: 1; transform: none; }

/* ---------- 琥珀色细线（唯一的结构装饰） ---------- */
.rule {
  display: block;
  width: 66px;
  height: 1px;
  background: linear-gradient(90deg, var(--ember) 0%, rgba(224, 145, 63, .12) 100%);
  transform: scaleX(0);
  transform-origin: left center;
  transition: transform 1.05s cubic-bezier(.2, .75, .25, 1) .2s;
}
.is-visible .rule { transform: scaleX(1); }
/* 靠右的那几屏，线也要从右边长出来 */
[data-align="right"] .rule { transform-origin: right center; background: linear-gradient(270deg, var(--ember) 0%, rgba(224, 145, 63, .12) 100%); }

/* ---------- 第一屏 · 开场 ----------
   整屏居中：一句「致」、一行毛笔大标题、一道琥珀细线、一行小字。
   标题是粒子拼出来的（见 main.js），拼完交给这行真文字 */
.hero {
  /* width:100% 这一条不能省。.sec 是纵向 flex，.hero 是它的 flex 项，
     本来靠默认的 stretch 撑满；但下面那个 margin:auto 会把 stretch 关掉，
     让它变成「按内容撑开」—— 标题一长，父容器跟着一起长，
     main.js 的 fitTitle() 拿父容器宽度当可用宽度就永远量不出溢出，
     只能眼看标题冲出屏幕。写死 100%，再让 max-width 和 auto 外边距去收 */
  width: 100%;
  max-width: 34rem;
  margin: 0 auto;
  text-align: center;
}

.hero__kicker {
  /* inline-block + 负的右外边距：letter-spacing 会在最后一个字后面留一截空隙，
     居中时那一截会让整行看起来偏左。把它从宽度里减掉，才是真的居中 */
  display: inline-block;
  margin: 0 -.62em 1.5rem 0;
  font-size: .72rem;
  letter-spacing: .62em;
  color: var(--ember);
  font-weight: 400;
}

/* 开场的主角。毛笔草书，四个字，比原来那个名字更大更放。
   字号是按「四个字刚好铺满一屏、又不溢出」定的 —— 这个字体每个字自带很宽的
   字身框（一个字差不多占满一个 em），所以 18vw × 4 ≈ 七成屏宽 */
.hero__title {
  margin: 0;
  font-family: var(--f-title);
  font-size: clamp(3.2rem, 18vw, 7rem);
  font-weight: 400;
  line-height: 1.12;
  letter-spacing: 0;
  /* 必须一行。折行了草书很难看，而且粒子是照着屏幕上文字的像素去拼的，
     折了行它也跟着散。放不下时由 main.js 的 fitTitle() 把字号收小 */
  white-space: nowrap;
  color: var(--moon);
  text-shadow: 0 0 46px rgba(160, 130, 230, .45), 0 0 110px rgba(160, 130, 230, .22);
}

/* 这一道线现在居中，所以从中间往两边长；两端收进背景里，只有当中那截是实的 */
.hero .rule {
  margin: 1.7rem auto 1.5rem;
  transform-origin: center center;
  background: linear-gradient(90deg,
    rgba(224, 145, 63, .10) 0%,
    var(--ember) 50%,
    rgba(224, 145, 63, .10) 100%);
}

.hero__sub {
  display: inline-block;
  margin: 0 -.3em 0 0;          /* 同上：减掉末尾那点字距，居中才准 */
  font-size: clamp(.95rem, 4.3vw, 1.2rem);
  font-weight: 400;
  letter-spacing: .3em;
  color: var(--moon-2);
}

/* 粒子正在拼这行标题的时候：先把字藏起来，并把逐字浮现的位移去掉。
   位移必须去掉 —— getBoundingClientRect() 量到的才是文字最终该在的位置，
   不然采样出来的字形会整体偏下四分之一个字。
   交接时 main.js 摘掉 .pf、再给 .hero 加 .is-visible，标题就整体淡进来 */
.hero__title.pf .ch,
.hero__title.pf .wd {
  opacity: 0;
  transform: none;
  transition: none;
}

/* 底部下滑提示：一根会呼吸的竖线，比箭头安静。
   第一屏整屏居中了，它也跟着落到中轴线上，跟标题对齐 */
.scrollcue {
  position: absolute;
  left: 50%;
  bottom: max(6vh, calc(env(safe-area-inset-bottom) + 3vh));
  transform: translateX(-50%);
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: .9rem;
  transition: opacity .7s ease;
}
.scrollcue.is-gone { opacity: 0; }

/* ---------- 第一屏那道门（开关在 main.js 第 9 节）----------
   开场那几秒锁住页面，等第一屏整个亮完才摘掉这个类。
   两件事都不能动：
     · 这个类只能由 JS 加上去，绝不能写死在 HTML 或 :root 上 ——
       脚本没跑起来的时候，页面必须照常能滚
     · overflow 只写在 html / body 上。别顺手给 .sec 之类加 fixed 或者固定高度：
       main.js 里那套位置是预先量好缓存的，一改布局它就算歪了
   overscroll-behavior 一起关掉，免得锁着的时候还能拉出橡皮筋 */
html.is-locked,
html.is-locked body { overflow: hidden; overscroll-behavior: none; }

/* 把滚动条那条沟尽量留住。桌面端的经典滚动条有 15px 宽，关掉 overflow 它就消失、
   可用宽度多出 15px，门一开又缩回去 —— 首屏全是居中的，会当场横着挪一下。
   加这一条之后实测：挪动从 15px 减到 7.5px（Chrome 在 overflow:hidden 时
   并不真的预留那条沟，所以消不掉，只能减半；能完整预留的内核上就是零位移）。
   手机是浮层滚动条，本来就不占宽度，这条写了等于没写。
   要彻底消掉得把 overflow:hidden 换成「保持可滚 + 用 JS 拦 wheel/touch」，
   那等于把「拦得住」这件事整个押在 JS 上，不值当 —— 何况这 7.5px 只在
   桌面浏览器上出现，手机上是 0 */
html { scrollbar-gutter: stable; }

/* 锁着的时候不显示下滑提示 —— 写着「往下滑」却划不动，是最糟的体验。
   门一开它才淡进来（过渡在 .scrollcue 上），那个淡入本身就是「现在可以了」的信号。
   跟上面 .is-gone 不冲突：两条都指向 opacity:0 */
html.is-locked .scrollcue { opacity: 0; }

.scrollcue__label {
  font-size: .7rem;
  letter-spacing: .5em;
  margin-right: -.5em;          /* 减掉末尾那点字距，才跟下面那根线对齐 */
  color: var(--moon-3);
}

.scrollcue__line {
  width: 1px;
  height: 52px;
  background: linear-gradient(180deg, rgba(233, 226, 247, .5), rgba(233, 226, 247, 0));
  transform-origin: top center;
  animation: cue 2.8s cubic-bezier(.6, 0, .4, 1) infinite;
}

@keyframes cue {
  0%        { transform: scaleY(.15); opacity: .25; }
  45%, 55%  { transform: scaleY(1);   opacity: 1; }
  100%      { transform: scaleY(.15); opacity: .25; }
}

/* ---------- 情话区：一整片会呼吸的情话 ----------
   十二个星位轮着用那十几句情话，用彼此错开的 CSS 动画此起彼伏地亮起、暗下。
   任何时刻大约有六到八句在场，靠屏幕中心那几行更亮更大 —— 视线天然落在那里。
   注意「十二」是星位数，不是情话条数：情话可以十几二十句，多出来的靠换词轮上
   （见 main.js 的 initBreath），改情话数量不需要动这里的任何东西。
   全程只有 transform / opacity 在动，没有一个 JS 定时器 */
.sec--quotes {
  display: block;
  /* 留出滚动的余量：场子在这里停住呼吸，再放你走。
     别调太高 —— 情话层是 fixed，它必须赶在生日那屏冒头之前淡干净，
     留太多就是一段纯黑的死滚动 */
  height: 150vh;
  min-height: 150vh;
  padding: 0;
}

/* 静态的样子。平时是给读屏和「减弱动态效果」用的；
   开了动效之后会被压缩成一个 1px 的隐藏节点，只为让内容留在无障碍树里 */
.quotes-list {
  margin: 0 auto;
  padding: 18vh 8vw;
  max-width: 34rem;
  list-style: none;
  counter-reset: q;
}

.quotes-list li {
  margin: 0 0 3.2rem;
  font-size: clamp(1.1rem, 5vw, 1.6rem);
  line-height: 1.95;
  letter-spacing: .05em;
  color: var(--moon);
  text-wrap: balance;
}

.quotes-list li::before {
  content: counter(q, decimal-leading-zero);
  counter-increment: q;
  display: block;
  margin-bottom: .8rem;
  font-family: var(--f-latin);
  font-size: .95rem;
  font-weight: 500;
  letter-spacing: .08em;
  color: var(--ember);
}

/* 开了动效之后，静态列表立刻收成一个 1px 的隐藏节点。
   保留它（而不是 display:none）是为了让它仍然留在无障碍树里 —— 会动的那层
   是 aria-hidden 的，不能让读屏的用户什么都拿不到 */
.sec--quotes.is-dynamic .quotes-list {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  border: 0;
  overflow: hidden;
  white-space: nowrap;
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
}

/* 会呼吸的那一层。用 fixed 而不是 sticky —— body 上有 overflow-x:hidden，
   那会让 body 变成滚动容器，sticky 在 iOS 上行为不稳。用 fixed + 观察器开关更稳 */
.breath {
  position: fixed;
  inset: 0;
  z-index: 0;
  overflow: hidden;
  pointer-events: none;
  opacity: 0;                        /* 浓淡由 main.js 按滚动位置逐帧写进来 */
  visibility: hidden;
  transform: translateZ(0);
}

/* 只有滚到这一屏，这一层才有必要被画出来 —— 顺便把里面的呼吸动画放行 */
.sec--quotes.is-live .breath { visibility: visible; }

.breath__item {
  position: absolute;
  margin: 0;
  max-width: 45%;
  font-size: calc(var(--sz, 1) * clamp(.82rem, 3.5vw, 1.08rem));
  line-height: 1.75;
  letter-spacing: .04em;
  color: var(--moon);
  opacity: 0;
  text-wrap: pretty;
  animation:
    breath-wake  var(--dur, 12s) ease-in-out var(--delay, 0s) infinite,
    breath-drift var(--dur, 12s) ease-in-out var(--delay, 0s) infinite;
  animation-play-state: paused;      /* 没滚到这一屏就别烧电 */
}

.sec--quotes.is-live .breath__item { animation-play-state: running; }

.breath__item--r { text-align: right; }

/* 亮起 → 停留 → 暗下，剩下的三成时间留白。
   六成二的占空比 × 十二句 ≈ 任何时刻都有七八句在场 */
@keyframes breath-wake {
  0%   { opacity: 0; }
  14%  { opacity: var(--peak, .5); }
  44%  { opacity: var(--peak, .5); }
  62%  { opacity: 0; }
  100% { opacity: 0; }
}

@keyframes breath-drift {
  0%   { transform: translate3d(0, 0, 0); }
  50%  { transform: translate3d(var(--fx, 6px), var(--fy, -8px), 0); }
  100% { transform: translate3d(0, 0, 0); }
}

/* ---------- 生日高潮 ---------- */
.bday { max-width: 34rem; }

.bday__eyebrow {
  margin: 0 0 .2rem;
  font-family: var(--f-latin);
  font-size: clamp(.82rem, 3.9vw, 1.05rem);
  font-weight: 400;
  font-style: italic;
  letter-spacing: .34em;
  text-indent: .34em;
  color: var(--moon-3);
}

.bday__num {
  display: block;
  margin: 0;
  font-family: var(--f-latin);
  font-size: clamp(7.5rem, 44vw, 19rem);
  font-weight: 300;
  line-height: .88;
  letter-spacing: -.02em;
  color: var(--moon);
  text-shadow: 0 0 60px rgba(190, 160, 255, .5), 0 0 140px rgba(190, 160, 255, .25);
  opacity: 0;
  transform: scale(.9);
  transition: opacity 1.1s ease, transform 1.3s cubic-bezier(.16, 1.02, .28, 1);
}
.is-visible .bday__num { opacity: 1; transform: none; }

.bday .rule { margin: 1.5rem 0 1.6rem; }

.bday__cn {
  margin: 0 0 1.7rem;
  font-size: clamp(.92rem, 4.2vw, 1.15rem);
  letter-spacing: .58em;
  text-indent: .58em;
  color: var(--ember);
}

.bday__line {
  margin: 0;
  font-size: clamp(1.3rem, 6.2vw, 2rem);
  font-weight: 400;
  line-height: 1.8;
  letter-spacing: .05em;
  color: var(--moon);
  text-wrap: balance;
}

.bday__sub {
  margin: 1.9rem 0 0;
  font-size: clamp(.88rem, 3.9vw, 1rem);
  line-height: 2;
  color: var(--moon-2);
  text-wrap: balance;
}

/* 生日那屏的一次性光爆（由 main.js 生成，播完自动清掉） */
.burst {
  position: absolute;
  inset: 0;
  overflow: hidden;
  pointer-events: none;
}

.burst__ring {
  position: absolute;
  left: 50%; top: 58%;
  width: 14px; height: 14px;
  margin: -7px 0 0 -7px;
  border-radius: 50%;
  border: 1px solid rgba(224, 145, 63, .8);
  animation: ring 1.7s cubic-bezier(.18, .72, .25, 1) forwards;
}
@keyframes ring {
  0%   { opacity: .85; transform: scale(1); }
  100% { opacity: 0;   transform: scale(30); }
}

.burst__spark {
  position: absolute;
  left: 50%; top: 58%;
  width: var(--s); height: var(--s);
  border-radius: 50%;
  background: var(--ember);
  box-shadow: 0 0 7px rgba(224, 145, 63, .9);
  animation: spark var(--dur) cubic-bezier(.14, .68, .34, 1) forwards;
}
@keyframes spark {
  0%   { opacity: 0; transform: translate3d(0, 0, 0) scale(.35); }
  12%  { opacity: 1; }
  100% { opacity: 0; transform: translate3d(var(--dx), var(--dy), 0) scale(1); }
}

/* ---------- 蛋糕 ----------
   一枚线稿，安静地压在祝福下面。细描边、几乎不填充 —— 跟全站那道琥珀细线
   是同一种语言；唯一的暖色是三簇火苗，跟 .burst__spark 用的是同一个琥珀。
   （烟花那块画布在 main.js 里建，样式见下面的 .fwork） */
.bday__cake {
  position: relative;
  width: clamp(96px, 30vw, 132px);
  margin: 2.2rem auto 0;
  text-align: center;
  /* 还没亮出来之前点不动 —— 免得她在淡入的过程中误触 */
  pointer-events: none;
}
.bday__cake.is-ready { pointer-events: auto; }

/* 全站唯一能被点的东西。把浏览器给按钮的那套默认样子全部清掉 */
.cake {
  display: block;
  width: 100%;
  margin: 0;
  padding: 0;
  border: 0;
  background: none;
  color: inherit;
  font: inherit;
  -webkit-appearance: none;
  appearance: none;
  cursor: pointer;
  touch-action: manipulation;      /* 去掉双击缩放那 300ms 的等待 */
}
/* 去掉默认焦点圈之后得自己补一个，不然键盘用户不知道自己在哪 */
.cake:focus-visible { outline: 1px solid var(--moon-3); outline-offset: 7px; }

.cake__art {
  display: block;
  width: 100%;
  height: auto;
  overflow: visible;               /* 别把火苗那层辉光裁掉 */
  filter: drop-shadow(0 0 10px rgba(190, 160, 255, .16));
}

.cake__tier,
.cake__line,
.cake__candle {
  fill: none;
  stroke: var(--moon-3);
  stroke-width: 1;
  stroke-linecap: round;
}
.cake__tier { fill: rgba(235, 228, 248, .03); }   /* 一点点填充，让它是个东西、不是个空框 */
.cake__line { stroke: rgba(110, 98, 144, .75); }
.cake__candle { stroke: var(--moon-2); }

/* 三簇火苗：全站除了那个大数字和那圈光爆之外，唯一的一点暖色。
   一根一根点起来（--i 错开），点着之后自己轻轻地晃 */
.cake__flame {
  fill: var(--ember);
  opacity: 0;
  transform-box: fill-box;
  transform-origin: bottom center;
  transition: opacity .7s ease, transform .7s ease;
  transition-delay: calc(.75s + var(--i, 0) * .32s);
  filter: drop-shadow(0 0 5px rgba(224, 145, 63, .8));
}
.bday__cake.is-visible .cake__flame {
  opacity: 1;
  animation: cake-flicker 2.1s ease-in-out calc(1.9s + var(--i, 0) * .32s) infinite;
}
@keyframes cake-flicker {
  0%, 100% { transform: scale(1, 1);      opacity: 1; }
  35%      { transform: scale(.92, 1.1);  opacity: .86; }
  70%      { transform: scale(1.07, .94); opacity: 1; }
}

/* 吹灭：火苗收下去，烟升起来。!important 是为了压住上面那条 flicker ——
   动画和 transition 抢同一个属性时，只有 animation 让位才收得干净 */
.bday__cake.is-out .cake__flame {
  opacity: 0 !important;
  transform: scale(.4, .12);
  animation: none !important;
  transition: opacity .26s ease, transform .26s ease;
  transition-delay: calc(var(--i, 0) * 70ms);
  filter: none;
}

/* 三缕烟，长在三个烛芯上（在 SVG 里，所以天生对得上位置、跟着一起缩放） */
.cake__smoke circle {
  fill: var(--moon-3);
  opacity: 0;
  transform-box: fill-box;
  transform-origin: center;
}
.bday__cake.is-out .cake__smoke {
  filter: blur(1.2px);             /* 只在冒烟那两秒里开滤镜 */
}
.bday__cake.is-out .cake__smoke circle {
  animation: cake-smoke 2.6s ease-out forwards;
  animation-delay: calc(var(--i, 0) * .18s);
}
@keyframes cake-smoke {
  0%   { opacity: 0;   transform: translate(0, 2px) scale(.4); }
  18%  { opacity: .7; }
  100% { opacity: 0;   transform: translate(-6px, -26px) scale(2.2); }
}

/* 托盘：比蛋糕本体宽出一截的一道琥珀细线，从中间往两边长出来。
   没有复用 .rule —— 那条线挂在「祖先 .is-visible」上，而整个 .bday 会比蛋糕
   早亮一截，复用的话托盘会先自己冒出来，蛋糕才慢慢浮现 */
.cake__tray {
  fill: none;
  stroke: var(--ember);
  stroke-width: 1;
  stroke-linecap: round;
  opacity: .62;
  transform-box: fill-box;        /* fill-box：以线自己为中心缩放，不是以整个画布 */
  transform-origin: center;
  transform: scaleX(0);
  transition: transform 1.05s cubic-bezier(.2, .75, .25, 1) .35s;
}
.bday__cake.is-visible .cake__tray { transform: scaleX(1); }

/* 「点一下」那行小字。只有真的点得动了才淡进来 ——
   提示写着可以点、实际点不动，是最糟的那种提示 */
.cake__hint {
  margin: .8rem 0 0;
  font-size: .72rem;
  line-height: 1.7;
  letter-spacing: .22em;
  text-indent: .22em;
  color: var(--moon-3);
  opacity: 0;
  transition: opacity .9s ease;
}
.bday__cake.is-ready .cake__hint { opacity: 1; }
/* 已经吹灭了，还挂着「许个愿，然后吹灭它」就成了句错话。
   这条必须写在上面那条后面 —— 两条特异性一样，靠顺序分胜负 */
.bday__cake.is-out .cake__hint { opacity: 0; }

/* ---------- 信 ---------- */
.sec--letter { padding: 15vh 5.5vw 16vh; }

.letter {
  position: relative;
  width: 100%;
  max-width: 34rem;
  margin: 0 auto;
}

/* 纸背后的一层暖光 —— 全页唯一被照亮的地方 */
.letter::before {
  content: "";
  position: absolute;
  inset: -14% -22%;
  background: radial-gradient(58% 46% at 50% 42%, rgba(224, 145, 63, .22), rgba(224, 145, 63, 0) 72%);
  pointer-events: none;
}

.letter__title {
  position: relative;
  margin: 0 0 2.4rem;
  text-align: center;
  font-size: clamp(.76rem, 3.5vw, .9rem);
  letter-spacing: .5em;
  text-indent: .5em;
  color: var(--moon-2);
}

.letter__paper {
  position: relative;
  padding: clamp(2.1rem, 8.5vw, 3.6rem) clamp(1.5rem, 6.5vw, 3rem);
  border-radius: 3px;
  color: var(--paper-ink);
  text-align: left;
  /* 只用纸本身的底色。之前这里还压了一层横格线，但正文的行高跟格子对不齐，
     线就从字中间穿过去，像作业本 —— 去掉了，靠左边那条竖界行保持信笺感就够了 */
  background-image: linear-gradient(168deg, #fdf9f2 0%, #f8f1e7 55%, #f1e8db 100%);
  box-shadow:
    0 40px 80px -30px rgba(0, 0, 0, .8),
    0 2px 0 0 rgba(255, 255, 255, .5) inset,
    0 0 0 1px rgba(120, 90, 70, .1);
  opacity: 0;
  transform: translateY(46px);
  transition: opacity 1.2s ease, transform 1.2s cubic-bezier(.16, .84, .28, 1);
}
/* 注意：is-visible 是加在信纸自己身上的，所以这里是复合选择器，不是后代选择器 */
.letter__paper.is-visible { opacity: 1; transform: none; }

/* 左边的竖界行 —— 信笺的那条红线 */
.letter__paper::before {
  content: "";
  position: absolute;
  left: clamp(1rem, 4.5vw, 1.7rem);
  top: clamp(2.1rem, 8.5vw, 3.6rem);
  bottom: clamp(2.1rem, 8.5vw, 3.6rem);
  width: 1px;
  background: linear-gradient(180deg, rgba(184, 86, 44, 0), rgba(184, 86, 44, .3) 12%, rgba(184, 86, 44, .3) 88%, rgba(184, 86, 44, 0));
}

.letter__greeting {
  position: relative;
  margin: 0 0 1.6rem;
  padding-left: 1.3rem;
  font-size: clamp(1.04rem, 4.5vw, 1.18rem);
  font-weight: 600;
  letter-spacing: .04em;
  color: #7a4630;
}

.letter__p {
  position: relative;
  margin: 0 0 1.5rem;
  padding-left: 1.3rem;
  font-size: clamp(.95rem, 4.1vw, 1.04rem);
  line-height: 2.05;
  letter-spacing: .015em;
  text-align: justify;
  color: var(--paper-ink);
}

.letter__sign {
  position: relative;
  margin: 2.6rem 0 0;
  padding-left: 1.3rem;
  text-align: right;
  font-size: clamp(1rem, 4.2vw, 1.1rem);
  letter-spacing: .05em;
  color: #7a4630;
}

/* 印章：整页最后一个动作。压下去的时候会稍微过冲一下再稳住 */
.seal {
  position: relative;
  display: grid;
  place-items: center;
  width: 70px; height: 70px;
  margin: 2.2rem 0 0 auto;
  margin-right: clamp(.2rem, 2vw, 1rem);
  border-radius: 7px;
  font-size: 2.05rem;
  line-height: 1;
  color: #ffeee0;
  background: linear-gradient(148deg, #cf5f38 0%, #b0402a 100%);
  box-shadow:
    0 0 0 1px rgba(255, 200, 170, .35) inset,
    0 12px 26px -10px rgba(150, 45, 20, .7);
  opacity: 0;
  transform: scale(1.7) rotate(-14deg);
  transition: opacity .3s ease, transform .6s cubic-bezier(.3, 1.55, .5, 1);
}
.seal.is-visible { opacity: .94; transform: scale(1) rotate(-5deg); }

/* 盖下去之后，边缘渗出的一圈印泥 */
.seal::after {
  content: "";
  position: absolute;
  inset: -7px;
  border-radius: 12px;
  background: radial-gradient(closest-side, rgba(185, 60, 35, .30), rgba(185, 60, 35, 0) 72%);
  opacity: 0;
  transition: opacity 1.4s ease .35s;
}
.seal.is-visible::after { opacity: 1; }

/* ---------- 兜底提示 ---------- */
.fallback, .fatal {
  max-width: 30rem;
  margin: 0 auto;
  padding: 22vh 8vw;
  line-height: 2;
  text-align: center;
  color: var(--moon-2);
  font-size: .95rem;
}

.fatal code {
  display: inline-block;
  padding: .15em .45em;
  margin: 0 .15em;
  border-radius: 4px;
  background: rgba(255, 255, 255, .1);
  font-family: ui-monospace, Consolas, monospace;
  font-size: .9em;
}

/* ---------- 无障碍：跟随系统「减弱动态效果」 ----------
   这里直接把内容显式打开。观察器在降级模式下当然也会跑，
   但把「文字能不能看见」完全押在 JS 时序上不值得，显式写死更稳妥 */
@media (prefers-reduced-motion: reduce) {
  /* .bday__num 的入场不是 animation 而是 transition（缩放 0.9→1），
     所以它必须在这份名单里，只写 animation:none 是盖不住它的 */
  .ch, .wd, .reveal, .letter__paper, .seal, .bday__num, .cake__flame {
    opacity: 1 !important;
    transform: none !important;
    transition-duration: .01ms !important;
    transition-delay: 0ms !important;
  }
  /* 托盘不能跟上面那几个一起处理：它平时是 scaleX(0)，这一档要把它放开，
     但 opacity 得留着 .62 —— 并进上面那条的话它会被烧成一根全亮的长线 */
  .cake__tray { transform: none !important; transition: none !important; }

  /* 蛋糕：这一档要的是一张不动的插画 —— 三根蜡烛稳稳亮着，点不动，也不会吹灭。
     已经吹灭过再切过来的，也要给它点回来，所以下面那条得单独再压一次：
     `.bday__cake.is-out .cake__flame` 的特异性比上面那条高，
     两个都带 !important 时赢的是特异性，不是谁写在后面 */
  .bday__cake.is-out .cake__flame {
    opacity: 1 !important;
    transform: none !important;
    transition: none !important;
  }
  .cake__smoke { filter: none !important; }
  .cake__smoke circle { opacity: 0 !important; animation: none !important; }
  /* 点不动的蛋糕下面挂着一行「许个愿，然后吹灭它」，是最糟的那种提示 */
  .cake__hint { display: none !important; }

  /* 下滑提示**不能**跟上面那几个一起处理 —— 它本来就是一进页面就显示的东西，
     不需要被「打开」，可上面那两条会把它弄坏：
       · transform: none 会清掉它用来水平居中的 translateX(-50%)，整根线往右跑偏
       · opacity: 1 !important 会盖住 .is-gone 的淡出，划下去之后它一直赖在屏幕上
     只掐掉过渡时长就够了：淡出留着，只是变成瞬间完成 */
  .scrollcue {
    transition-duration: .01ms !important;
    transition-delay: 0ms !important;
  }

  .night__bloom,
  .mote,
  .scrollcue__line,
  .breath__item,
  .cake__flame { animation: none !important; }

  .rule { transform: scaleX(1) !important; transition: none !important; }
  .mote { opacity: .25 !important; }

  /* 情话退回成安静的静态列表：不再呼吸，但每一句都完整可读 */
  .sec--quotes { height: auto; min-height: 100vh; }
  .breath { display: none !important; }
  .quotes-list { padding: 16vh 8vw; }

  /* 她如果是在页面上就把系统切成了「减弱动态效果」（运行中途切换），
     静态列表之前已经被压成 1px 藏起来了，而 .breath 又刚被 display:none ——
     不把这份裁剪复原，情话这一屏会变成一片空白 */
  .sec--quotes.is-dynamic .quotes-list {
    position: static;
    width: auto;
    height: auto;
    margin: 0 auto;
    padding: 16vh 8vw;
    overflow: visible;
    white-space: normal;
    clip: auto;
    clip-path: none;
  }
}

/* ---------- 矮屏上给蛋糕让地方 ----------
   生日那一屏本来就不空（巨大的数字 + 三行字），再压一枚蛋糕，
   iPhone SE 那种 667 高的屏会被撑过一屏。
   注意这个断点比下面那条 640 高：SE 的 667 恰好落在 641～700 之间，
   用 640 那条根本够不着它。蛋糕本来就是「一枚压在纸上的印章」，收小一档正好 */
@media (max-height: 700px) {
  .bday__cake { width: clamp(84px, 24vw, 108px); margin-top: 1.4rem; }
  .bday__cake .cake__tray { margin-top: .6rem; }
  .bday__cake .cake__hint { margin-top: .55rem; font-size: .66rem; }
}

/* ---------- 矮屏（横屏 / 小屏手机）压缩留白 ---------- */
@media (max-height: 640px) {
  .sec { padding: 11vh 8vw; }
  .sec--letter { padding: 11vh 5.5vw 12vh; }
  /* 矮屏压缩的是上下留白，标题本身不该跟着缩太多 —— 它是这一屏的主角 */
  .hero__title { font-size: clamp(3rem, 16.5vw, 4.6rem); }
  .bday__num { font-size: clamp(5.6rem, 38vw, 11rem); }
  .bday__cake { width: clamp(76px, 21vw, 96px); margin-top: 1.1rem; }
  .scrollcue { bottom: 3.5vh; }
  .scrollcue__line { height: 34px; }
}
