/* 加载动画的全部样式（2026-10-25）—— **唯一出处**，而且**先于应用样式下发**。
 *
 * ## 为什么单独一份文件、而且用 `<link>` 放在 `<head>` 最前面
 *
 * 用户在 Slow 3G 下看到的第一个画面必须是**动画**，而不是空白、更不是左上角一行裸文字。
 * 应用的主 CSS 是随 JS 一起到的（开发服务器甚至是 JS 注入的），在它到达之前：
 * - 期望：两枚方块已经在跳 +「正在加载歌单…」；
 * - 实际（把这套样式留在 styles.css 里时）：`index.html` 里那份占位**没有任何样式**，
 *   浏览器把它当普通段落排在左上角——用户截图里就是那样，比空白更难看。
 *
 * 所以这段样式单独成文件（约 4KB，gzip 后 1KB 出头），`index.html` 里**第一个** `<link>`：
 * 浏览器一拿到 HTML 就发这个请求，动画随它一起出现，完全不等应用。
 *
 * ## 谁在用
 *
 * 1. `index.html` 里的首屏启动占位（`#boot-loading`，只在听众页显示）；
 * 2. React 的 `components/LoadingState.tsx`（应用挂载之后的加载状态与淡出）——
 *    **用的是同一批类名、同一份规则**，所以改动画永远只改这一个文件。
 *
 * ## 那个 `--boot-css` 标记
 *
 * 给 `index.html` 里那段脚本读的哨兵：**只有确认这份样式真的生效了，才把占位显示出来**。
 * 没有它的话，一旦这份 CSS 没到（被拦、断网、以后改名），占位就会以"裸文字"的样子出现。
 */

:root {
  /* 哨兵：`index.html` 的脚本读它判断"动画样式到了没有"（值不重要，存在就行） */
  --boot-css: 1;

  /**
   * 页面上内边距的基准值。
   *
   * 它放在这里而不是 `styles.css`，因为**首屏占位要用它、而占位比应用样式先出现**：
   * - `.page` 自己的 `padding-top`；
   * - 加载层离场时改成绝对定位，`top` 拿它对齐（绝对定位参照 padding box）；
   * - `#boot-loading` 补它，否则交接那一帧整块动画会往上跳。
   *
   * 宽屏（≥900px）上它是 24px，见文件末尾的媒体查询——`.page` 那边只写
   * `padding: var(--page-pad-top) …`，值只在这里定义一次。
   */
  --page-pad-top: 16px;
}

/**
 * ⚠️ **`body` 的默认边距必须在这一份里清掉**（2026-10-25）。
 *
 * 应用样式（`styles.css`）里也有 `body { margin: 0 }`，但它随应用一起到——在它到达之前，
 * 浏览器给 `body` 的默认边距是 8px，占位会整体偏右下 8px，等应用样式来了再"跳"回去
 * （开发服务器上实测到过：`rect: [8, 8, …]`）。这里多一条声明，换首屏位置稳定。
 */
html,
body {
  margin: 0;
}

/* --- 加载状态：两枚跳动的方块 + 分阶段文案（2026-10-25）------------------------

   听众打开歌单页等数据的那一两秒，原来整页只有一行灰字「正在加载歌单…」，
   画面完全静止——网慢时分不清是在加载还是卡死了。这里补一个**纯装饰**的等待动画
   （不表示进度，只分散注意力）：两枚 20×20 的方块从原位放大、上跳 25px、绕 X 轴翻 45°，
   0.5 秒一次往复，右块错开 0.15 秒起步。

   ⚠️ 填充色是**静态声明**（`background-color: var(--loading-ink)`），动画只碰
   `transform` / `opacity` / 投影。**别把颜色挪进 `@keyframes`**：那要靠浏览器
   「自定义属性变了会不会重算关键帧」这个行为——Chromium 会（2026-10-21 跑马灯那轮
   实测过 `--marquee-shift`），但别的引擎不稳。静态化之后颜色跟随在任何引擎上都成立，
   而且「主题色晚到时方块变色」也不需要重启动画。

   ⚠️ `--loading-ink` 的兜底**必须写全** `var(--theme, #2f6fed)`：只写 `var(--theme)` 时，
   万一某个容器没有定义 `--theme`，`background-color` 会变成无效值 →
   **方块完全透明、而且不报任何错**。默认色只有 `:root` 的 `--theme` 一个出处，
   这里只多加一个兜底值，不重复定义第二个默认色变量。
   JS（`loadingTheme.ts`）拿到主播主题色时用内联样式覆盖这个变量。

   ⚠️⚠️ 下面那块 `prefers-reduced-motion` 必须留在**文件中间**（紧跟这几条规则），
   不能追加到文件末尾：`interactionFeedback.test.ts` 用 `mediaBlock(…, 'last')` 取
   文件里**最后一个** reduce 块来验「按钮按下不缩放」，把加载器那块排到最后会让那两条
   用例红，而且报错信息指向按钮、完全误导人。`LoadingState.test.tsx` 有一条用例
   专门盯着「它不能是最后一块」。 */

/* --- 首屏启动占位（`index.html` 里那份，只在听众页显示）----------------------

   它挂在 `<body>` 下、**不在** `.page` 里，所以自己要顶上 `.page` 那份上内边距，
   否则交接那一帧整块动画（方块 + 文案）会向上跳 16px——正是这次要消灭的"抖一下"。
   变量与 `.page` 同源（`:root`），不要在这里写死一个 16px。 */
