/* BODHI Dashboard · css/motion.css · 全站动效层（2026-08-01）
   ⚠ 层叠顺序 = index.html 里 <link> 的先后：本文件在 base.css 之后、ui.css 之前
     ——动效是「工具层」，组件层（ui.css）要能覆盖它，别调到 ui.css 后面去。

   ★ 四条硬纪律（改本文件前必读，每条都对应一个真实约束）：
     1. **auto-refresh 每 10s 重渲 #main**。入场动画一律挂在 `.mo-enter` 这个一次性 class 上，
        由 router.js 只在「视图真切换」时打（判据 _lastRoutedHash !== matched.hash）。
        直接给列表元素裸写 animation = 每 10 秒全站重放一次，眩晕。
     2. **公网走家宽上行隧道**。动效零额外字节：纯 CSS，不引任何库/图/字体，JS 只做 class 开关。
     3. **tailwind CDN 的 <style> 注在 css/ 之后**，同特异性下工具类会赢。本文件所有类一律
        `.mo-` 前缀，绝不占 animate-* / transition-* / duration-* 这些 tailwind 域名。
     4. **只动 transform / opacity / filter**（合成层属性）。禁 height/margin/width 补间——
        router.js 的 restoreScroll() 在渲染后同步跑，布局属性一动，滚动恢复位置就飘。

   token 定义在 ui.css :root（设计层唯一真源），本文件只消费。*/

/* ── 全局减少动画兜底 ──────────────────────────────────────────────────
   系统开了「减少动态效果」就全站静止。这一条同时收口了原先散在
   mobile.css / auth.css 里的两块局部 reduce 规则（已删）。*/
/* 显式 app 偏好也能主动减弱（即使 OS 没开）；motion.js 初始化时同步 class。 */
html.motion-reduced *,
html.motion-reduced *::before,
html.motion-reduced *::after {
  animation-duration: .01ms !important;
  animation-iteration-count: 1 !important;
  transition-duration: .01ms !important;
  scroll-behavior: auto !important;
}
@media (prefers-reduced-motion: reduce) {
  html:not(.motion-force-full) *,
  html:not(.motion-force-full) *::before,
  html:not(.motion-force-full) *::after {
    animation-duration: .01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: .01ms !important;
    scroll-behavior: auto !important;
  }
}

/* GSAP 成功载入后由统一 motion.js 接管这些入口；CSS animation 只保留为无库降级。 */
.gsap-ready body.mo-routing .route-progress span,
.gsap-ready .modal-overlay,
.gsap-ready .modal-overlay > *,
.gsap-ready .picker-overlay,
.gsap-ready .picker-overlay > *,
.gsap-ready .aigen-overlay,
.gsap-ready .aigen-overlay > *,
.gsap-ready .editor-overlay,
.gsap-ready .editor-overlay > * {
  animation: none;
}
.gsap-ready .modal-overlay > * { will-change: transform, opacity; }

/* ══ 1. 结构层：modal / lightbox / picker / 抽屉 进出 ══════════════════ */

/* 入场不需要 JS：这些层每次都是新建元素塞进 #modal-root，animation 自动播一次。
   退场需要 JS（元素得先留在 DOM 上）——收口在 ui/modal.js closeModal() 与
   ui/lightbox.js closeLightbox()，给根节点打 .mo-leaving 再等动画结束清空。*/
@keyframes mo-overlay-in { from { opacity: 0; } to { opacity: 1; } }
@keyframes mo-overlay-out { from { opacity: 1; } to { opacity: 0; } }
@keyframes mo-card-in {
  from { opacity: 0; transform: translateY(var(--mo-rise)) scale(.94); }
  to   { opacity: 1; transform: none; }
}
@keyframes mo-card-out {
  from { opacity: 1; transform: none; }
  to   { opacity: 0; transform: translateY(4px) scale(.985); }
}

.modal-overlay { animation: mo-overlay-in var(--mo-base) var(--mo-out); }
.modal-overlay > * { animation: mo-card-in var(--mo-base) var(--mo-out); }

/* ★ 退场标记 .mo-leaving 打在 overlay 元素**自身**，不打在 #modal-root 上——各处打开
   modal 是「root.innerHTML='' + append 新 overlay」（15+ 处，无统一 open 函数），标记若在
   root 上，切镜连开时新 overlay 会继承它，一进 DOM 就播退场然后消失。见 ui/modal.js。*/