#boot-loading:not([hidden]) {
  /**
   * ⚠️ **从显示的那一刻就绝对定位，永远不参与布局**（2026-10-25 实测的"跳动"）。
   *
   * 之前它按普通块排版：等待时占着顶部 166px，内容被顶在它下面；一挂离场类就变成
   * `position: absolute`，那 166px 从文档流里消失 → **内容整体上跳 166px**，
   * 同时加载层还压在顶部淡出，看起来就是"歌单跳上去、加载动画又冒出来"。
   *
   * 它本质是一层**盖在页面上的帘子**，不是一个区块：从一开始就 absolute，
   * 内容始终从页面原点开始，帘子抬走时不会带走任何布局。
   * 上内边距仍与 `.page` 同源（`--page-pad-top`），所以帘子里的动画和内容起点对齐。
   */
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  /* 不写 `bottom`：帘子只要盖住自己那点内容，不要铺满整页 */
  padding-top: var(--page-pad-top);
}

/* --- 加载层的**离场**（2026-10-25 的交叉过渡）--------------------------------

   加载结束时不卸载这个节点，只把它从「等待中」换成「离场」：
   外层淡出 200ms，同时歌单内容淡入 + 上浮（见下面 `.page__inner--enter`）。

   ⚠️ 外层与里层是分层的：外层 `.loading-layer` 管**身份与过渡**（testid / 读屏 / 定位 /
   淡出），里层 `.loading` 只管**外观**（内边距、居中、文字颜色）。离场只动外层。

   ⚠️ 离场节点**常驻**（不卸载）：任何「淡出完再删」的方案都要一个收尾事件，
   而「减少动态效果」下过渡被关掉时 `transitionend` **根本不会发**——
   节点会永远留着，而代码以为在等收尾。常驻 + 下面三个静态属性是没有收尾状态的设计。 */
.loading-layer {
  /* ⚠️ 把兜底主题色**钉死**：`--theme` 在这一步会变（等待时 Shell 拿不到外观数据，
     是默认蓝；内容出来之后是主播的主题色）。不钉的话，一旦主题色接口失败
     （`--loading-ink` 走兜底），冻结的方块会在淡出那一帧**突然变色**——
     不报错、只是闪一下。`initial` 是「保证无效值」，于是 `var(--theme, #2f6fed)`
     永远取兜底色，和等待期间的实际取值一致。 */
  --theme: initial;
  /* ⚠️ 兜底声明必须挂**外层**：JS 把主播主题色写成这个层的**内联** `--loading-ink`，
     内联只在「同一个元素」上压得住声明。挂到里层 `.loading` 上的话，它会盖掉继承来的
     内联值——方块永远是默认蓝，而测试里只要接线对就看不出来（端到端抓到过一次）。 */
  --loading-ink: var(--theme, #2f6fed);
  /**
   * ⚠️ **提前把这层提成合成层**（2026-10-25，用户实测的"消失两帧"）。
   *
   * 不写的话，浏览器要到**离场动画开始那一刻**才为它新建合成层；那一两帧里这一层的
   * 内容还没栅格化，屏幕上就是空的（只剩页面底色）——等它画出来时 opacity 已经淡下去了，
   * 用户看到的就是"加载动画突然消失，然后又以很淡的样子出现"。
   * 它只是一个小元素，从一开始就独占一层几乎没有代价。
   */
  will-change: translate, opacity;
  /**
   * ⚠️ **`z-index` 常驻在这里，不要挪到离场态**（2026-10-25，用户指出的问题）。
   *
   * 它必须存在：`.page__inner` 是 `z-index: 1`，帘子不抬高的话，淡出时会被内容盖住。
   * 但它**不能和淡出同时变**——`z-index` 从 auto 变 2 会改变层叠顺序，这一层要重建/重画；
   * 而淡出开始的那一刻，栅格线程正好被歌单首次绘制占着，帘子抢不到 → 那一块画不出东西
   * （用户看到"消失"），等画好时透明度已经走远 → "很淡地闪一下"。
   *
   * 常驻之后，离场规则只改 `animation`：层叠顺序全程不变、层不重建，
   * 300ms 的 opacity 渐变就只是合成器对一张早已画好的图做混合。
   */
  z-index: 2;
  /* 退场不再用 transition：改成 `--leaving` 上的"上浮淡出"动画（见下面） */
}

.loading-layer--leaving {
  /**
   * ⚠️ **这里不再改定位**（2026-10-25）：`#boot-loading` 从一开始就是绝对定位
   * （见上面那条），离场只加"上浮淡出"动画。以前在这里改成 `position: absolute`
   * 会把节点从文档流里抽走，内容整体上跳 166px——用户实测到的"歌单突然跳上去"。
   */
  /* `z-index` 不在这里改（见 `.loading-layer` 里那条注释）：它在基础态常驻，
     离场这一下只允许改 `animation`，否则会触发这一层重建。 */
  /**
   * 离场：**整层上浮 + 淡出**（用户 2026-10-25 定的口径：动画保持播放、文案「加载完成」、
   * 然后向上浮动淡出；歌单从下方插入、上浮淡入）。
   *
   * 之前那版是"方块冻住 + 整层直接淡出"，用户的原话是"动画直接卡停了然后直接消失"。
   * 现在方块继续跳，整层自己往上浮 28px 并淡出；只动 `translate` / `opacity`，
   * 全在合成器上跑。
   *
   * ⚠️ **这里不加延迟**（2026-10-25）：延迟改由 JS 控制——先让内容进场 150ms，
   * 再挂这个类。否则一旦"加载层比内容先消失"，就会出现全屏白一帧
   * （用户 GIF 第 49 帧实测到过）。
   */
  animation: loading-layer-out 300ms ease forwards;
  /* 起点由动画的 `from` 给（opacity: 1），`forwards` 停在 opacity: 0 */
  pointer-events: none;
}

/* ⚠️ 离场时方块**继续跳**（用户 2026-10-25 要求「加载动画保持播放」）：
   这里**不许**再写 `animation-play-state: paused`——上一版就是那样冻住的。 */

.loading {
  padding: 40px 0;
  text-align: center;
  color: var(--text-soft);
}

.loading__anim {
  position: relative;
  display: block;
  width: 50px;
  height: 50px;
  /* 向上跳 25px 吃掉的是上面那条 `padding-top: 40px`，不会顶到别的内容 */
  margin: 0 auto;
  perspective: 200px;
}

.loading__square {
  position: absolute;
  left: 0;
  width: 20px;
  height: 20px;
  background-color: var(--loading-ink);
  /* ⚠️ 末尾的 `backwards` 不是可有可无的：第二枚方块有 0.15s 的延迟，而默认的
     `animation-fill-mode: none` 在延迟期间**不套关键帧**——那 150ms 里它是一枚
     无位移、完全不透明的实心方块，看起来像「先闪一下再开始跳」。
     `backwards` 让它在延迟期间就按 `from`（opacity: 0）画，两枚一起从无到有。 */
  animation: loading-square 0.5s infinite alternate backwards;
  /**
   * ⚠️ **只许动 `transform` / `opacity`，别再往关键帧里加像素级属性**（2026-10-25 实测）。
   *
   * 原来的关键帧里还有 `box-shadow`（原版给的那句 `0 25px 40px` 投影）。`box-shadow`
   * **不是合成属性**：它每帧都要主线程重绘一次；而加载期间主线程正好被 JS 执行占着，
   * 于是动画会僵住——CDP 录屏帧实测**连续 1549ms 一帧都没有**（就是用户说的"卡一下"）。
   *
   * 现在投影挂在下面的 `::after` 上，是**静态**的 `box-shadow`；动的是它自己的 `opacity`
   * （合成属性）。视觉上投影还是跟着跳动"亮起来"，但整条动画都在合成器上跑。
   */
  will-change: transform, opacity;
}

.loading__square::after {
  content: '';
  position: absolute;
  inset: 0;
  /* 原版是 `0 25px 40px #000`（纯黑投影）：换成主题色后全不透明会很沉，压到 35%。
     不支持 `color-mix()` 的浏览器只会丢掉这一条声明，方块和动画照常。 */
  box-shadow: 0 25px 40px color-mix(in srgb, var(--loading-ink) 35%, transparent);
  opacity: 0;
  animation: loading-square-glow 0.5s infinite alternate backwards;
  will-change: opacity;
}

.loading__square:last-child {
  left: auto;
  right: 0;
  /* 第二枚比第一枚晚 0.15s（两枚错开才像连绵的跳动） */
  animation-delay: 0.15s;
}

.loading__square:last-child::after {
  /* 投影和方块自己同一节奏 */
  animation-delay: 0.15s;
}

@keyframes loading-square {
  from {
    transform: scale(1) translateY(0) rotateX(0);
    opacity: 0;
  }

  to {
    transform: scale(1.2) translateY(-25px) rotateX(45deg);
    opacity: 1;
  }
}

/* 投影的明暗：只有 `opacity`，所以整层在合成器上跑 */
@keyframes loading-square-glow {
  from {
    opacity: 0;
  }

  to {
    opacity: 1;
  }
}

/* 整层离场：往上浮 28px 的同时淡出（只有 translate / opacity，合成器上跑） */
@keyframes loading-layer-out {
  from {
    opacity: 1;
  }

  to {
    opacity: 0;
  }
}

/* 换文案：新的一句淡入 + 轻微上移。旧的那句在换字那一帧就被替换掉了（不做淡出）——
   淡出需要把旧句子短暂留在 DOM 里，收尾只能靠定时器或 `animationend`，
   而「减少动态效果」下动画不播、事件永远不来，旧句子会永久残留。
   `LoadingState.tsx` 的文件头写了这三条理由。 */
.loading__label {
  display: inline-block;
  animation: loading-label-in 160ms ease both;
}

@keyframes loading-label-in {
  from {
    opacity: 0;
    transform: translateY(4px);
  }

  to {
    opacity: 1;
    transform: none;
  }
}

/* 宽屏上页面内边距是 24px（和 `styles.css` 里 `.page` 的 20px 左右内边距配套）。
   ⚠️ 必须改变量、不要只写 `padding: 24px …`：首屏占位和离场层都读这个变量，
   写死数字的话只有 `.page` 变成 24，交接时整块动画会往上跳 8px。 */
@media (min-width: 900px) {
  :root {
    --page-pad-top: 24px;
  }
}

@media (prefers-reduced-motion: reduce) {
  .loading__anim {
    display: none;
  }

  .loading__label {
    animation: none;
  }

  /* 交叉过渡：两段都关掉之后**没有任何需要收尾的东西**——加载层直接 opacity: 0。
     ⚠️ 这也是为什么不能靠 `transitionend` / `animationend` 去卸载离场节点：这里连事件都不会发。 */
  .loading-layer--leaving {
    animation: none;
    opacity: 0;
  }
}

/**
 * 深色首帧（B7）。
 *
 * 加载层自己**没有背景色**：页面底色来自 `styles.css` 的 `body`，而那份样式是随 JS 到的。
 * 不在这里兜一层的话，深色用户会先看到浏览器默认白底，再"啪"地变深。
 * `color-scheme: dark` 顺带把原生滚动条/输入框也染深。
 */
@media (prefers-color-scheme: dark) {
  html,
  body {
    background-color: #070a0e;
    color-scheme: dark;
  }
}

/**
 * ============================================================================
 * 手绘涂鸦风（2026-10-31）
 *
 * 这一节是**调色板的唯一出处**：加载层在应用样式之前就要用，所以颜色/纹理必须
 * 跟着 boot.css 一起先到。`doodle.css` 只消费这些变量，不再定义第二份。
 *
 * - 浅色：奶白纸张 + 青绿横线 + 黄色胶带；
 * - 深色：黑板粉笔（墨绿板面 + 粉笔白 + 粉笔灰点阵）；阴影用墨蓝低对比，
 *   不用青色/白色大块（用户 2026-10-31 反馈「青色阴影突兀」后定的）。
 * ============================================================================ */

/* 风格未知时给浅色系统一个中性底，避免等 /theme 的 2.5s 里纯白/空屏。
   ⚠️ styles.css 顶部有 `html[data-skin] body { background: var(--bg) }` 同等特异性
   的复位（后加载），所以应用样式到了之后 body 会回到主播表面色；不能删那条复位。 */
html[data-skin='light'] body {
  background-color: #f6f7f9;
}

/* ---------------------------------------------------------------------------
   首屏兜底调色板：**每套风格一整段**

   ⚠️ 并行开发约定（2026-11-01）：新风格请把整段**追加到文件末尾**，
   不要插进已有风格的段中间——两套风格交错了，改 A 的时候很容易覆盖 B。
   `scripts/check-style-layers.mjs` 会检查「同一风格的规则是否连成一段」。

   ⚠️ 为什么不拆成 boot-<风格>.css：这是**首屏**样式，必须在任何 JS 之前
   一次拿全（冷启动时还不知道是哪套风格）；拆文件要么多一次请求、要么白载全部。
   --------------------------------------------------------------------------- */

html[data-playlist-style='doodle'],
.page[data-playlist-style='doodle'] {
  --doodle-paper: #fffef5;
  --doodle-paper-deep: #f7f2e2;
  --doodle-ink: #2c2c2c;
  --doodle-ink-soft: #57534e;
  --doodle-ink-faint: #6f6a5f;
  --doodle-red: #ff6b6b;
  --doodle-teal: #4ecdc4;
  --doodle-yellow: #ffd93d;
  --doodle-tape: rgba(255, 217, 61, 0.58);
  --doodle-border: 2px dashed rgba(44, 44, 44, 0.72);
  --doodle-border-soft: rgba(44, 44, 44, 0.4);
  --doodle-shadow-color: var(--doodle-teal);
  --doodle-btn-shadow: var(--doodle-ink);
  --doodle-radius: 4px;
  --doodle-lines: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='32'%3E%3Cpath d='M0 31.5H160' stroke='%234ecdc4' stroke-opacity='.34' stroke-width='1'/%3E%3C/svg%3E");
  color-scheme: light;
}

html[data-skin='dark'][data-playlist-style='doodle'],
html[data-skin='dark'] .page[data-playlist-style='doodle'] {
  --doodle-paper: #1f2b24;
  --doodle-paper-deep: #18211c;
  --doodle-ink: #f4f2e7;
  --doodle-ink-soft: #ccd5ca;
  --doodle-ink-faint: #a4b0a5;
  --doodle-red: #ff9b9b;
  --doodle-teal: #9be7df;
  --doodle-yellow: #ffe27a;
  --doodle-tape: rgba(244, 242, 231, 0.12);
  --doodle-border: 2px dashed rgba(244, 242, 231, 0.6);
  --doodle-border-soft: rgba(244, 242, 231, 0.3);
  --doodle-shadow-color: rgba(52, 84, 148, 0.55);
  --doodle-btn-shadow: rgba(20, 30, 50, 0.6);
  --doodle-lines: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='140'%3E%3Cg fill='%23f4f2e7' fill-opacity='.055'%3E%3Ccircle cx='18' cy='24' r='1.1'/%3E%3Ccircle cx='64' cy='82' r='.8'/%3E%3Ccircle cx='112' cy='34' r='1.3'/%3E%3Ccircle cx='148' cy='108' r='.9'/%3E%3Ccircle cx='36' cy='124' r='1'/%3E%3Ccircle cx='92' cy='62' r='.7'/%3E%3Ccircle cx='160' cy='18' r='1.2'/%3E%3Ccircle cx='80' cy='118' r='.8'/%3E%3Ccircle cx='132' cy='76' r='.6'/%3E%3Ccircle cx='8' cy='72' r='.9'/%3E%3C/g%3E%3Cg stroke='%23f4f2e7' stroke-opacity='.035' stroke-width='1.4' stroke-linecap='round'%3E%3Cpath d='M20 58h26'/%3E%3Cpath d='M104 100h30'/%3E%3Cpath d='M54 20h18'/%3E%3C/g%3E%3C/svg%3E");
  color-scheme: dark;
}

html[data-playlist-style='doodle'] body {
  background-color: var(--doodle-paper);
  background-image: var(--doodle-lines);
  color: var(--doodle-ink);
}

/* 加载层的纸面/黑板面 */
html[data-playlist-style='doodle'] #boot-loading {
  background-color: var(--doodle-paper);
  background-image: var(--doodle-lines);
}