.modal-overlay.mo-leaving {
  animation: mo-overlay-out var(--mo-fast) var(--mo-in) forwards;
  pointer-events: none;   /* 离场的 140ms 里别再吃点击 */
}
.modal-overlay.mo-leaving > * {
  animation: mo-card-out var(--mo-fast) var(--mo-in) forwards;
}

/* lightbox：黑底淡入 + 换图淡入。
   ⚠ .lightbox-img 带 inline transform（缩放/平移，见 lightbox.js apply()），
     绝不能用 transform 做它的动画——animation 优先级高过 inline style，
     放大态下一重渲就会被拽回原位。只动 opacity。*/
@keyframes mo-fade-in { from { opacity: 0; } to { opacity: 1; } }
@keyframes mo-spin { to { transform: rotate(360deg); } }
@keyframes mo-breath { 50% { transform: translateY(-2px) scale(1.035); opacity: .78; } }
@keyframes mo-route-progress {
  0% { transform: translateX(-120%); }
  64% { transform: translateX(190%); }
  100% { transform: translateX(285%); }
}
@keyframes mo-rail-settle { from { transform: scaleX(0); opacity: .35; } to { transform: scaleX(1); opacity: 1; } }
/* ⚠ 黑底只在**首次打开**淡入：renderLightbox() 在切上/下一张、切宫格、缩放复位时都会
   整层重建，若无条件播 mo-overlay-in，每翻一张图黑底就闪一次。首次由 lightbox.js
   打 .mo-first（见 openLightbox）。图片本身则每次都淡入——那正是想要的换图过渡。*/
.lightbox.mo-first { animation: mo-overlay-in var(--mo-base) var(--mo-out); }
.lightbox .lightbox-img { animation: mo-fade-in var(--mo-base) var(--mo-out); }
.lightbox.mo-leaving {
  animation: mo-overlay-out var(--mo-fast) var(--mo-in) forwards;
  pointer-events: none;
}

/* 另外三类浮层（ref-picker / AI 生图 / 改图器）同一套进出。它们各自 append 到不同容器，
   但关闭都收口到 dom.js leaveOverlay()，标记同为 .mo-leaving。*/
.picker-overlay, .aigen-overlay, .editor-overlay {
  animation: mo-overlay-in var(--mo-base) var(--mo-out);
}
.picker-overlay > *, .aigen-overlay > *, .editor-overlay > * {
  animation: mo-card-in var(--mo-base) var(--mo-out);
}
.picker-overlay.mo-leaving, .aigen-overlay.mo-leaving, .editor-overlay.mo-leaving {
  animation: mo-overlay-out var(--mo-fast) var(--mo-in) forwards;
  pointer-events: none;
}

/* 搜索结果下拉 */
#search-results:not(.hidden) { animation: mo-card-in var(--mo-fast) var(--mo-out); }

/* ══ 2. 加载态：骨架屏 ════════════════════════════════════════════════ */

/* 结构由 dom.js skeleton() 生成。高光扫过用 transform 位移伪元素，不动 background-position
   （后者每帧重绘整块，长列表下掉帧）。*/
.mo-skel {
  position: relative;
  overflow: hidden;
  border-radius: var(--b-radius);
  background: var(--b-surface-3);
}
.mo-skel::after {
  content: '';
  position: absolute;
  inset: 0;
  transform: translateX(-100%);
  background: linear-gradient(90deg,
    transparent 0%,
    rgb(255 255 255 / .55) 50%,
    transparent 100%);
  animation: mo-skel-sweep 1.4s ease-in-out infinite;
}
.dark .mo-skel::after {
  background: linear-gradient(90deg,
    transparent 0%,
    rgb(255 255 255 / .07) 50%,
    transparent 100%);
}
@keyframes mo-skel-sweep {
  from { transform: translateX(-100%); }
  to   { transform: translateX(100%); }
}
/* 120ms 门槛由 router 在 DOM 层控制；进 DOM 就立即柔和出现，不再叠加第二段空窗。*/
.mo-skel-wrap { animation: mo-fade-in var(--mo-base) var(--mo-out) both; }
.mo-skel-line { height: 12px; border-radius: 4px; }
.mo-skel-card { aspect-ratio: 16 / 9; border-radius: var(--b-radius-lg); }
.mo-skel-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
  gap: 14px;
}
.mo-skel-rows { display: flex; flex-direction: column; gap: 10px; }
.mo-skel-dashboard { display: grid; gap: 18px; }
.mo-skel-dashboard-head { padding: 4px 2px; }
.mo-skel-dashboard-hero { height: min(230px, 29vh); border-radius: 16px; }
.mo-skel-dashboard-rail {
  display: grid;
  grid-template-columns: repeat(5, minmax(0, 1fr));
  gap: 14px;
  padding: 22px;
  border: 1px solid var(--b-line);
  border-radius: 14px;
  background: color-mix(in srgb, var(--b-surface) 92%, transparent);
}
.mo-skel-dashboard-columns { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }

/* ══ 3. 图片解码淡入 ══════════════════════════════════════════════════ */

/* ★ 用 animation 而非「默认 opacity:0 + load 后转 1」：后者一旦 JS 没跑到（模块加载失败、
   老浏览器）图片就永久不可见。这里默认状态完全不改，只在 dom.js 标记 .mo-img-in 后
   补播一次 0→1，JS 失效顶多没动效，不会白图。*/
@keyframes mo-img-in { from { opacity: 0; } to { opacity: 1; } }
.mo-img-in { animation: mo-img-in 220ms var(--mo-out); }

/* ══ 4. 列表入场 stagger + 视图转场 ═══════════════════════════════════ */

/* .mo-enter 由 router.js 在切视图时打一次，animationend 后自摘（见 router.js）。*/
@keyframes mo-rise-in {
  from { opacity: 0; transform: translateY(var(--mo-rise)); }
  to   { opacity: 1; transform: none; }
}
#main.mo-enter { animation: mo-view-in var(--mo-slow) var(--mo-out); }
@keyframes mo-view-in {
  /* ⚠ 别再往下调：初版写的是 .96→1（4% 透明度变化 + 4px），实测肉眼根本察觉不到，
     等于白做。这是每次点导航都看的动效，克制是对的，但克制到看不见就没有意义了。*/
  from { opacity: .55; transform: translateY(10px); }
  to   { opacity: 1; transform: none; }
}

/* ★ stagger 必须限定在 #main.mo-enter 之内——.mo-stagger 这个 class 是视图渲染时写死在
   容器上的，一直都在；只有外层的 .mo-enter 是 router 按「真切了视图」才打的一次性标记。
   少了这层限定，auto-refresh 每 10s 重渲就会让整屏卡片再瀑布一遍。
   只给前 12 个：长列表尾部继续排队 = 视口里的卡迟迟不出现；且 40 张卡各开一个合成层，
   低端机直接掉帧。*/
#main.mo-enter .mo-stagger > * {
  animation: mo-rise-in var(--mo-slow) var(--mo-out) both;
}
#main.mo-enter .mo-stagger > *:nth-child(1)  { animation-delay: 0ms; }
#main.mo-enter .mo-stagger > *:nth-child(2)  { animation-delay: 45ms; }
#main.mo-enter .mo-stagger > *:nth-child(3)  { animation-delay: 90ms; }
#main.mo-enter .mo-stagger > *:nth-child(4)  { animation-delay: 135ms; }
#main.mo-enter .mo-stagger > *:nth-child(5)  { animation-delay: 180ms; }
#main.mo-enter .mo-stagger > *:nth-child(6)  { animation-delay: 225ms; }
#main.mo-enter .mo-stagger > *:nth-child(7)  { animation-delay: 270ms; }
#main.mo-enter .mo-stagger > *:nth-child(8)  { animation-delay: 315ms; }
#main.mo-enter .mo-stagger > *:nth-child(9)  { animation-delay: 360ms; }
#main.mo-enter .mo-stagger > *:nth-child(10) { animation-delay: 405ms; }
#main.mo-enter .mo-stagger > *:nth-child(11) { animation-delay: 450ms; }
#main.mo-enter .mo-stagger > *:nth-child(12) { animation-delay: 495ms; }
#main.mo-enter .mo-stagger > *:nth-child(n+13) { animation: none; }

/* ══ 5. 状态型微动效 ══════════════════════════════════════════════════ */

/* 弹一下：📬 新任务徽标 / 收藏 / 计数跳变。--mo-spring 带回弹。*/
@keyframes mo-pop {
  0%   { transform: scale(1); }
  40%  { transform: scale(1.35); }
  100% { transform: scale(1); }
}
.mo-pop { animation: mo-pop 320ms var(--mo-spring); }