html[data-playlist-style='doodle'] #boot-loading .loading {
  color: var(--doodle-ink-soft);
}

html[data-playlist-style='doodle'] #boot-loading .loading__square {
  /* 动画参数不动；只加手绘边框/硬阴影。border 用 border-box 避免改变 20px 尺寸 */
  box-sizing: border-box;
  border: var(--doodle-border);
  border-radius: 3px;
  box-shadow: 2px 2px 0 var(--doodle-shadow-color);
}

html[data-playlist-style='doodle'] #boot-loading .loading__square::after {
  box-shadow: none;
}

html[data-playlist-style='doodle'] #boot-loading .loading__label {
  color: var(--doodle-ink);
  text-decoration: underline wavy var(--doodle-red);
  text-decoration-thickness: 1.5px;
  text-underline-offset: 5px;
}

/* ===========================================================================
   吉卜力风（2026-10-31）

   和手绘风相反：这套调色板的**唯一出处是 `src/ghibliPalette.ts`**（按主播主题色推导）。
   下面这组只是**首帧默认值**（默认蓝主题、按皮肤），值必须与
   `ghibliPalette('#2f6fed', 皮肤)` 的输出逐字一致——`bootGhibliContract.test.ts` 盯着。
   改了推导规则就要同步改这两块，否则冷缓存首帧会闪一下。
   --------------------------------------------------------------------------- */

html[data-playlist-style='ghibli'],
.page[data-playlist-style='ghibli'] {
  /* 纸与墨（默认蓝主题 · 浅色） */
  --gh-paper: #ebeae7;
  --gh-paper-deep: #e5e2d8;
  --gh-ink: #4c392c;
  --gh-ink-soft: #715e50;
  --gh-ink-faint: #8c7869;
  --gh-line: rgba(76, 57, 44, 0.22);
  --gh-line-strong: rgba(76, 57, 44, 0.44);
  --gh-chip-a: #797cc3;
  --gh-chip-b: #bf887d;
  --brand: #4967a4;
  --brand-strong: #3d5482;
  --brand-ink: #ffffff;

  /* 场景（**不跟主题色**，永远是吉卜力的天光） */
  --gh-sky-top: #8fd0ee;
  --gh-sky-mid: #b9dff0;
  --gh-haze: #dfe8de;
  --gh-horizon: #f6ead0;
  --gh-moonlight: transparent;
  /* 云：一条 SVG 四种尺寸复用（和样张同一份形状） */
  --gh-cloud: url("data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 240 96%22 width=%22240%22 height=%2296%22%3E %3Cg fill=%22%23ffffff%22%3E %3Cellipse cx=%2278%22 cy=%2262%22 rx=%2258%22 ry=%2228%22 /%3E %3Cellipse cx=%22132%22 cy=%2246%22 rx=%2246%22 ry=%2230%22 /%3E %3Cellipse cx=%22178%22 cy=%2264%22 rx=%2244%22 ry=%2224%22 /%3E %3Crect x=%2240%22 y=%2258%22 width=%22170%22 height=%2226%22 rx=%2213%22 /%3E %3Cellipse cx=%2296%22 cy=%2240%22 rx=%2226%22 ry=%2218%22 /%3E %3C/g%3E %3C/svg%3E");
  --gh-cloud-opacity: 0.88;
  --gh-hills: url("data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 1440 340%22 preserveAspectRatio=%22none%22 width=%221440%22 height=%22340%22%3E %3Cpath d=%22M0 214 C 150 150 300 150 440 196 C 580 242 700 150 860 154 C 1030 158 1200 230 1440 186 L1440 340 L0 340Z%22 fill=%22%23a9cdb6%22 /%3E %3Cpath d=%22M0 262 C 180 214 330 250 500 244 C 690 238 820 190 1000 216 C 1180 242 1300 262 1440 246 L1440 340 L0 340Z%22 fill=%22%2388bb9e%22 /%3E %3C/svg%3E");
  color-scheme: light;
}