/* 呼吸：生成中 / 排队中。原 agent-chat.css agent-pulse 与 scene-board.css sb-pulse
   是同一个东西，统一到这里；那两处保留各自类名但引用本 keyframes。*/
@keyframes mo-pulse { 50% { opacity: .65; } }
.mo-pulse { animation: mo-pulse 1.6s ease-in-out infinite; }

/* 生成中的卡：金色边框呼吸，比整块变淡更克制（不影响读图） */
@keyframes mo-glow {
  0%, 100% { box-shadow: 0 0 0 1px rgb(var(--gold-500) / .35), 0 0 0 0 rgb(var(--gold-500) / 0); }
  50%      { box-shadow: 0 0 0 1px rgb(var(--gold-500) / .8),  0 0 14px 2px rgb(var(--gold-500) / .28); }
}
.mo-glow { animation: mo-glow 1.8s ease-in-out infinite; }

/* 落定闪一下：新 take / 新版本 / 保存成功。收口了 annotations.css 的 anno-flash 手法。*/
@keyframes mo-flash {
  0%, 35% { background-color: rgb(var(--gold-500) / .5); }
  100%    { background-color: transparent; }
}
.mo-flash { animation: mo-flash 1.1s ease-out; }

/* 完成打勾：SVG stroke 描画。用法 <svg class="mo-check"><path pathLength="1" …>
   pathLength=1 把任意路径长度归一，dasharray 就不用按真实长度算。*/
@keyframes mo-check-draw { to { stroke-dashoffset: 0; } }
.mo-check path {
  stroke-dasharray: 1;
  stroke-dashoffset: 1;
  animation: mo-check-draw 420ms var(--mo-out) forwards;
}

/* 进度环补间：--ring-pct 注册成真正的数值类型后，CSS 才肯对 conic-gradient 里的
   calc(var(--ring-pct)*1%) 做补间（默认自定义属性是字符串，只会硬跳）。
   @property 无 polyfill；不支持的浏览器退化成硬跳，功能不受影响。*/
@property --ring-pct {
  syntax: '<number>';
  inherits: true;
  initial-value: 0;
}
.ring-bg { transition: --ring-pct 700ms var(--mo-out); }

/* ══ 6. 打磨项 ════════════════════════════════════════════════════════ */

/* 暗色切换 crossfade。⚠ 必须排除 img/video/canvas ——媒体跟着做背景色补间会闪，
   而且镜头清单一屏几十个 <img>，全体开过渡直接掉帧。
   只在切换的那一瞬开：main.js toggleTheme 给 <html> 打 .mo-theming，300ms 后摘。*/
.mo-theming *:not(img):not(video):not(canvas):not(svg) {
  transition: background-color 220ms var(--mo-out),
              border-color 220ms var(--mo-out),
              color 220ms var(--mo-out) !important;
}

/* tab 活动下划线：从中间展开而非硬跳（画线的 ::after 在 ui.css .tab-item.is-active）*/
@keyframes mo-underline-in {
  from { transform: scaleX(0); opacity: .4; }
  to   { transform: scaleX(1); opacity: 1; }
}

/* strip 扫播起播缓入：原 steps(5) 从第 1 帧硬跳，hover 瞬间很突兀。
   加 120ms 延迟 = 鼠标只是路过时根本不起播，减少满屏乱动。*/
.strip-frame:hover { animation-delay: 120ms; }
.take-strip-anim:hover, .group\/tk:hover .take-strip-anim { animation-delay: 120ms; }

/* 侧栏折叠只做宽度过渡（在 nav.css）。
   ⚠ 别试图给 .nav-label 加淡出：nav.css 收起态是 `display:none`，元素直接从渲染树上没了，
     opacity 过渡根本不会播。要淡出就得先把 display:none 换成不影响布局的写法，
     那会牵动整条收起态样式，收益不值——收起是低频操作，硬切可接受。 */

/* 移动端抽屉：侧栏 transform 滑入在 mobile.css（已有，用同一套 token）；这里只补遮罩淡入
   ——它是 display:none↔block 硬切，加 animation 后从 none 变 block 那一帧会播。*/
@media (max-width: 768px) {
  .sidebar-backdrop { animation: mo-overlay-in var(--mo-base) var(--mo-out); }
  .mo-skel-dashboard-rail { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .mo-skel-dashboard-columns { grid-template-columns: 1fr; }
}