html[data-skin='dark'][data-playlist-style='ghibli'],
html[data-skin='dark'] .page[data-playlist-style='ghibli'] {
  --gh-paper: #2d3138;
  --gh-paper-deep: #242830;
  --gh-ink: #edeae8;
  --gh-ink-soft: #bfb6b0;
  --gh-ink-faint: #9f9289;
  --gh-line: rgba(237, 234, 232, 0.14);
  --gh-line-strong: rgba(237, 234, 232, 0.24);
  --gh-chip-a: #797cc3;
  --gh-chip-b: #bf887d;
  --brand: #7f9bd1;
  --brand-strong: #9bb1db;
  --brand-ink: #1f1a15;

  --gh-sky-top: #0e1729;
  --gh-sky-mid: #1a2740;
  --gh-haze: #2b3347;
  --gh-horizon: #453a33;
  --gh-moonlight: rgba(255, 240, 205, 0.26);
  --gh-cloud: url("data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 240 96%22 width=%22240%22 height=%2296%22%3E %3Cg fill=%22%23dfe7f2%22%3E %3Cellipse cx=%2278%22 cy=%2262%22 rx=%2258%22 ry=%2228%22 /%3E %3Cellipse cx=%22132%22 cy=%2246%22 rx=%2246%22 ry=%2230%22 /%3E %3Cellipse cx=%22178%22 cy=%2264%22 rx=%2244%22 ry=%2224%22 /%3E %3Crect x=%2240%22 y=%2258%22 width=%22170%22 height=%2226%22 rx=%2213%22 /%3E %3Cellipse cx=%2296%22 cy=%2240%22 rx=%2226%22 ry=%2218%22 /%3E %3C/g%3E %3C/svg%3E");
  --gh-cloud-opacity: 0.2;
  --gh-hills: url("data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 1440 340%22 preserveAspectRatio=%22none%22 width=%221440%22 height=%22340%22%3E %3Cpath d=%22M0 214 C 150 150 300 150 440 196 C 580 242 700 150 860 154 C 1030 158 1200 230 1440 186 L1440 340 L0 340Z%22 fill=%22%232c3d4f%22 /%3E %3Cpath d=%22M0 262 C 180 214 330 250 500 244 C 690 238 820 190 1000 216 C 1180 242 1300 262 1440 246 L1440 340 L0 340Z%22 fill=%22%2324323f%22 /%3E %3C/svg%3E");
  color-scheme: dark;
}

/* 首帧的 body 底：必须比 `styles.css` 的 `html[data-skin] body`（0,1,2）更特指，
   否则冷缓存第一帧会是中性画布而不是天空 */
html[data-skin='light'][data-playlist-style='ghibli'] body,
html[data-skin='dark'][data-playlist-style='ghibli'] body {
  background-color: var(--gh-horizon);
  background-image: linear-gradient(
    180deg,
    var(--gh-sky-top) 0%,
    var(--gh-sky-mid) 38%,
    var(--gh-haze) 62%,
    var(--gh-horizon) 100%
  );
  color: var(--gh-ink);
}

/* 加载层：和听众页**同一套场景**（天空 + 云 + 远山），抬帘子的时候不会换画。

   ⚠️ 基础规则把加载层做成「只盖住自己那点内容」的条（position:absolute + 无高度）。
   默认风下页面是白底，看不出来；吉卜力下它就成了顶部一条天空 + 下面一大片蓝——
   所以这里必须让它**铺满视口**。 */
html[data-playlist-style='ghibli'] #boot-loading:not([hidden]) {
  inset: 0;
  min-height: 100vh;
  padding-top: var(--page-pad-top);
  background-color: var(--gh-horizon);
  background-image: linear-gradient(
    180deg,
    var(--gh-sky-top) 0%,
    var(--gh-sky-mid) 38%,
    var(--gh-haze) 62%,
    var(--gh-horizon) 100%
  );
  background-repeat: no-repeat;
  background-size: cover;
}

/* 云：和 `.page::before` 同一条几何（46% 高、四个尺寸/位置、整层 opacity） */
html[data-playlist-style='ghibli'] #boot-loading::before {
  content: '';
  position: absolute;
  inset: 0 0 auto 0;
  height: 46%;
  pointer-events: none;
  background-image: var(--gh-cloud), var(--gh-cloud), var(--gh-cloud), var(--gh-cloud);
  background-repeat: no-repeat;
  background-size: 320px auto, 210px auto, 150px auto, 104px auto;
  background-position: 88% 38%, 62% 14%, 4% 46%, 26% 6%;
  opacity: var(--gh-cloud-opacity, 0.88);
}

/* 远山：贴底 */
html[data-playlist-style='ghibli'] #boot-loading::after {
  content: '';
  position: absolute;
  inset: auto 0 0 0;
  height: 46vh;
  min-height: 300px;
  pointer-events: none;
  background-image: var(--gh-hills);
  background-repeat: no-repeat;
  background-position: center bottom;
  background-size: 100% 100%;
}

/* 深色：月亮也一起 */
html[data-skin='dark'][data-playlist-style='ghibli'] #boot-loading:not([hidden]) {
  background-image: radial-gradient(
      44px 44px at 86% 7%,
      #f7ecd4 0 26px,
      rgba(247, 236, 212, 0.5) 28px,
      rgba(247, 236, 212, 0) 56px
    ),
    radial-gradient(700px 420px at 86% 6%, var(--gh-moonlight), transparent 62%),
    linear-gradient(
      180deg,
      var(--gh-sky-top) 0%,
      var(--gh-sky-mid) 38%,
      var(--gh-haze) 62%,
      var(--gh-horizon) 100%
    );
  background-repeat: no-repeat, no-repeat, no-repeat;
  background-size: auto, auto, cover;
  background-position: center, center, center;
}

html[data-playlist-style='ghibli'] #boot-loading .loading {
  color: var(--gh-ink-soft);
}

html[data-playlist-style='ghibli'] #boot-loading .loading__square {
  /* 动画参数一个字不动，只换材质：奶油底 + 细边 + 柔和阴影
     （纯奶油底在浅蓝天空上几乎看不见，必须给一圈边） */
  box-sizing: border-box;
  border: 1px solid color-mix(in srgb, var(--gh-ink) 22%, transparent);
  border-radius: 8px;
  background-color: var(--gh-paper);
  box-shadow: 0 10px 22px -12px rgba(70, 60, 45, 0.55);
}

html[data-playlist-style='ghibli'] #boot-loading .loading__square::after {
  box-shadow: none;
}

html[data-playlist-style='ghibli'] #boot-loading .loading__label {
  color: var(--gh-ink);
  text-shadow: 0 1px 10px rgba(255, 255, 255, 0.45);
}

html[data-skin='dark'][data-playlist-style='ghibli'] #boot-loading .loading__label {
  text-shadow: 0 1px 14px rgba(0, 0, 0, 0.6);
}

/* ============================================================ 霓虹风（2026-11-01）

   **只在文件末尾追加**（契约脚本会检查同一风格的规则连成一段）。
   这组值必须与 `src/neon/vars.ts` 的 `neonStyleVars(#2f6fed, 皮肤)` 逐字一致，
   由 `bootNeonContract.test.ts` 盯着。
   ⚠️ body 的首帧底色必须显式写：只写令牌的话，暖缓存首帧会落回默认风的中性底（白闪一下）。
   ============================================================ */

html[data-playlist-style='neon'],
.page[data-playlist-style='neon'] {
  --n-bg: #eef3fb;
  --n-card: hsl(0 0% 100% / 0.88);
  --n-card-solid: #ffffff;
  --n-card-strong: hsl(0 0% 100% / 0.96);
  --n-seed: #2f6fed;
  --n-primary: #1d4eaf;
  --n-secondary: #17758c;
  --n-mid: #25399d;
  --n-lamp-a: #7ca1e9;
  --n-lamp-b: #919fe8;
  --n-lamp-c: #b2c7f0;
  --n-ink: #162136;
  --n-ink-soft: #404f6d;
  --n-line: hsl(219.8 84.070796460177% 42% / 0.5);
  --n-line-strong: hsl(219.8 84.070796460177% 36% / 0.75);
  --n-glow-2: hsl(230.1 80% 60% / 0.12);
  --n-scanline: hsl(0 0% 0% / 0.03);
  --n-on-primary: #ffffff;
}

html[data-skin='dark'][data-playlist-style='neon'],
html[data-skin='dark'] .page[data-playlist-style='neon'] {
  --n-bg: #0b0533;
  --n-card: hsl(247.4 100% 17% / 0.6);
  --n-card-solid: #0b0057;
  --n-card-strong: hsl(247.4 100% 17% / 0.92);
  --n-seed: #2f6fed;
  --n-primary: #7ca4f4;
  --n-secondary: #42d5fa;
  --n-mid: #798cec;
  --n-lamp-a: #7ca4f4;
  --n-lamp-b: #798cec;
  --n-lamp-c: #2866e1;
  --n-ink: #f5f7fa;
  --n-ink-soft: #b2bdd2;
  --n-line: hsl(219.8 84.070796460177% 72% / 0.25);
  --n-line-strong: hsl(219.8 84.070796460177% 72% / 0.5);
  --n-glow-2: hsl(230.1 100% 70% / 0.18);
  --n-scanline: hsl(0 0% 0% / 0.06);
  --n-on-primary: #0a0512;
}

html[data-playlist-style='neon'] body {
  background-color: #eef3fb;
}

html[data-skin='dark'][data-playlist-style='neon'] body {
  background-color: #0b0533;
}

/* 加载层：紫黑夜幕底 + 发光方块（浅色=奶白底+深字），和听众页同一套配色。
   ⚠️ 基础规则把加载层做成「只盖住自己那点内容」的条，这里必须让它铺满视口。 */
html[data-playlist-style='neon'] #boot-loading:not([hidden]) {
  inset: 0;
  min-height: 100vh;
  padding-top: var(--page-pad-top);
  background-color: var(--n-bg);
  background-image:
    radial-gradient(80% 60% at 50% 10%, var(--n-glow-2) 0%, transparent 70%),
    linear-gradient(var(--n-line) 1px, transparent 1px),
    linear-gradient(90deg, var(--n-line) 1px, transparent 1px);
  background-size:
    100% 100%,
    60px 60px,
    60px 60px;
  color: var(--n-ink);
}

html[data-playlist-style='neon'] #boot-loading .loading__square {
  background-color: var(--n-lamp-a);
  box-shadow: 0 0 12px var(--n-lamp-a);
}

html[data-playlist-style='neon'] #boot-loading .loading__label {
  color: var(--n-ink-soft);
  letter-spacing: 0.2em;
}

/* ============================================================ 可爱风（2026-11-03）
   **只在文件末尾追加**（契约脚本会检查同一风格的规则连成一段）。
   这组值必须与 `src/styles/kawaii/vars.ts` 的 `kawaiiStyleVars('#2f6fed', 皮肤)` 逐字一致，
   由 `bootKawaiiContract.test.ts` 盯着。
   ⚠️ body 的首帧底色必须显式写：只写令牌的话，暖缓存首帧会落回默认风的中性底（白闪一下）。
   ============================================================ */

html[data-playlist-style='kawaii'],
.page[data-playlist-style='kawaii'] {
  --k-bg: #fff7ed;
  --k-card: #f8fafe;
  --k-ink: #4a3f4b;
  --k-ink-2: #7a6f7c;
  --k-ink-3: #75677c;
  --k-line: #eaf0fb;
  --k-line-strong: #d9e3f9;
  --k-card-line: #cad9f6;
  --k-pink: #9fbaef;
  --k-pink-deep: #5081e2;
  --k-pink-soft: #e5ecfb;
  --k-pink-line: #c3d3f4;
  --k-pink-shade: #6a94e7;
  --k-pink-ink: #46243a;
  --k-title: #477be1;
  --k-lav: #a78bfa;
  --k-mint: #67e8f9;
  --k-butter: #fde68a;
  --k-accent-b: #a594f8;
  --k-accent-c: #72dff7;
  --k-tag-bg: #e5ecfb;
  --k-tag-ink: #1d3d7c;
  --k-tone-butter-bg: #faf2cd;
  --k-tone-butter-ink: #7a4b00;
  --k-tone-lav-bg: #e0d9fd;
  --k-tone-lav-ink: #5b3aa8;
  --k-tone-mint-bg: #d2f5fd;
  --k-tone-mint-ink: #0b6472;
  --k-shadow-soft: 0 8px 22px rgba(159, 186, 239, 0.15), 0 2px 6px rgba(167, 139, 250, 0.07);
  --k-shadow-lift: 0 16px 32px rgba(159, 186, 239, 0.24), 0 3px 10px rgba(167, 139, 250, 0.1);
  --k-shadow-press: 0 4px 10px rgba(159, 186, 239, 0.18);
  --brand: #5081e2;
  --brand-strong: #2462db;
  --brand-soft: rgba(159, 186, 239, 0.18);
  --brand-soft-strong: rgba(159, 186, 239, 0.3);
  --brand-line: rgba(159, 186, 239, 0.45);
  --brand-ink: #46243a;
}

html[data-skin='dark'][data-playlist-style='kawaii'],
html[data-skin='dark'] .page[data-playlist-style='kawaii'] {
  --k-bg: #2b2130;
  --k-card: #231d29;
  --k-ink: #f7efea;
  --k-ink-2: #c9bbcb;
  --k-ink-3: #9c8ea2;
  --k-line: #4d485c;
  --k-line-strong: #5c5c74;
  --k-card-line: #666a86;
  --k-pink: #a3bbeb;
  --k-pink-deep: #82a3e3;
  --k-pink-soft: #223458;
  --k-pink-line: #3c5586;
  --k-pink-shade: #547fd4;
  --k-pink-ink: #3a1c2e;
  --k-title: #8aa9e5;
  --k-lav: #c4b5fd;
  --k-mint: #8fe9f5;
  --k-butter: #fce7a2;
  --k-accent-b: #bdb6f9;
  --k-accent-c: #93e0f3;
  --k-tag-bg: #223458;
  --k-tag-ink: #d7e1f4;
  --k-tone-butter-bg: #463d3c;
  --k-tone-butter-ink: #fce7a2;
  --k-tone-lav-bg: #433b53;
  --k-tone-lav-ink: #e9d5ff;
  --k-tone-mint-bg: #343e4a;
  --k-tone-mint-ink: #a5f3fc;
  --k-shadow-soft: 0 8px 24px rgba(0, 0, 0, 0.38), 0 2px 6px rgba(0, 0, 0, 0.26);
  --k-shadow-lift: 0 16px 34px rgba(0, 0, 0, 0.5), 0 3px 10px rgba(0, 0, 0, 0.3);
  --k-shadow-press: 0 4px 10px rgba(0, 0, 0, 0.32);
  --brand: #82a3e3;
  --brand-strong: #a3bbeb;
  --brand-soft: rgba(163, 187, 235, 0.18);
  --brand-soft-strong: rgba(163, 187, 235, 0.3);
  --brand-line: rgba(163, 187, 235, 0.45);
  --brand-ink: #3a1c2e;
}

html[data-playlist-style='kawaii'] body {
  background-color: var(--k-bg);
}

/* ⚠️ 必须带上 `[data-skin]`：`styles.css` 里 `html[data-skin] body`（0,1,2）和
   `html[data-playlist-style='kawaii'] body`（0,1,2）同特异性、且 styles.css 更晚加载，
   只写上面那条的话，**首帧（风格样式还没按需加载）会落回默认风的中性底**（实测 #f6f7f9）。 */
html[data-skin='light'][data-playlist-style='kawaii'] body {
  background-color: var(--k-bg);
}

html[data-skin='dark'][data-playlist-style='kawaii'] body {
  background-color: var(--k-bg);
}

/* 加载层：奶油/可可底 + 粉彩方块 + 暖字，和听众页同一套配色 */
html[data-playlist-style='kawaii'] #boot-loading:not([hidden]) {
  inset: 0;
  min-height: 100vh;
  padding-top: var(--page-pad-top);
  background-color: var(--k-bg);
  background-image:
    radial-gradient(
      52% 42% at 104% -6%,
      color-mix(in srgb, var(--k-pink) 38%, transparent),
      transparent 62%
    ),
    radial-gradient(
      46% 38% at -8% 62%,
      color-mix(in srgb, var(--k-accent-b) 24%, transparent),
      transparent 64%
    );
  color: var(--k-ink);
}

html[data-playlist-style='kawaii'] #boot-loading .loading__square {
  background-color: var(--k-pink);
  box-shadow: 0 6px 16px color-mix(in srgb, var(--k-pink) 35%, transparent);
}

html[data-playlist-style='kawaii'] #boot-loading .loading__label {
  color: var(--k-ink-2);
}
