跳转至

会动的网页 PPT 是怎么做出来的

2026 年 5 月我做了一次组内分享,没用 PPT 软件——演示稿是一个滚动驱动的叙事网页:向下滚动,背景里的柴犬在 12 个场景之间连续动画过渡,文案随场景切换。先看效果:

操作方式

先点一下画面,然后按下鼠标右键,尽量别用滚轮

原理:静帧 + 过场视频的三明治

拆开看只有三种东西:

scene0.png ──trans0to1.mp4──▶ scene1.png ──trans1to2.mp4──▶ scene2.png ──▶ …
  静帧            过场视频         静帧           过场视频        静帧

页面本体是一个很高的空滚动区(数百 vh),真正的画面固定在视口里。滚动进度落在静帧段就显示对应 PNG;落在过渡段就把滚动进度映射成视频的 currentTime——不是 play() 播放,而是滚多少、视频走多少帧,倒着滚就倒放。因为过场视频的首帧和尾帧就是前后两张静帧图,衔接处像素级无缝,看起来就是「一镜到底」。

素材管线:关键帧图 + 首尾帧视频

第一步,出关键帧。 12 个场景 = 12 张柴犬图,用 GPT 网页批量生成 image2 再人工筛选。出图不是随便出,有三条规矩(来自工程手册 deck skill 的 Scene Anatomy 章):

  • 统一 16:9 灰底,柴犬主体只占 1/3 ~ 1/2 画面,剩余大量留白是给文字的——文字盖到柴犬脸上就是设计失败;
  • 每张图先想清楚「柴犬落在九宫格哪个位置」,再决定这一页文字走哪种版式;
  • 每个场景对应叙事弧线上的一个情绪:待出发 → 起跑 → 犹豫 → 探索 → 好奇 → 慵懒 → 满足 → 专注 → 振奋 → 温柔反思 → 安睡戴冠。

当时的调研笔记里有一句现在看依然成立的话:

我发现里面最难的还是美术风格,即便你知道使用什么技术,里面也会有很多坑……特别是这种非常主观表达的场景,可以说是有一部分创作的成分在里面,我们需要的并不是一个 AI 非常无偏的表达。

所以「批量出图 + 人工筛选」不是偷懒,它就是这类主观创作场景下和图像模型的正确协作方式——模型负责量,人负责挑出符合叙事情绪的那张。

第二步,首尾帧生成过场视频。 把相邻两张关键帧作为首帧和尾帧喂给支持首尾帧的图生视频模型,让它脑补中间的运动(成片约 4 秒、720p、24fps)。这一招还有个彩蛋用法:首尾给同一张图,就得到一段可以无限循环的动画背景——标题页(trans1to2,奔跑循环)和结尾「感谢聆听」页(trans11to11,安睡循环)都是这么来的,静帧页里柴犬是活的。

素材目录里至今留着管线化石:_orig.mp4 是模型原片,_抠图.png 是一轮没用上的抠图实验——创作过程比成品目录乱得多,这很正常。

最大的坑:模型给的 mp4 根本没法 scroll-seek

这是整个项目踩得最深的坑,也是这篇文章最值钱的一节。视频模型(以及几乎一切常规导出)给你的 mp4,直接拿来做 scroll-driven 必然出现:滚到中间画面冻住、只播开头然后直接跳末尾、必须滚很久才开始动。两个原因:

  1. moov 索引在文件末尾。 mp4 的 moov atom 记录「每个时间点对应文件哪个字节」,多数编码器默认把它放在文件尾部——浏览器没拿到索引之前,任何 currentTime 都无法精确 seek,只能等整个文件几乎下完。
  2. 关键帧密度不够,这个更致命。 视频里只有 keyframe(I-frame)能独立解码,常规编码几秒才一个。scroll-driven 是每一帧 requestAnimationFrame 都要 seek 到任意时间点——目标不是 keyframe 时,浏览器要找到上一个 keyframe 顺序解码过来,根本来不及,于是放弃、渲染手头最近的帧(往往是开头或末尾)。实测 v0.2 新入库的 6 段过场,无一例外都是 1 个 keyframe / 97~121 帧

修复一条 ffmpeg 命令同时解决两个问题——重编码成每帧都是关键帧(all-intra)并把索引挪到文件头:

ffmpeg -y -i in.mp4 \
  -c:v libx264 -preset fast -crf 20 \
  -g 1 -keyint_min 1 -sc_threshold 0 \
  -pix_fmt yuv420p -an \
  -movflags +faststart \
  out.mp4

-g 1 就是「GOP = 1,每帧皆 keyframe」。代价是体积变为 1.5~3 倍,但几秒的过场绝对体积仍然很小,换来帧级精确 seek。经验教训是把它做成素材入库仪式:每段新视频进目录先跑诊断(keyframe 数应 ≈ 总帧数),不达标立即重编码,不要等浏览器里「卡住了」再回头查——诊断命令和批量脚本都在工程手册里。

前端配套三件套也别漏:<video muted playsinline preload="auto">(iOS Safari 没有 playsinline 直接不渲染);本地预览必须走 HTTP server(file:// 下浏览器不给视频 byte-range);改完视频记得硬刷新清缓存——最后这条最蠢也最常踩。

引擎:三层 js,数据驱动

整个引擎没有框架,三个文件各管一层:

文件 职责
content.js 全部文案和媒体声明,一个大对象,改内容只碰这里
layouts.js 8 种版式的渲染函数(hero / left / right / anchor / accordion / steps / dual / blank)
deck.js 滚动映射、视频 seek、手风琴与画廊交互

这个分层的直接收益是加一页只要在 content.js 追加一个对象:声明背景(新视频先过 all-intra 仪式)、挑版式、标题里选一个词包 <span class="accent">,段落区间和章节归属全部自动计算。文案单源也意味着 AI 改稿只需要读写一个文件——这份稿子后期的内容调整基本都是这么让 AI 干的。

版式层面有几条铁律,来自工程手册的 Style Guide 章,违反任何一条页面气质就塌:

  • 每页只允许一个橙色强调词(#ff6a1a 是全局唯一强调色,多用即失效);
  • 主标题行高 0.9~0.94、letter-spacing 必须为负——视觉重量全靠这两个参数;
  • 16:9 letterbox 内一律用容器查询单位 cqw/cqh,写成 vw 响应式直接爆;
  • 留白宁大勿小,stats 是纯文字大数字,加圆角卡片底就变 dashboard 了。

把坑沉淀成 SKILL,让 AI 下次不再踩

这个项目从 v0.1 迭代到 v0.3,目录里最有价值的演变不是代码,是文档:v0.1 时视频处理、场景排布、视觉风格是三份散的笔记,v0.3 合并成一份带 frontmatter 的 SKILL.md——「新人看完就能改内容、加页、换图、排查视频问题」,而这个「新人」主要是 AI。下次再做同类 deck,把手册喂进上下文,all-intra、九宫格、单点橙这些坑一个都不用重踩。

另外存档了一份更早的化石:prompt-v0.md,第一版滚动开场的一次成型 prompt——从资产清单、滚动区间映射表到「scroll 事件只更新目标值、rAF 里缓动 seek」的实现要点全部写死在 prompt 里,AI 一发直出完整 HTML。对比它和后来的三层引擎能看出这类项目的自然演化:一次性 prompt 长出可维护的架构,踩坑记录长出 SKILL

附:怎么原样搬进这个 wiki

这个 deck 不是单文件——入口 html + 3 js + 1 css + 40 多个媒体文件,内部全是相对路径。能整体进 wiki 靠的是本站部署链路对静态文件的原样透传:zensical 构建时 docs/ 里所有非 .md 文件(含 .html)原样拷进 site/deploy.sh 再把 site/ 整体镜像到 COS——对象存储不关心你放的是一篇页面还是一整个网页应用。搬运时动了四处:

  • 只搬运行时子集:历史版本、_orig 原片、讲稿 .md 都不带——.md 混进 docs/ 会被当 wiki 页面渲染;
  • 字体本地化:原版走 Google Fonts,国内不可达会卡渲染。把 Archivo Black + Inter 的 woff2 真身下进 fonts.css(共约 160KB),中文走系统字体兜底;
  • 摘除内网 iframe:「在线试玩」原来嵌的是公司内网地址,公网必挂且泄漏 IP,换成说明文字;
  • 摘除失效外链:一处封面图是 star-history 在线图表,该 API 已因 GitHub 限制 star 数据而失效(原版现在也是裂的),移除后手风琴直接展开案例。

本地预览的坑:zensical serve 是简易开发服务器,会被 deck 一次性预加载的 12 段视频并发连接压挂(之后所有请求永远 pending)——预览这页请用 npx serve site;线上 COS 原生支持 Range 和并发,无此问题。

附:源码与手稿原文

引擎真身在本文 assets/deck/ 下,线上有独立 URL 可直接 GET;此处按规矩折叠展示,方便就地查看。工程手册已沉淀为独立 skill——滚动 deck 工程手册(Skills · 写作口味,导航可达),不在此重复。

引擎五件套(wiki 发布版:字体已本地化、内网 iframe 与失效外链已摘除,均留有注释):

index.html —— 入口骨架(21 行,画面全由三层 js 生成)
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>AI 工具实践分享</title>
<!-- 字体真身随站发布(原 Google Fonts 国内不可达);中文走 PingFang/雅黑系统兜底 -->
<link rel="stylesheet" href="fonts.css">
<link rel="stylesheet" href="style.css">
</head>
<body>
  <div class="stage">
    <div class="stage-inner" id="stageInner" data-active="0"></div>
  </div>
  <div class="spacer"></div>
  <div class="global-timer" id="globalTimer">00:00</div>
  <!-- 全屏按钮(wiki 发布版新增):文章内嵌 iframe 与新标签页都可一键进入沉浸阅读 -->
  <button class="fs-btn" id="fsBtn" title="全屏切换(快捷键 F)">&ensp;全屏</button>
  <style>
    .fs-btn {
      position: fixed; bottom: 1.2vh; right: 1.2vw; z-index: 9999;
      font: 600 max(1.1vw, 12px)/1 'Inter', 'Noto Sans SC', sans-serif;
      letter-spacing: 0.14em;
      color: rgba(10, 10, 10, 0.58); background: none;
      border: 1px solid rgba(10, 10, 10, 0.18); border-radius: 999px;
      padding: 0.6em 1.1em; cursor: pointer;
    }
    .fs-btn:hover { color: #ff6a1a; border-color: #ff6a1a; }
  </style>
  <script>
    (() => {
      const btn = document.getElementById('fsBtn');
      const root = document.documentElement;
      const toggle = () => document.fullscreenElement || document.webkitFullscreenElement
        ? (document.exitFullscreen || document.webkitExitFullscreen).call(document)
        : (root.requestFullscreen || root.webkitRequestFullscreen).call(root);
      btn.addEventListener('click', toggle);
      // 快捷键 F 由 deck.js 原生处理,此处不再监听(避免双触发互相抵消)
      document.addEventListener('fullscreenchange', () => {
        btn.innerHTML = document.fullscreenElement ? '⛶&ensp;退出全屏' : '⛶&ensp;全屏';
      });
    })();
  </script>
  <script src="content.js"></script>
  <script src="layouts.js"></script>
  <script src="deck.js"></script>
</body>
</html>
content.js —— 全部文案与媒体声明,改内容只碰这里(单源)
window.CONTENT = {
  meta: {
    title: 'AI 工具实践分享 · v0.3',
    version: 'v0.3',
    date: '2026.05.21',
    logo: 'Tap4Fun.BI',
  },

  media: [
    { type: 'image', id: 's0', src: './素材/scene0.webp' },
    { type: 'video', id: 'v0_1', src: './素材/trans0to1.mp4' },
    { type: 'video', id: 's1', src: './素材/trans1to2.mp4', loop: true },
    { type: 'video', id: 'v1_2', src: './素材/trans2to3.mp4' },
    { type: 'image', id: 's2', src: './素材/scene3.webp' },
    { type: 'video', id: 'v2_3', src: './素材/trans3to4.mp4' },
    { type: 'image', id: 's3', src: './素材/scene4.webp' },
    { type: 'image', id: 's4', src: './素材/scene4.webp' },
    { type: 'image', id: 's5', src: './素材/scene4.webp' },
    { type: 'image', id: 's6', src: './素材/scene4.webp' },
    { type: 'video', id: 'v6_7', src: './素材/trans4to5.mp4' },
    { type: 'image', id: 's7', src: './素材/scene5.webp' },
    { type: 'video', id: 'v7_8', src: './素材/trans5to6.mp4' },
    { type: 'image', id: 's8', src: './素材/scene6.webp' },
    { type: 'image', id: 's9', src: './素材/scene6.webp' },
    { type: 'image', id: 's10', src: './素材/scene6.webp' },
    { type: 'video', id: 'v10_11', src: './素材/trans6to7.mp4' },
    { type: 'image', id: 's11', src: './素材/scene7.webp' },
    { type: 'video', id: 'v11_12', src: './素材/trans7to8.mp4' },
    { type: 'image', id: 's12', src: './素材/scene8.webp' },
    { type: 'video', id: 'v12_13', src: './素材/trans8to9.mp4' },
    { type: 'image', id: 's13', src: './素材/scene9.webp' },
    { type: 'video', id: 'v13_14', src: './素材/trans9to10.mp4' },
    { type: 'image', id: 's14', src: './素材/scene10.webp' },
    { type: 'image', id: 's15', src: './素材/scene10.webp' },
    { type: 'video', id: 'v15_16', src: './素材/trans10to11.mp4' },
    { type: 'video', id: 's16', src: './素材/trans11to11.mp4', loop: true },
  ],

  nav: [
    { label: 'Prologue', jump: 0 },
    { label: 'Title', jump: 1 },
    { label: 'Part 1', jump: 2 },
    { label: 'Part 2', jump: 7 },
    { label: 'Part 3', jump: 11 },
    { label: 'Part 4', jump: 14 },
    { label: 'Fin', jump: 16 },
  ],

  chapters: [
    { mark: 'circle', label: 'Prologue' },
    { mark: 'square', label: 'Part 1' },
    { mark: 'tri', label: 'Part 2' },
    { mark: 'bar', label: 'Part 3' },
    { mark: 'dot', label: 'Part 4' },
    { mark: 'circle', label: 'Fin' },
  ],

  scenes: [
    {
      layout: 'blank',
      chapter: 0,
    },

    {
      layout: 'hero',
      chapter: 0,
      classes: ['scene-title'],
      kicker: 'AI 工具实践分享',
      headlineMid: '今天到底该',
      headline: '用什么&nbsp;<span class="accent">AI</span>&nbsp;工具',
      subhead: '从日常提效到 Agent 工程化探索',
    },

    {
      layout: 'anchor',
      chapter: 1,
      kicker: 'Part 01 · Quick Tricks',
      headline: '看了就能<br><span class="accent">变强</span>的小技巧',
      subhead: '<strong>技多不压身,学到就是赚到。</strong><br><span style="color: var(--muted);">目标:会后就能拿走几个真实可用的方法。</span>',
      modules: [
        { num: '4-1', name: 'Google Sheet', tag: '脚本 · 节假日 · 翻译' },
        { num: '4-2', name: '网页 AI', tag: 'DeepSeek · AI Studio · GPT' },
        { num: '4-3', name: 'AI PPT', tag: '工作流 · Skill · 美术' },
        { num: '4-4', name: '浏览器 / 电脑', tag: 'Browser Use · Computer Use' },
      ],
    },

    {
      layout: 'accordion',
      chapter: 1,
      accId: 'acc-1',
      coverImg: './part1/app脚本.jpg',
      overview: {
        kicker: '4-1 · Google Sheet · 小技巧',
        title: '第一部分<br><span class="accent">Google Sheet</span> 小技巧',
        lead: '围绕 Google Sheet 的自定义 App 脚本,解决日常小痛点。',
        note: '<span style="color:var(--accent); font-weight:600;">→ 点击此处展开案例</span>',
      },
      cases: [
        {
          tag: 'Case 01',
          title: '翻译脚本',
          summary: '翻译脚本与结果对照',
          detail: {
            title: '翻译脚本与<span class="accent">结果对照</span>',
            paragraphs: [
              '<video src="./part1/翻译脚本效果.mp4" controls playsinline class="detail-media"></video>',
            ],
            codeFile: './part1/翻译脚本.js',
            aside: [
              { kicker: '类型', text: 'Google Sheet <strong>App 脚本</strong>' },
            ],
          },
        },
        {
          tag: 'Case 02',
          title: '节假日统计',
          summary: '节假日统计脚本',
          detail: {
            title: '节假日<span class="accent">统计</span>',
            paragraphs: [
              '<img src="./part1/节假日.jpg" class="detail-media">',
            ],
            codeFile: './part1/节假日.js',
            aside: [
              { kicker: '类型', text: 'Google Sheet <strong>App 脚本</strong>' },
            ],
          },
        },
        {
          tag: 'Case 03',
          title: '更新通知整理',
          summary: '更新通知整理',
          detail: {
            title: '更新通知<span class="accent">整理</span>',
            paragraphs: [
              '<img src="./part1/app脚本.jpg" class="detail-media">',
            ],
            aside: [
              { kicker: '类型', text: 'Google Sheet <strong>App 脚本</strong>' },
            ],
          },
        },
      ],
    },

    {
      layout: 'accordion',
      chapter: 1,
      accId: 'acc-2',
      coverImg: './part1/网页端占比.png',
      overview: {
        kicker: '4-2 · 网页 AI',
        title: '不同任务<br>选不同<span class="accent">工具</span>',
        lead: '每个 AI 产品都有擅长的边界,挑对工具效率翻倍。',
        note: '<span style="color:var(--accent); font-weight:600;">→ 点击此处展开案例</span>',
      },
      cases: [
        {
          tag: 'Case 01',
          title: 'DeepSeek',
          summary: 'RP 特定提示词',
          detail: {
            title: 'DeepSeek V4 <span class="accent">RP</span> 思考模式切换',
            paragraphs: [
              '<strong>三种模式:</strong>默认(自动选择)/ <span class="accent-text">角色沉浸</span>(带内心独白)/ 纯分析(冷静规划)',
              '<code class="detail-code">角色沉浸模式:                        纯分析模式:\n&lt;think&gt;                              &lt;think&gt;\n(他跟我打招呼了……心跳加速。)        场景:用户打招呼,角色是傲娇属性。\n我要装作不在意的样子回应。             回复策略:先嫌弃,身体语言暴露真情。\n(不能让他看出来我很高兴!)           控制150字,先动作描写再对话。\n&lt;/think&gt;                             &lt;/think&gt;</code>',
              '<strong>用法:</strong>在第一条消息末尾粘贴指令,后续正常聊天即可全程生效。',
            ],
            aside: [
              { kicker: '仓库', text: '<a href="https://github.com/victorchen96/deepseek_v4_rolepaly_instruct/tree/main" target="_blank" style="color:var(--accent);font-weight:600;">deepseek_v4_roleplay_instruct →</a>' },
              { kicker: '适用', text: 'DeepSeek 官方 APP / 网页<strong>专家模式</strong>,及 deepseek-v4-flash / pro API' },
              { kicker: '注意', text: '概率输出,无法 100% 触发,可多 roll 几次' },
            ],
          },
        },
        {
          tag: 'Case 02',
          title: 'AI Studio',
          summary: '优先于 Gemini 网页',
          detail: {
            title: '<span class="accent">AI Studio</span> 优先于 Gemini 网页',
            paragraphs: [
              '<div class="detail-media-row"><img src="./part1/aistudio.jpg" class="detail-media"><img src="./part1/gemini会员.jpg" class="detail-media"></div>',
            ],
            aside: [
              { kicker: '链接', text: '<a href="https://aistudio.google.com/" target="_blank" style="color:var(--accent);font-weight:600;">aistudio.google.com →</a>' },
              { kicker: '说明', text: 'Gemini Pro 会员可以用<strong>更多功能</strong>' },
            ],
          },
        },
        {
          tag: 'Case 03',
          title: 'GPT',
          summary: '大量 image2 生成',
          detail: {
            title: 'GPT 网页适合<br>大量 <span class="accent">image2</span> 生成',
            paragraphs: [
              '<img src="./part1/gpt网页.jpg" class="detail-media">',
            ],
            aside: [
              { kicker: '适合', text: '大量图片生成场景' },
            ],
          },
        },
        {
          tag: 'Case 04',
          title: 'Claude',
          summary: '',
          detail: {
            title: '<span class="accent">Claude</span>',
            reveal: true,
            paragraphs: [
              '<div class="reveal-wrap"><img src="./part1/claude.webp" class="detail-media reveal-img"></div>',
            ],
            aside: [],
          },
        },
      ],
    },

    {
      layout: 'accordion',
      chapter: 1,
      accId: 'acc-3',
      overview: {
        kicker: '4-3 · AI PPT',
        title: '<span class="accent">AI PPT</span>',
        lead: '工作流平台 + PPT Skill,两条技术路线。',
        note: '<span style="color:var(--accent); font-weight:600;">→ 点击此处展开案例</span>',
      },
      cases: [
        {
          tag: 'Case 01',
          title: '工作流',
          summary: 'Genspark / Coze / LandPPT',
          detail: {
            title: '<span class="accent">工作流</span>',
            paragraphs: [
              '<a href="https://www.genspark.ai/" target="_blank" class="detail-link">Genspark →</a>',
              '<a href="https://www.coze.cn/overview" target="_blank" class="detail-link">Coze →</a>',
              '<a href="https://github.com/sligter/LandPPT" target="_blank" class="detail-link">LandPPT (GitHub) →</a>',
            ],
            aside: [
              { kicker: '类型', text: '端到端 <strong>AI PPT 工作流</strong>平台' },
            ],
          },
        },
        {
          tag: 'Case 02',
          title: 'PPT Skill',
          summary: '越来越流行',
          detail: {
            title: '<span class="accent">PPT Skill</span><br>越来越流行',
            paragraphs: [
              '<a href="https://github.com/mucsbr/ppt-agent-workflow-san" target="_blank" class="detail-link">ppt-agent-workflow-san →</a>',
              '<a href="https://github.com/JuneYaooo/gpt-image2-ppt-skills" target="_blank" class="detail-link">gpt-image2-ppt-skills →</a>',
              '<a href="https://github.com/stevenjinlong/awesome-ppt-skills" target="_blank" class="detail-link">awesome-ppt-skills →</a>',
            ],
            aside: [
              { kicker: '趋势', text: '大模型 + PPT Skill,<strong>越来越流行</strong>' },
            ],
          },
        },
      ],
    },

    {
      layout: 'accordion',
      chapter: 1,
      accId: 'acc-4',
      overview: {
        kicker: '4-4 · 浏览器操作与电脑操作',
        title: '浏览器操作<br>与<span class="accent">电脑</span>操作',
        lead: 'AI 开始能操作界面,但都还在早期。',
        note: '<span style="color:var(--accent); font-weight:600;">→ 点击此处展开案例</span>',
      },
      cases: [
        {
          tag: 'Case 01',
          title: '操作浏览器',
          summary: 'Browser Use',
          detail: {
            title: '操作<span class="accent">浏览器</span>',
            paragraphs: [
              '<a href="https://github.com/iFurySt/open-browser-use" target="_blank" class="detail-link">Codex 浏览器拆解 · open-browser-use →</a>',
            ],
            aside: [
              { kicker: '类型', text: '<strong>Browser Use</strong> 开源拆解' },
            ],
          },
        },
        {
          tag: 'Case 02',
          title: '操作电脑',
          summary: 'Computer Use',
          detail: {
            title: '操作<span class="accent">电脑</span>',
            paragraphs: [
              '<span style="font-weight:600;">perplexity / sonar-pro-search</span>',
              '<a href="https://github.com/iFurySt/open-codex-computer-use" target="_blank" class="detail-link">Codex Computer Use 拆解 · open-codex-computer-use →</a>',
            ],
            aside: [
              { kicker: '类型', text: '<strong>Computer Use</strong> 开源拆解' },
            ],
          },
        },
      ],
    },

    {
      layout: 'anchor',
      chapter: 2,
      kicker: 'Part 02 · OpenClaw',
      headline: '自己养一个<br><span class="accent">Agent</span>',
      subhead: '<strong>Agent 不只是会聊天,而是能稳定接任务、执行任务、反馈状态。</strong><br><span style="color: var(--muted);">长期运行的 Agent,需要边界、维护和监控。</span>',
      modules: [
        { num: '01', name: '运维脚本', tag: '自动化入口' },
        { num: '02', name: '角色扮演', tag: 'RP 实验场' },
        { num: '04', name: '信息收集', tag: '邮件 · 播客 · 摘要' },
        { num: '03', name: '信息输出', tag: 'HTML · 卡片 · 图' },
      ],
    },

    {
      layout: 'accordion',
      chapter: 2,
      classes: ['ov-bottom'],
      accId: 'acc-5',
      overview: {
        kicker: '6-1 · OpenClaw · 实验矩阵',
        title: '功能到<span class="accent">产品</span><br>很远',
        lead: 'AI 能力越强,人越需要做产品判断。',
        note: '图像生成有能力,不等于朋友圈场景、姿势、穿搭、多样性都自动解决。<br><br><span style="color:var(--accent); font-weight:600;">→ 点击此处展开案例</span>',
      },
      cases: [
        {
          tag: 'Exp 01',
          title: '祖师对话',
          summary: 'RP 模拟历史人物',
          detail: {
            title: '佛教<span class="accent">祖师</span>对话',
            paragraphs: [
              '<span class="detail-big">通过聊天框和祖师对话</span>',
              '<span class="detail-mid">比直接进入网页更有<strong>和人对话的感觉</strong></span>',
              '<span class="detail-mid"><strong class="accent-text">14 位祖师</strong> — 慧能 · 玄奘 · 宗喀巴 · 米拉日巴 · 阿姜查 …<br>覆盖汉传 / 藏传 / 南传三大传统</span>',
              '<span class="detail-mid"><strong class="accent-text">503 数据源</strong> · 67.8 万+ 语义向量<br>每条回答必须引用可查证经典,杜绝"编经"</span>',
            ],
            aside: [
              { kicker: '体验', text: '<a href="https://fojin.app/chat" target="_blank" style="color:var(--accent);font-weight:600;">fojin.app/chat →</a>' },
              { kicker: '仓库', text: '<a href="https://github.com/xr843/Master-skill" target="_blank" style="color:var(--accent);font-weight:600;">Master-skill (GitHub) →</a>' },
              { kicker: '接入', text: 'Claude Code / Cursor / Gemini CLI,一条命令安装' },
            ],
          },
        },
        {
          tag: 'Exp 02',
          title: '睡前故事',
          summary: '内容生成',
          detail: {

          },
        },
        {
          tag: 'Exp 03',
          title: '塔罗牌',
          summary: '个性化报告',
          detail: {

          },
        },
        {
          tag: 'Exp 04',
          title: 'AI 小说',
          summary: '长篇连载',
          detail: {

          },
        },
        {
          tag: 'Exp 05',
          title: '自拍',
          summary: '角色自拍图 + 文案',
          detail: {
            title: '角色<span class="accent">朋友圈</span>',
            paragraphs: [
              '生成虚拟角色自拍图和文案,模拟真实社交动态。',
            ],
            aside: [
              { kicker: '暴露', text: '姿势 · 穿搭 · 多样性<strong>全是坑</strong>。' },
            ],
          },
        },
      ],
    },

    {
      layout: 'accordion',
      chapter: 2,
      classes: ['ov-bottom'],
      accId: 'acc-7',
      overview: {
        kicker: '6-3 · 邮件系统 · 信息入口',
        title: '重要的不是<br><span class="accent">全都接收</span>',
        lead: '让 AI 负责信息收集整理,但不要把所有信息直接倒给人。',
        note: '每个人产生的信息越来越多,重要的不是"全都接收",而是"<strong>哪些值得看</strong>"。<br>筛选能力应该放在上层,而不是把原始信息直接发给上层。<br><br><span style="color:var(--accent); font-weight:600;">→ 点击此处展开案例</span>',
      },
      cases: [
        {
          tag: 'System 01',
          title: '邮件系统',
          summary: '信息入口 + 筛选',
          detail: {
            title: '<span class="accent">邮件</span>系统',
            paragraphs: [
              '<span class="detail-mid">通过 Postfix 接收邮件,使用 <strong>Pipe to Script</strong> 模式</span>',
              '<span class="detail-mid">在 <code class="inline-code">master.cf</code> 注册 Python 脚本,将邮件 JSON 化便于读取</span>',
              '<span class="detail-mid">在 <code class="inline-code">/etc/postfix/transport</code> 注册命中规则,让域名下的邮件都走脚本处理</span>',
              '<span class="detail-mid">收集各渠道推送 → AI 筛选优先级 → 输出可追溯的<strong>结构化摘要</strong></span>',
            ],
            aside: [
              { kicker: '核心', text: '不是"全都接收",而是<strong>哪些值得看</strong>' },
              { kicker: '可追溯', text: '摘要可回溯原文,不丢信息' },
            ],
          },
        },
        {
          tag: 'System 02',
          title: 'RSS',
          summary: '可控的信息源',
          detail: {
            title: '<span class="accent">RSS</span><br>可控的信息入口',
            paragraphs: [
              '信息源越来越分散 —— 公众号、X、Newsletter、Slack、各种群。需要一个<strong>可控、可订阅、可整理</strong>的入口。',
              'RSS 是经过时间验证的答案。<span class="accent-text">订阅制 + AI 摘要</span>,把信息消费从被动推送变回主动选择。',
            ],
            aside: [
              { kicker: '核心', text: '<strong>可控 · 可订阅 · 可整理</strong>。' },
              { kicker: '关键', text: '从被动推送变回主动选择。' },
            ],
          },
        },
      ],
    },

    {
      layout: 'accordion',
      chapter: 2,
      classes: ['ov-bottom'],
      accId: 'acc-6',
      overview: {
        kicker: '6-2 · 卡片输出 Skill',
        title: '展示形式的<br>成本正在<span class="accent">降低</span>',
        lead: '从卡片到 HTML、从 Markdown 到 HTML,更直观的表达方式越来越便宜。',
        note: '当更好理解的展示形式成本越来越低,人就应该更多使用这种表达。<br>人和 AI 之间的交互不应该永远停留在纯聊天框里。<br><br><a href="https://x.com/trq212/status/2052809885763747935" target="_blank" style="color:var(--accent); font-weight:500; font-size:0.78cqw;">→ 参考原文</a><br><br><span style="color:var(--accent); font-weight:600;">→ 点击此处展开案例</span>',
      },
      cases: [
        {
          tag: 'Skill 01',
          title: '卡片总结',
          summary: 'HTML 卡片',
          detail: {
            title: '<span class="accent">卡片</span>总结',
            paragraphs: [
              '<img src="./part2/卡片总结.webp" class="detail-media">',
            ],
            aside: [
              { kicker: '关键', text: '不只是"好看",而是<strong>更容易理解</strong>' },
              { kicker: '趋势', text: '生成成本越来越低,应该更多使用' },
            ],
          },
        },
        {
          tag: 'Skill 02',
          title: '音频早报',
          summary: '播客输出',
          detail: {
            title: '音频<span class="accent">早报</span>',
            paragraphs: [
              '<img src="./part2/早报.jpg" class="detail-media">',
            ],
            aside: [
              { kicker: '适合', text: '通勤、碎片时间、<strong>被动消费</strong>场景' },
              { kicker: '关键', text: '内容不变,展示形式匹配场景' },
            ],
          },
        },
        {
          tag: 'Skill 03',
          title: 'MCP 渲染',
          summary: '可交互输出',
          detail: {
            title: 'MCP <span class="accent">可视化</span>渲染',
            paragraphs: [
              '<div class="detail-media-row"><img src="./part2/可视化1.png" class="detail-media"><img src="./part2/可视化2.png" class="detail-media"></div>',
            ],
            aside: [
              { kicker: '本质', text: 'AI 输出从<strong>文本</strong>变为<strong>界面</strong>' },
              { kicker: '方向', text: '交互形式还差一点东西,值得探索' },
            ],
          },
        },
      ],
    },



    {
      layout: 'anchor',
      chapter: 3,
      kicker: 'Part 03 · Harness Engineering',
      headline: '用工程化思维<br>驯服 <span class="accent">AI</span>',
      subhead: '<strong>Harness 围绕的是如何让一个项目引入了 AI 仍然能够长期开发维护。</strong><br><span style="color: var(--muted);">怎样设计一个让 Agent 可以稳定执行、可以被纠错、可以被验证的工作环境?</span>',
      modules: [
        { num: '01', name: '定义方式', tag: '是什么 · 4步阶梯' },
        { num: '02', name: '如何实现', tag: '反盘问 · 意图确认' },
        { num: '03', name: '中途接入', tag: 'CLAUDE.md · Git · Hook' },
        { num: '04', name: '协作编排', tag: '多 Agent · 协作编排' },
      ],
    },

    {
      layout: 'steps',
      chapter: 3,
      kicker: '7-1 · Harness 是什么 · 阶梯演进',
      headline: '用<span class="accent">文档</span><br>驯服代码',
      quote: '技术代码是可抛弃的、可重构的。<br><strong>文档规范是固定的唯一事实依据。</strong>',
      steps: [
        { num: '01', title: '需求文档', desc: 'PRD · 目标与边界的源头' },
        { num: '02', title: '架构与系统地图', desc: '<strong>ARCHITECTURE.md</strong> 提供域与包分层的顶层地图,配合 <code class="inline-code">FRONTEND.md</code> / <code class="inline-code">RELIABILITY.md</code> / <code class="inline-code">SECURITY.md</code> / <code class="inline-code">DESIGN.md</code> 等参考文件,让 Agent 直接从代码库推理业务领域' },
        { num: '03', title: '计划与进度日志', desc: '更加具体的执行计划与状态记录,沉淀决策过程' },
        { num: '04', title: '边界与约束', desc: '机械化边界 — <strong>自定义工具与 Linter</strong>;配合<strong>质量追踪与技术债务清单</strong>' },
        { num: '05', title: '精简的 AGENTS.md', desc: '不要塞厚重说明书 — 过多指导让 Agent 针对错误约束优化。提供约 <strong>100 行的 AGENTS.md 作为内容目录和地图</strong>,指引去哪里寻找更深的信息' },
      ],
    },

    {
      layout: 'accordion',
      chapter: 3,
      accId: 'acc-8',
      // 封面原为 star-history 在线图表(superpowers/trellis/OpenSpec/skills 四仓库 star 增长),
      // 该 API 已因 GitHub 限制 star 数据访问而失效,wiki 发布版移除封面、直接展开案例卡片
      overview: {
        kicker: '7-2 · 如何 Harness · 实践工具',
        title: '不是让 AI<br><span class="accent">听话</span>',
        lead: '而是建立一套让 AI 高效执行的协作环境。',
        note: 'Grill-me 前置确认意图,Skills 规范执行边界,Git 追溯变更。<br><br><span style="color:var(--accent); font-weight:600;">→ 点击此处展开案例</span>',
      },
      cases: [
        {
          tag: 'Tool 01',
          title: '头脑风暴与烤打',
          summary: 'grill-me · 让 AI 给自己找茬',
          detail: {
            title: '<span class="accent">Grill-me</span> · 烤打我',
            paragraphs: [
              '<span class="detail-mid">是我用起来<strong>最爽</strong>的一个 skill — 它就是一段话,让 AI 来<strong class="accent-text">给自己找茬</strong>,提出一些可以预见的问题。</span>',
              '<blockquote class="detail-quote">Interview me relentlessly about every aspect of this plan until we reach a shared understanding. Walk down each branch of the design tree, resolving dependencies between decisions one-by-one. For each question, provide your recommended answer.<br><br>Ask the questions one at a time.<br><br>If a question can be answered by exploring the codebase, explore the codebase instead.</blockquote>',
              '<span class="detail-mid">当你提了一个需求,它会围绕这个需求<strong>不断提出问题</strong>来确认意图,更重要的是提出很多可能会遇到的问题。</span>',
              '<span class="detail-mid">这样就预先把很多<span class="accent-text">需要返工、或者开发一半再来问你的问题集中起来</span>,极大避免人必须守在窗口走走停停 — 你的思路也不会被绑架在某个问题上,思考会非常集中。</span>',
              '<span class="detail-mid">不管是<strong>大需求</strong>还是<strong>小 demo</strong> 都非常有效。</span>',
            ],
            aside: [
              { kicker: '对比', text: '原来我用 <strong>Plan Mode</strong> 有点像 — 也是和你确认是不是该这么做,但 Plan Mode 还是<strong>太顺从人</strong>了。' },
              { kicker: '核心', text: '让 AI <strong>给自己找茬</strong>,问题前置。' },
              { kicker: '适合', text: '大需求、小 demo 都有效。' },
            ],
          },
        },
        {
          tag: 'Tool 02',
          title: 'superpowers',
          summary: '',
          detail: {},
        },
        {
          tag: 'Tool 03',
          title: 'trellis',
          summary: '',
          detail: {},
        },
      ],
    },

    {
      layout: 'left',
      chapter: 4,
      kicker: 'Part 04 · 做一个项目吧',
      headline: '做完一次完整项目<br><span class="accent">视角才完整</span>',
      subhead: '<strong>GAL-Git</strong>|Galgame 叙事 + 多 NPC AI + 云容器<br>把 Git 教学做成职场剧本',
      flow: '立项 → 架构 → 玩法剧情 → UI / 美术',
      bodyNote: '每一步都做着才发现需要什么',
      stats: [
        { num: '2W', note: '行代码' },
        { num: '1+', note: '月业余时间' },
        { num: '已开源', note: 'GitHub' },
      ],
    },

    {
      layout: 'accordion',
      chapter: 4,
      classes: ['acc-reversed'],
      accId: 'acc-galgit',
      coverImg: './part4/gal-git1.jpg',
      overview: {
        kicker: 'Part 04 · GAL-Git 实机展示',
        title: 'Galgame × <span class="accent">Git</span> 教学',
        lead: '把枯燥的 Git 命令包进职场剧本,边玩边学。',
        note: '<span style="color:var(--accent); font-weight:600;">→ 点击展开查看</span>',
      },
      cases: [
        {
          tag: '截图',
          title: '界面一览',
          summary: '5 张核心界面截图',
          detail: {
            title: '界面<span class="accent">一览</span>',
            paragraphs: [
              '<div class="mini-slideshow" data-count="5">'
              + '<div class="ms-track">'
              + '<img src="./part4/gal-git1.jpg" class="ms-slide ms-active" alt="">'
              + '<img src="./part4/gal-git2.jpg" class="ms-slide" alt="">'
              + '<img src="./part4/gal-git3.jpg" class="ms-slide" alt="">'
              + '<img src="./part4/gal-git4.jpg" class="ms-slide" alt="">'
              + '<img src="./part4/gal-git5.jpg" class="ms-slide" alt="">'
              + '</div>'
              + '<div class="ms-nav">'
              + '<button class="ms-prev">‹</button>'
              + '<span class="ms-counter">1 / 5</span>'
              + '<button class="ms-next">›</button>'
              + '</div>'
              + '</div>',
            ],
          },
        },
        {
          tag: '录屏',
          title: '演示视频',
          summary: '完整游玩流程录屏',
          detail: {
            title: '演示<span class="accent">视频</span>',
            paragraphs: [
              '<video src="./part4/录屏2026-05-20 17.19.22.mp4" controls playsinline class="detail-media"></video>',
            ],
          },
        },
        {
          tag: 'LIVE',
          title: '在线试玩',
          summary: '嵌入在线 Demo',
          detail: {
            title: '在线<span class="accent">试玩</span>',
            paragraphs: [
              // 原为公司内网 demo 的 iframe,公开发布版以说明文字替代
              '<span class="detail-mid">在线 Demo 当时部署在公司内网,未随本稿公开发布 —— 完整游玩流程见上一张卡片的<strong>演示录屏</strong>。</span>',
            ],
          },
        },
        {
          tag: '副产品',
          title: 'Lovave',
          summary: '音频版 Lovart · 即听即改',
          detail: {
            title: '音频版 <span class="accent">Lovart</span>',
            paragraphs: [
              '<iframe src="https://lovave.liuhetian.work/#proj=test1" class="detail-iframe" allow="clipboard-write; microphone"></iframe>',
            ],
            aside: [
              { kicker: '灵感', text: '开发中发现的<strong>副产品</strong>,即听即改' },
            ],
          },
        },
      ],
    },

    {
      layout: 'hero',
      chapter: 5,
      classes: ['scene-closing'],
      headline: '感谢<span class="accent">聆听</span>',
    },
  ],
};
layouts.js —— 8 种版式的渲染函数
window.Layouts = {

  blank() {
    return '';
  },

  hero(s) {
    let h = '<div class="col-text">';
    if (s.kicker) h += `<div class="kicker">${s.kicker}</div>`;
    if (s.headlineMid) h += `<p class="headline-mid">${s.headlineMid}</p>`;
    if (s.headline) h += `<h1 class="headline">${s.headline}</h1>`;
    if (s.subhead) h += `<p class="subhead">${s.subhead}</p>`;
    h += '</div>';
    return h;
  },

  left(s) {
    let h = '<div class="col-text">';
    if (s.kicker) h += `<div class="kicker">${s.kicker}</div>`;
    if (s.headline) h += `<h1 class="headline">${s.headline}</h1>`;
    if (s.subhead) h += `<p class="subhead">${s.subhead}</p>`;
    if (s.flow) h += `<div class="kicker flow">${s.flow}</div>`;
    if (s.bodyNote) h += `<p class="body-note">${s.bodyNote}</p>`;
    h += '</div>';
    if (s.stats) {
      h += '<div class="col-stats">';
      s.stats.forEach(st => {
        h += '<div class="stat">';
        h += `<div class="num">${st.num}</div>`;
        h += `<div class="note">${st.note}</div>`;
        h += '</div>';
      });
      h += '</div>';
    }
    return h;
  },

  dual(s) {
    let h = '';
    if (s.kicker) h += `<div class="dual-kicker"><div class="kicker">${s.kicker}</div></div>`;
    h += '<div class="dual-cols">';
    s.columns.forEach(col => {
      h += '<div class="dual-col">';
      h += `<div class="dual-col-title">${col.title}</div>`;
      h += `<div class="dual-col-body">${col.body}</div>`;
      h += '</div>';
    });
    h += '</div>';
    if (s.footer || s.footerNote) {
      h += '<div class="dual-footer">';
      if (s.footer) h += `<p class="dual-footer-main">${s.footer}</p>`;
      if (s.footerNote) h += `<p class="dual-footer-note">${s.footerNote}</p>`;
      h += '</div>';
    }
    return h;
  },

  anchor(s) {
    let h = '<div class="col-text">';
    if (s.kicker) h += `<div class="kicker">${s.kicker}</div>`;
    if (s.headline) h += `<h1 class="headline">${s.headline}</h1>`;
    if (s.subhead) h += `<p class="subhead">${s.subhead}</p>`;
    h += '</div>';

    if (s.modules) {
      h += '<div class="col-stats"><div class="chapter-modules">';
      s.modules.forEach(m => {
        h += '<div class="mod">';
        h += `<div class="mod-num">${m.num}</div>`;
        h += `<div class="mod-name">${m.name}</div>`;
        h += `<div class="mod-tag">${m.tag}</div>`;
        h += '</div>';
      });
      h += '</div></div>';
    }
    return h;
  },

  steps(s) {
    let h = '<div class="col-steps">';
    if (s.kicker) h += `<div class="kicker">${s.kicker}</div>`;
    h += '<div class="steps-list">';
    s.steps.forEach(st => {
      h += '<div class="step">';
      h += `<div class="step-num">${st.num}</div>`;
      h += '<div class="step-body">';
      h += `<div class="step-title">${st.title}</div>`;
      h += `<div class="step-desc">${st.desc}</div>`;
      h += '</div></div>';
    });
    h += '</div></div>';

    h += '<div class="col-text">';
    if (s.headline) h += `<h1 class="headline">${s.headline}</h1>`;
    if (s.quote) h += `<blockquote class="steps-quote">${s.quote}</blockquote>`;
    if (s.embed) h += `<div class="steps-embed">${s.embed}</div>`;
    h += '</div>';

    return h;
  },

  accordion(s) {
    const id = s.accId || 'acc-' + s._idx;
    const ov = s.overview;
    let h = `<div class="acc" id="${id}">`;

    h += '<div class="acc-overview">';
    h += `<div class="ov-kicker">${ov.kicker}</div>`;
    h += `<h2 class="ov-title">${ov.title}</h2>`;
    h += `<p class="ov-lead">${ov.lead}</p>`;
    h += `<p class="ov-note">${ov.note}</p>`;
    h += '</div>';

    h += '<div class="acc-cases-wrap">';
    if (s.coverImg) {
      h += `<div class="acc-cover">`;
      h += `<img src="${s.coverImg}" alt="">`;
      h += '<div class="acc-cover-hint">点击查看详情 →</div>';
      h += '</div>';
    }
    h += `<div class="acc-cases${s.coverImg ? ' acc-cases-hidden' : ''}">`;
    s.cases.forEach((c, ci) => {
      const isEmpty = !c.detail || (!c.detail.title && (!c.detail.paragraphs || !c.detail.paragraphs.length) && (!c.detail.aside || !c.detail.aside.length));
      h += `<div class="acc-case${isEmpty ? ' acc-case-empty' : ''}" data-case="${ci + 1}">`;

      h += '<div class="case-collapsed">';
      h += `<div class="case-tag">${c.tag}</div>`;
      h += `<div class="case-title">${c.title}</div>`;
      h += `<div class="case-summary">${c.summary}</div>`;
      h += '<span class="case-arrow">→</span>';
      h += '</div>';

      const det = c.detail || {};
      h += '<div class="case-detail">';
      const hasToggle = det.codeFile || det.reveal;
      h += '<div class="detail-head">';
      if (hasToggle) {
        h += `<div class="detail-title title-toggle" data-toggle="${det.codeFile ? 'code' : 'reveal'}">${det.title || c.title || ''}</div>`;
      } else {
        h += `<div class="detail-title">${det.title || c.title || ''}</div>`;
      }
      h += '</div>';
      h += '<div class="detail-body">';
      h += '<div class="detail-text">';
      (det.paragraphs || []).forEach(p => { h += `<p>${p}</p>`; });
      h += '</div>';
      if (det.codeFile) {
        h += '<div class="detail-code-col">';
        h += `<div class="detail-code-label">${det.codeFile.split('/').pop()}</div>`;
        h += `<pre class="detail-code-block" data-code-src="${det.codeFile}"></pre>`;
        h += '</div>';
      }
      if (det.aside && det.aside.length) {
        h += '<div class="detail-aside">';
        det.aside.forEach(a => {
          h += '<div class="aside-row">';
          h += `<div class="aside-kicker">${a.kicker}</div>`;
          h += `<div>${a.text}</div>`;
          h += '</div>';
        });
        h += '</div>';
      }
      h += '</div></div>';

      h += '</div>';
    });
    h += '</div></div></div>';

    return h;
  },

  buildDeck(content, stageInner) {
    let html = '';

    // bg-layer
    html += '<div class="bg-layer">';
    content.media.forEach(m => {
      if (m.type === 'image') {
        html += `<img id="${m.id}" src="${m.src}" alt="">`;
      } else {
        const attrs = 'muted playsinline preload="auto"' + (m.loop ? ' loop' : '');
        html += `<video id="${m.id}" src="${m.src}" ${attrs}></video>`;
      }
    });
    html += '</div>';

    // nav
    html += '<nav class="nav">';
    html += `<div class="nav-logo">${content.meta.logo}</div>`;
    html += '<div class="nav-menu">';
    content.nav.forEach(n => {
      html += `<a href="#" data-jump="${n.jump}">${n.label}</a>`;
    });
    html += '</div>';
    html += '<div class="nav-right">';
    html += `<span class="nav-link">${content.meta.version} \xb7 ${content.meta.date}</span>`;
    html += '</div></nav>';

    // scenes
    html += '<div class="scenes">';
    content.scenes.forEach((s, i) => {
      const extra = (s.classes || []).map(c => ' ' + c).join('');
      html += `<section class="scene layout-${s.layout}${extra}" data-scene="${i}" data-chapter="${s.chapter}">`;
      s._idx = i;
      const renderer = Layouts[s.layout];
      if (renderer) html += renderer(s);
      html += '</section>';
    });
    html += '</div>';

    // bottom strip
    html += '<div class="bottom-strip-anchor">';
    html += '<div class="chapter-row" id="chapterRow">';
    content.chapters.forEach((ch, i) => {
      html += `<span class="ch" data-jump="${i}"><span class="mark ${ch.mark}"></span>${ch.label}</span>`;
    });
    html += '</div>';
    html += `<div class="footer-meta" id="footerMeta">01 / ${String(content.scenes.length).padStart(2, '0')} \xb7 ${content.meta.date}</div>`;
    html += '</div>';

    // scroll hint + wait corner
    html += '<div class="scroll-hint" id="scrollHint">Scroll <span class="bar"></span></div>';
    html += '<div class="wait-corner">入场中 \xb7 调试投屏 \xb7 Scroll to begin</div>';

    stageInner.innerHTML = html;
  },
};
deck.js —— 滚动映射、视频 seek、手风琴与画廊交互
(() => {
  const content = window.CONTENT;
  const stageInner = document.getElementById('stageInner');

  Layouts.buildDeck(content, stageInner);

  const SCENE_COUNT = content.scenes.length;
  const imgs = content.media.filter(m => m.id.startsWith('s')).map(m => document.getElementById(m.id));
  const vidMeta = content.media.filter(m => m.id.startsWith('v'));
  const vids = vidMeta.map(m => document.getElementById(m.id));
  const transToVidIdx = new Array(SCENE_COUNT - 1).fill(-1);
  const vidToTransIdx = [];
  vidMeta.forEach((m, vi) => {
    const match = m.id.match(/^v(\d+)_/);
    if (match) {
      const ti = parseInt(match[1], 10);
      if (ti < SCENE_COUNT - 1) transToVidIdx[ti] = vi;
      vidToTransIdx[vi] = ti;
    }
  });
  const scenes = document.querySelectorAll('.scene');
  const hint = document.getElementById('scrollHint');
  const footerMeta = document.getElementById('footerMeta');
  const chapterRow = document.getElementById('chapterRow');
  const navLinks = document.querySelectorAll('.nav-menu a[data-jump]');

  const sceneToChapter = content.scenes.map(s => s.chapter);

  // ===== scroll mapping =====
  const SCENE_WT = 1;
  const TRANS_WT_VIDEO = 5;
  const TRANS_WT_NONE = 0.3;
  const rawSegs = [];
  for (let i = 0; i < SCENE_COUNT; i++) {
    rawSegs.push({ kind: 'scene', idx: i, w: SCENE_WT });
    if (i < SCENE_COUNT - 1) {
      rawSegs.push({ kind: 'trans', idx: i, w: transToVidIdx[i] >= 0 ? TRANS_WT_VIDEO : TRANS_WT_NONE });
    }
  }
  const totalW = rawSegs.reduce((s, r) => s + r.w, 0);
  const segments = [];
  let cursor = 0;
  rawSegs.forEach(r => {
    const span = r.w / totalW;
    segments.push({ start: cursor, end: cursor + span, kind: r.kind, idx: r.idx });
    cursor += span;
  });
  segments[segments.length - 1].end = 1.0;

  // looping background videos
  const loopBgs = content.media
    .filter(m => m.loop)
    .map(m => document.getElementById(m.id))
    .filter(el => el && el.tagName === 'VIDEO');
  loopBgs.forEach(v => v.play().catch(() => {}));

  const videoReady = new Array(vids.length).fill(false);
  const targetT = new Array(vids.length).fill(0);
  vids.forEach((v, i) => {
    v.addEventListener('loadedmetadata', () => { videoReady[i] = true; onScroll(); });
  });

  const lastOpImg = new Array(imgs.length).fill(-1);
  const lastOpVid = new Array(vids.length).fill(-1);
  let lastActive = -1;

  function setImgOp(i, v) {
    const r = Math.round(v * 1000) / 1000;
    if (lastOpImg[i] !== r) { imgs[i].style.opacity = r; lastOpImg[i] = r; }
    if (loopBgs.includes(imgs[i]) && imgs[i].paused && r > 0) imgs[i].play().catch(() => {});
  }
  function setVidOp(i, v) {
    const r = Math.round(v * 1000) / 1000;
    if (lastOpVid[i] !== r) { vids[i].style.opacity = r; lastOpVid[i] = r; }
  }

  function onScroll() {
    const max = document.body.scrollHeight - window.innerHeight;
    const p = max > 0 ? Math.max(0, Math.min(1, window.scrollY / max)) : 0;

    const oi = new Array(imgs.length).fill(0);
    const ov = new Array(vids.length).fill(0);

    let seg = segments[segments.length - 1];
    for (let i = 0; i < segments.length; i++) {
      if (p < segments[i].end) { seg = segments[i]; break; }
    }

    let active;
    if (seg.kind === 'scene') {
      oi[seg.idx] = 1;
      active = seg.idx;
    } else {
      const t = (p - seg.start) / (seg.end - seg.start);
      const si = seg.idx;
      const vi = transToVidIdx[si];
      if (vi >= 0 && videoReady[vi]) {
        ov[vi] = 1;
        targetT[vi] = t * vids[vi].duration;
      } else {
        oi[si] = 1 - t;
        oi[si + 1] = t;
      }
      active = (t < 0.5) ? si : (si + 1);
    }

    for (let i = 0; i < imgs.length; i++) setImgOp(i, oi[i]);
    for (let i = 0; i < vids.length; i++) setVidOp(i, ov[i]);

    if (active !== lastActive) {
      scenes.forEach(sc => sc.classList.toggle('active', +sc.dataset.scene === active));
      footerMeta.textContent = String(active + 1).padStart(2, '0') + ' / ' + String(SCENE_COUNT).padStart(2, '0') + ' \xb7 ' + content.meta.date;
      const chapIdx = sceneToChapter[active];
      chapterRow.querySelectorAll('.ch').forEach((el, i) => el.classList.toggle('active', i === chapIdx));
      navLinks.forEach(a => a.classList.toggle('active', sceneToChapter[+a.dataset.jump] === chapIdx));
      stageInner.dataset.active = String(active);

      if (lastActive !== -1) {
        const prevScene = content.scenes[lastActive];
        if (prevScene && prevScene.layout === 'accordion') {
          closeAllAccordions();
          resetAccordionCovers();
        }
      }

      lastActive = active;
    }

    hint.style.opacity = p > 0.015 ? 0 : 1;
  }

  function tick() {
    for (let i = 0; i < vids.length; i++) {
      if (!videoReady[i]) continue;
      const v = vids[i];
      if (Math.abs(targetT[i] - v.currentTime) > 0.005) v.currentTime = targetT[i];
    }
    requestAnimationFrame(tick);
  }

  window.addEventListener('scroll', onScroll, { passive: true });
  window.addEventListener('resize', onScroll);
  window.addEventListener('load', onScroll);

  // ===== animated scroll =====
  const FALLBACK_MS = 500, MIN_MS = 250, MAX_MS = 8000;
  function pathTransitionMs(p0, p1) {
    const a = Math.min(p0, p1), b = Math.max(p0, p1);
    let total = 0, hasVideo = false;
    for (let i = 0; i < vids.length; i++) {
      if (!videoReady[i]) continue;
      const ti = vidToTransIdx[i];
      const seg = segments.find(s => s.kind === 'trans' && s.idx === ti);
      if (!seg) continue;
      const overlap = Math.max(0, Math.min(b, seg.end) - Math.max(a, seg.start));
      const span = seg.end - seg.start;
      if (overlap > 0 && span > 0) {
        total += (overlap / span) * vids[i].duration * 1000;
        hasVideo = true;
      }
    }
    if (!hasVideo) return FALLBACK_MS;
    return Math.max(MIN_MS, Math.min(MAX_MS, total));
  }

  let scrollAnim = null;
  function scrollToP(targetP, duration) {
    const max = document.body.scrollHeight - window.innerHeight;
    const target = targetP * max;
    const start = window.scrollY;
    const delta = target - start;
    if (Math.abs(delta) < 1) return;
    if (duration === undefined) duration = pathTransitionMs(start / max, targetP);
    const t0 = performance.now();
    if (scrollAnim) cancelAnimationFrame(scrollAnim);
    function step(now) {
      const t = Math.min(1, (now - t0) / duration);
      window.scrollTo(0, start + delta * t);
      if (t < 1) scrollAnim = requestAnimationFrame(step);
      else scrollAnim = null;
    }
    scrollAnim = requestAnimationFrame(step);
  }

  function gotoScene(idx) {
    idx = Math.max(0, Math.min(SCENE_COUNT - 1, idx));
    const seg = segments.find(s => s.kind === 'scene' && s.idx === idx);
    scrollToP(seg.start + (seg.end - seg.start) * 0.5);
  }
  function nextScene() { gotoScene((lastActive < 0 ? 0 : lastActive) + 1); }
  function prevScene() { gotoScene((lastActive < 0 ? 0 : lastActive) - 1); }

  // wire data-jump
  document.querySelectorAll('[data-jump]').forEach(el => {
    el.addEventListener('click', (e) => {
      e.preventDefault();
      gotoScene(+el.dataset.jump);
    });
  });

  // ===== accordion covers =====
  document.querySelectorAll('.acc-cover').forEach(cover => {
    cover.addEventListener('click', () => {
      const cases = cover.parentElement.querySelector('.acc-cases');
      cover.classList.add('hidden');
      if (cases) cases.classList.add('acc-cases-visible');
    });
  });

  function resetAccordionCovers() {
    document.querySelectorAll('.acc-cover').forEach(cover => {
      cover.classList.remove('hidden');
      const cases = cover.parentElement.querySelector('.acc-cases');
      if (cases) cases.classList.remove('acc-cases-visible');
    });
  }

  // ===== accordion =====
  const allAccs = document.querySelectorAll('.acc');

  function closeAllAccordions() {
    allAccs.forEach(acc => {
      acc.classList.remove('has-expanded');
      acc.querySelectorAll('.acc-case.expanded').forEach(c => c.classList.remove('expanded'));
    });
  }

  function loadCodeBlocks(container) {
    container.querySelectorAll('.detail-code-block[data-code-src]').forEach(pre => {
      if (pre.dataset.loaded) return;
      pre.dataset.loaded = '1';
      fetch(pre.dataset.codeSrc)
        .then(r => r.ok ? r.text() : Promise.reject())
        .then(code => {
          pre.textContent = code;
        })
        .catch(() => {
          pre.textContent = '// 无法加载文件';
        });
    });
  }

  document.addEventListener('wheel', (e) => {
    const block = e.target.closest('.detail-code-block');
    if (!block) return;
    const atTop = block.scrollTop <= 0 && e.deltaY < 0;
    const atBottom = block.scrollTop + block.clientHeight >= block.scrollHeight - 1 && e.deltaY > 0;
    if (!atTop && !atBottom) {
      e.preventDefault();
      e.stopPropagation();
      block.scrollTop += e.deltaY;
    }
  }, { passive: false, capture: true });

  document.querySelectorAll('.title-toggle').forEach(title => {
    title.addEventListener('click', (e) => {
      e.stopPropagation();
      const detail = title.closest('.case-detail');
      const mode = title.dataset.toggle;
      if (mode === 'code') {
        detail.classList.toggle('show-code');
        if (detail.classList.contains('show-code')) loadCodeBlocks(detail);
      } else if (mode === 'reveal') {
        detail.querySelectorAll('.reveal-wrap').forEach(r => r.classList.add('revealed'));
      }
    });
  });

  allAccs.forEach(acc => {
    acc.querySelectorAll('.acc-case').forEach(card => {
      card.addEventListener('click', () => {
        if (card.classList.contains('acc-case-empty')) return;
        if (card.classList.contains('expanded')) {
          acc.classList.remove('has-expanded');
          acc.querySelectorAll('.acc-case.expanded').forEach(c => {
            c.classList.remove('expanded');
            const det = c.querySelector('.case-detail');
            if (det) det.classList.remove('show-code');
            c.querySelectorAll('.reveal-wrap.revealed').forEach(r => r.classList.remove('revealed'));
          });
          return;
        }
        acc.querySelectorAll('.acc-case').forEach(c => c.classList.remove('expanded'));
        card.classList.add('expanded');
        acc.classList.add('has-expanded');
      });
    });
  });

  // ===== mini slideshow =====
  document.querySelectorAll('.mini-slideshow').forEach(ss => {
    const slides = ss.querySelectorAll('.ms-slide');
    const counter = ss.querySelector('.ms-counter');
    const total = slides.length;
    let cur = 0;
    const go = (idx) => {
      slides[cur].classList.remove('ms-active');
      cur = (idx + total) % total;
      slides[cur].classList.add('ms-active');
      if (counter) counter.textContent = `${cur + 1} / ${total}`;
    };
    ss.querySelector('.ms-prev')?.addEventListener('click', (e) => { e.stopPropagation(); go(cur - 1); });
    ss.querySelector('.ms-next')?.addEventListener('click', (e) => { e.stopPropagation(); go(cur + 1); });
  });

  // ===== keyboard =====
  window.addEventListener('keydown', (e) => {
    if (e.target.matches && e.target.matches('input,textarea,[contenteditable]')) return;
    if (e.key === 'Escape') {
      const anyExpanded = Array.from(allAccs).some(a => a.classList.contains('has-expanded'));
      if (anyExpanded) {
        e.preventDefault();
        allAccs.forEach(acc => {
          acc.classList.remove('has-expanded');
          acc.querySelectorAll('.acc-case.expanded').forEach(c => c.classList.remove('expanded'));
        });
        return;
      }
    }
    switch (e.key) {
      case 'ArrowRight': case 'PageDown': case ' ':
      case 'n': case 'N':
        e.preventDefault(); nextScene(); break;
      case 'ArrowLeft': case 'PageUp':
      case 'p': case 'P':
        e.preventDefault(); prevScene(); break;
      case 'ArrowDown': e.preventDefault(); nextScene(); break;
      case 'ArrowUp':   e.preventDefault(); prevScene(); break;
      case 'Home':      e.preventDefault(); gotoScene(0); break;
      case 'End':       e.preventDefault(); gotoScene(SCENE_COUNT - 1); break;
      case 'f': case 'F':
        e.preventDefault();
        if (!document.fullscreenElement) document.documentElement.requestFullscreen?.();
        else document.exitFullscreen?.();
        break;
      default:
        if (/^[0-9]$/.test(e.key)) {
          e.preventDefault();
          const n = parseInt(e.key, 10);
          if (n >= 1 && n <= SCENE_COUNT) gotoScene(n - 1);
        }
    }
  });

  onScroll();
  requestAnimationFrame(tick);

  // ===== global timer =====
  const timerEl = document.getElementById('globalTimer');
  if (timerEl) {
    const t0 = Date.now();
    setInterval(() => {
      const s = Math.floor((Date.now() - t0) / 1000);
      const m = Math.floor(s / 60);
      const ss = s % 60;
      timerEl.textContent = String(m).padStart(2, '0') + ':' + String(ss).padStart(2, '0');
    }, 1000);
  }

  window.Deck = { gotoScene, nextScene, prevScene, closeAccordion: closeAllAccordions };
})();
style.css —— 视觉规范的落地(色彩 token / 字体 / 8 layout / letterbox)
* { margin: 0; padding: 0; box-sizing: border-box; }

:root {
  --ink: #0a0a0a;
  --paper: #e8e6e1;
  --letterbox: #000;
  --accent: #ff6a1a;
  --muted: rgba(10, 10, 10, 0.58);
  --hairline: rgba(10, 10, 10, 0.18);
  --ease: cubic-bezier(0.65, 0, 0.35, 1);
}

html, body {
  background: var(--letterbox);
  color: var(--ink);
  font-family: 'Inter', 'Noto Sans SC', -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif;
  -webkit-font-smoothing: antialiased;
  overflow-x: hidden;
}

.spacer { height: 1400vh; pointer-events: none; }

/* ============ 16:9 stage ============ */
.stage {
  position: fixed; inset: 0;
  display: flex; align-items: center; justify-content: center;
  background: var(--letterbox);
  z-index: 0;
}
.stage-inner {
  position: relative;
  aspect-ratio: 16 / 9;
  width: 100vw;
  max-width: calc(100vh * 16 / 9);
  max-height: 100vh;
  overflow: hidden;
  background: var(--paper);
  container-type: size;
  container-name: stage;
}

/* ============ background layers ============ */
.bg-layer { position: absolute; inset: 0; }
.bg-layer img,
.bg-layer video {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  object-fit: cover;
  opacity: 0;
  transition: opacity 0.32s ease;
  will-change: opacity;
  pointer-events: none;
}

/* ============ TOP NAV ============ */
.nav {
  position: absolute; top: 0; left: 0; right: 0;
  z-index: 30;
  display: flex; align-items: center; justify-content: space-between;
  padding: 2.6cqh 3cqw;
  color: var(--ink);
}
.nav-logo {
  font-family: 'Archivo Black', 'Noto Sans SC', sans-serif;
  font-size: 1.35cqw;
  letter-spacing: -0.025em;
  line-height: 1;
}
.nav-menu {
  display: flex; gap: 2.6cqw;
  font-size: 0.92cqw;
  font-weight: 500;
  color: var(--ink);
}
.nav-menu a { color: inherit; text-decoration: none; opacity: 0.85; transition: opacity 0.2s; }
.nav-menu a:hover, .nav-menu a.active { opacity: 1; }
.nav-right { display: flex; align-items: center; gap: 1.2cqw; }
.nav-link {
  font-size: 0.92cqw;
  font-weight: 500; color: var(--ink); text-decoration: none; opacity: 0.85;
}

/* ============ SCENE base ============ */
.scenes { position: absolute; inset: 0; z-index: 20; pointer-events: none; }
.scene {
  position: absolute; inset: 0;
  padding: 14cqh 3cqw 10cqh;
  display: grid;
  column-gap: 3cqw; row-gap: 3cqh;
  opacity: 0;
  transition: opacity 0.45s ease;
  pointer-events: none;
}
.scene.active { opacity: 1; pointer-events: auto; }

.scene .col-text {
  display: flex; flex-direction: column;
  max-width: 60cqw;
}
.scene .col-stats {
  display: flex; flex-direction: column; gap: 4cqh;
  max-width: 30cqw;
}

/* ===== layouts ===== */
.scene.layout-left {
  grid-template-columns: minmax(0, 1.15fr) minmax(0, 0.85fr);
  grid-template-rows: 1fr;
}
.scene.layout-left .col-text  { grid-column: 1; grid-row: 1; align-self: start; }
.scene.layout-left .col-stats { grid-column: 2; grid-row: 1; align-self: start; justify-self: end; text-align: right; }
.scene.layout-left .col-stats .stat .note { margin-left: auto; }

.scene.layout-right {
  grid-template-columns: minmax(0, 0.85fr) minmax(0, 1.15fr);
  grid-template-rows: 1fr;
}
.scene.layout-right .col-text  { grid-column: 2; grid-row: 1; align-self: start; justify-self: end; text-align: right; max-width: 56cqw; }
.scene.layout-right .col-text .headline { letter-spacing: -0.02em; }
.scene.layout-right .col-text .subhead { margin-left: auto; max-width: 42cqw; }
.scene.layout-right .col-text .body-note { margin-left: auto; max-width: 38cqw; }
.scene.layout-right .col-text .social { justify-content: flex-end; }
.scene.layout-right .col-stats { grid-column: 1; grid-row: 1; align-self: end; justify-self: start; }

.scene.layout-hero {
  grid-template-columns: 1fr;
  grid-template-rows: 1fr;
  place-items: center;
  text-align: center;
  padding: 16cqh 4cqw 12cqh;
}
.scene.layout-hero .col-text { grid-column: 1; grid-row: 1; align-items: center; max-width: 78cqw; }
.scene.layout-hero .col-text .headline { font-size: 7cqw; text-align: center; }
.scene.layout-hero .col-text .subhead { text-align: center; max-width: 56cqw; }
.scene.layout-hero .col-text .body-note { text-align: center; max-width: 52cqw; margin-left: auto; margin-right: auto; }
.scene.layout-hero .col-text .social { justify-content: center; }
.scene.layout-hero .col-stats {
  grid-column: 1; grid-row: 1;
  flex-direction: row; gap: 4cqw;
  align-self: end; justify-self: center;
  max-width: 78cqw; text-align: center;
}
.scene.layout-hero .col-stats .stat { flex: 1; max-width: 30cqw; }
.scene.layout-hero .col-stats .stat .num { font-size: 1.65cqw; }
.scene.layout-hero .col-stats .stat .note { margin-left: auto; margin-right: auto; }

.scene.layout-anchor {
  grid-template-columns: minmax(0, 0.35fr) minmax(0, 0.65fr);
  grid-template-rows: 1fr;
  padding-top: 13cqh;
  align-items: start;
}
.scene.layout-anchor .col-text {
  grid-column: 2; grid-row: 1; align-self: start; justify-self: end;
  max-width: 58cqw;
  text-align: right;
}
.scene.layout-anchor .col-text .headline {
  font-size: 8cqw;
  line-height: 0.9;
}
.scene.layout-anchor .col-stats {
  grid-column: 1; grid-row: 1; align-self: stretch; justify-self: start;
  flex-direction: column; gap: 0;
  text-align: left;
  display: flex; height: 100%;
}
.scene.layout-anchor .col-stats .stat { max-width: 20cqw; }

/* ===== scene-title ===== */
.scene.scene-title {
  place-items: start center;
  padding-top: 10cqh;
  padding-bottom: 50cqh;
}
.scene.scene-title .col-text {
  align-self: start;
  width: fit-content;
  max-width: 88cqw;
  display: flex; flex-direction: column;
  align-items: stretch;
  gap: 0;
}
.scene.scene-title .col-text > * {
  margin: 0;
  max-width: none;
}
.scene.scene-title .kicker,
.scene.scene-title .subhead {
  font-size: 2.36cqw;
  font-weight: 400;
  letter-spacing: 0;
  text-transform: none;
  line-height: 1;
  margin-bottom: 0;
  opacity: 0.7;
  text-align: justify;
  text-align-last: justify;
}
.scene.scene-title .headline {
  font-size: 44.8cqw;
  line-height: 1;
  text-align: justify;
  text-align-last: justify;
}
.scene.scene-title .headline-mid {
  font-family: 'Archivo Black', 'Noto Sans SC', sans-serif;
  font-weight: 900;
  font-size: 5.9cqw;
  line-height: 1;
  letter-spacing: 0;
  color: var(--ink);
  text-align: justify;
  text-align-last: justify;
}

/* ===== layout-blank ===== */
.scene.layout-blank {
  grid-template-columns: 1fr;
  grid-template-rows: 1fr;
  place-items: end;
  padding: 14cqh 3cqw 10cqh;
}

/* ===== layout-dual ===== */
.scene.layout-dual {
  grid-template-columns: 1fr;
  grid-template-rows: auto 1fr auto;
  padding: 13cqh 3cqw 11cqh;
  gap: 2.4cqh;
}
.dual-kicker { grid-row: 1; }
.dual-cols {
  grid-row: 2;
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 4cqw;
  align-items: start;
}
.dual-col {
  display: flex; flex-direction: column;
  gap: 2cqh;
}
.dual-col-title {
  font-family: 'Archivo Black', 'Noto Sans SC', sans-serif;
  font-weight: 900;
  font-size: 5.6cqw;
  line-height: 0.94;
  letter-spacing: -0.025em;
  color: var(--ink);
}
.dual-col-body {
  font-size: 1.18cqw;
  line-height: 1.65;
  color: var(--ink);
  font-weight: 400;
}
.dual-col-body .accent { color: var(--accent); }
.dual-footer {
  grid-row: 3;
  border-top: 1px solid var(--hairline);
  padding-top: 1.6cqh;
}
.dual-footer-main {
  font-size: 0.92cqw;
  font-weight: 500;
  color: var(--ink);
  line-height: 1.55;
}
.dual-footer-note {
  font-size: 0.82cqw;
  color: var(--muted);
  line-height: 1.55;
  margin-top: 0.6cqh;
}

/* ===== scene-closing ===== */
.scene.scene-closing {
  place-items: center;
  padding: 0;
}
.scene.scene-closing .col-text {
  align-items: center;
  margin-top: -8cqw;
}
.scene.scene-closing .headline {
  font-size: 12cqw;
  line-height: 1;
  letter-spacing: 0.08em;
  text-align: center;
}

/* ===== layout-steps ===== */
.scene.layout-steps {
  grid-template-columns: 1fr 1fr;
  grid-template-rows: auto 1fr;
  align-items: start;
}
.scene.layout-steps .col-steps {
  grid-column: 2; grid-row: 1 / 3;
  display: flex; flex-direction: column;
  align-self: end;
  justify-self: end;
  width: 100%;
  max-width: 56cqw;
}
.scene.layout-steps .col-steps .kicker { margin-bottom: 1.6cqh; }
.scene.layout-steps .steps-list {
  display: flex; flex-direction: column;
}
.scene.layout-steps .step {
  display: flex;
  align-items: baseline;
  gap: 1.4cqw;
  padding: 1.4cqh 0;
  border-bottom: 1px solid var(--hairline);
}
.scene.layout-steps .step:last-child { border-bottom: none; }
.scene.layout-steps .step-num {
  font-family: 'Archivo Black', sans-serif;
  font-size: 1.4cqw;
  letter-spacing: 0.18em;
  color: var(--accent);
  flex: 0 0 auto;
}
.scene.layout-steps .step-body { flex: 1; min-width: 0; }
.scene.layout-steps .step-title {
  font-family: 'Archivo Black', 'Noto Sans SC', sans-serif;
  font-weight: 900;
  font-size: 2.2cqw;
  line-height: 1.15;
  letter-spacing: -0.02em;
  color: var(--ink);
}
.scene.layout-steps .step-desc {
  font-size: 1.1cqw;
  color: var(--muted);
  line-height: 1.5;
  margin-top: 0.6cqh;
}
.scene.layout-steps .col-text {
  grid-column: 1; grid-row: 1;
  align-self: start;
  justify-self: start;
  text-align: left;
  max-width: 42cqw;
  padding-top: 2cqh;
}
.scene.layout-steps .col-text .headline {
  font-size: 6.4cqw;
  line-height: 0.92;
}
.scene.layout-steps .steps-quote {
  font-size: 1.1cqw;
  font-weight: 400;
  line-height: 1.65;
  color: var(--muted);
  margin-top: 3cqh;
  max-width: 38cqw;
  border: none;
  padding: 0;
}
.scene.layout-steps .steps-quote strong {
  color: var(--ink);
  font-weight: 700;
}
.steps-embed {
  margin-top: 2cqh;
  width: 100%;
  max-width: 50cqw;
  margin-left: auto;
}
.steps-embed-img {
  width: 100%;
  border-radius: 0.6cqh;
  display: block;
}

/* ===== layout-accordion ===== */
.scene.layout-accordion {
  grid-template-columns: 1fr;
  grid-template-rows: 1fr;
  padding: 13cqh 3cqw 11cqh;
}
.acc {
  grid-column: 1; grid-row: 1;
  width: 100%; height: 100%;
  display: flex;
  gap: 2.4cqw;
  overflow: hidden;
}

.acc-overview {
  flex: 1 1 50%;
  min-width: 0; max-height: 100%;
  display: flex; flex-direction: column; justify-content: flex-start;
  transition: flex 0.7s var(--ease), opacity 0.45s ease, padding 0.7s var(--ease);
  overflow: hidden;
}
.acc.has-expanded .acc-overview {
  flex: 0 0 0;
  opacity: 0;
  padding: 0;
  pointer-events: none;
}

.acc-cases {
  flex: 1 1 50%;
  min-width: 0; min-height: 0; max-height: 100%;
  display: flex; flex-direction: row;
  gap: 1.2cqw;
  transition: flex 0.7s var(--ease), opacity 0.45s ease;
  overflow: hidden;
}
.acc.has-expanded .acc-cases { flex: 1 1 100%; }
.acc.has-expanded .acc-cases-wrap { flex: 1 1 100%; }

.acc-case {
  flex: 1 1 0;
  min-width: 0; min-height: 0; max-height: 100%;
  background: rgba(255, 255, 255, 0.68);
  backdrop-filter: blur(8px);
  -webkit-backdrop-filter: blur(8px);
  border: 1px solid var(--hairline);
  border-radius: 1.4cqh;
  padding: 2cqh 1.2cqw;
  cursor: pointer;
  overflow: hidden;
  position: relative;
  display: flex;
  transition: flex 0.7s var(--ease), opacity 0.45s ease, background 0.3s ease, transform 0.3s ease;
}
.acc:not(.has-expanded) .acc-case:hover {
  background: rgba(255, 255, 255, 0.88);
  flex: 1.35 1 0;
}
.acc-case.acc-case-empty {
  cursor: default;
}
.acc:not(.has-expanded) .acc-case.acc-case-empty:hover {
  background: rgba(255, 255, 255, 0.68);
  flex: 1 1 0;
}
.acc.has-expanded .acc-case { flex: 0 0 0; opacity: 0; padding-left: 0; padding-right: 0; }
.acc.has-expanded .acc-case.expanded {
  flex: 1 1 100%;
  opacity: 1;
  padding: 2.5cqh 3cqw;
  max-height: 100%;
  background: rgba(255, 255, 255, 0.92);
  transform: none;
  cursor: zoom-out;
  overflow: hidden;
}

.acc-case .case-collapsed {
  display: flex; flex-direction: column;
  justify-content: space-between; align-items: flex-start;
  width: 100%; height: 100%;
  min-height: 0;
}
.acc-case .case-tag {
  font-family: 'Inter', 'Noto Sans SC', sans-serif;
  font-size: 0.78cqw;
  letter-spacing: 0.22em;
  text-transform: uppercase;
  color: var(--muted);
  flex: 0 0 auto;
  white-space: nowrap;
}
.acc-case .case-title {
  font-family: 'Archivo Black', 'Noto Sans SC', sans-serif;
  font-weight: 900;
  font-size: 2.4cqw;
  line-height: 1.05;
  letter-spacing: 0.08em;
  color: var(--ink);
  writing-mode: vertical-rl;
  text-orientation: upright;
  flex: 1 1 auto;
  min-height: 0;
  margin: 1.4cqh 0 1.4cqh 0.2cqw;
  align-self: center;
}
.acc-case .case-summary { display: none; }
.acc-case .case-arrow {
  width: 3cqh; height: 3cqh; min-width: 1.5rem; min-height: 1.5rem;
  border-radius: 50%;
  background: var(--ink); color: #fff;
  display: inline-flex; align-items: center; justify-content: center;
  font-size: 1cqw;
  font-weight: 700;
  flex: 0 0 auto;
  align-self: flex-start;
  transition: transform 0.3s var(--ease);
}
.acc:not(.has-expanded) .acc-case:hover .case-arrow {
  transform: translateX(-0.4cqw);
}

.acc-case .case-detail {
  opacity: 0;
  display: none;
  transition: opacity 0.4s ease 0.25s;
}
.acc.has-expanded .acc-case.expanded .case-detail {
  opacity: 1;
  display: flex;
  flex-direction: column;
  height: 100%;
  width: 100%;
  overflow: hidden;
  min-height: 0;
  position: relative;
}
.acc.has-expanded .acc-case.expanded .case-collapsed { display: none; }

.case-detail .detail-head {
  margin-bottom: 1.2cqh;
  flex: 0 0 auto;
}
.title-toggle {
  cursor: pointer;
  transition: opacity 0.2s;
}
.title-toggle:hover {
  opacity: 0.65;
}
.case-detail .detail-title {
  font-family: 'Archivo Black', 'Noto Sans SC', sans-serif;
  font-size: 3cqw;
  font-weight: 900;
  line-height: 1;
  letter-spacing: -0.025em;
}
.case-detail .detail-title .accent { color: var(--accent); }
.case-detail .detail-body {
  display: flex;
  flex-direction: column;
  gap: 0;
  flex: 1; min-height: 0;
  overflow: hidden;
}
.case-detail .detail-body .detail-text {
  font-size: 1.1cqw;
  line-height: 1.55;
  color: var(--ink);
  overflow: hidden;
  flex: 1; min-height: 0;
  display: flex;
  flex-direction: column;
  gap: 0.8cqh;
}
.case-detail .detail-code-col {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  min-height: 0;
  background: rgba(30, 30, 46, 0.97);
  border-radius: 1cqh;
  padding: 0.8cqh 0.8cqw;
  opacity: 0;
  pointer-events: none;
  transform: translateX(3%);
  transition: opacity 0.35s ease, transform 0.35s ease;
  z-index: 5;
}
.case-detail.show-code .detail-code-col {
  opacity: 1;
  pointer-events: auto;
  transform: translateX(0);
}
.case-detail .detail-body .detail-text p { margin-bottom: 0; flex: 1; min-height: 0; display: flex; align-items: center; justify-content: center; width: 100%; }
.case-detail .detail-body .detail-text strong { font-weight: 700; }
.case-detail .detail-body .detail-text .accent-text { color: var(--accent); font-weight: 600; }
.case-detail .detail-aside {
  border-top: 1px solid var(--hairline);
  padding-top: 1.2cqh;
  display: flex; flex-direction: row; flex-wrap: wrap; gap: 1cqh 3cqw;
  font-size: 0.92cqw;
  color: var(--muted);
  line-height: 1.5;
  overflow: hidden;
  flex: 0 0 auto;
}
.case-detail .detail-aside:empty {
  display: none;
}
.case-detail .detail-aside .aside-kicker {
  font-family: 'Inter', sans-serif;
  font-size: 0.78cqw;
  letter-spacing: 0.22em;
  text-transform: uppercase;
  color: var(--ink);
  opacity: 0.7;
  font-weight: 500;
}
.case-detail .detail-aside .aside-row {
  display: flex; flex-direction: column; gap: 0.4cqh;
}
.case-detail .detail-aside .aside-row strong {
  color: var(--ink); font-weight: 600;
}

/* overview pinned to bottom (for scenes where dog is in top-left) */
.scene.ov-bottom .acc-overview {
  justify-content: flex-end;
}

/* reversed accordion: overview on right, cases on left */
.scene.acc-reversed .acc {
  flex-direction: row-reverse;
}

/* blank placeholder for unexplored cases */
.blank-placeholder {
  display: block;
  text-align: center;
  font-family: 'Archivo Black', sans-serif;
  font-size: 10cqw;
  font-weight: 900;
  line-height: 1;
  color: var(--hairline);
  margin-top: 2cqh;
  user-select: none;
}

/* accordion cover over cases area */
.acc-cases-wrap {
  position: relative;
  flex: 1 1 50%;
  min-width: 0; min-height: 0; max-height: 100%;
  overflow: hidden;
}
.acc-cases-wrap .acc-cases {
  flex: none;
  width: 100%; height: 100%;
  display: flex; flex-direction: row;
  gap: 1.2cqw;
  overflow: hidden;
}
.acc-cover {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  flex-direction: column;
  cursor: pointer;
  z-index: 3;
  transition: opacity 0.45s ease;
}
.acc-cover img {
  max-width: 100%;
  max-height: 78%;
  object-fit: contain;
  border-radius: 1cqh;
  transition: transform 0.3s ease;
}
.acc-cover:hover img {
  transform: scale(1.01);
}
.acc-cover-hint {
  margin-top: 1.4cqh;
  font-size: 0.92cqw;
  font-weight: 600;
  color: var(--accent);
  opacity: 0.7;
  transition: opacity 0.3s ease;
}
.acc-cover:hover .acc-cover-hint { opacity: 1; }
.acc-cover.hidden {
  opacity: 0;
  pointer-events: none;
}
.acc-cases-hidden {
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.45s ease;
}
.acc-cases-hidden.acc-cases-visible {
  opacity: 1;
  pointer-events: auto;
}

/* embedded media in detail */
.detail-media {
  max-width: 100%;
  height: 100%;
  border-radius: 0.6cqh;
  margin: 0;
  object-fit: contain;
  display: block;
}
.detail-media-row {
  display: flex;
  gap: 1.2cqw;
  align-items: center;
  justify-content: center;
  width: 100%;
  height: 100%;
}
.detail-media-row .detail-media {
  flex: 1 1 0;
  min-width: 0;
  max-width: 50%;
  height: 100%;
  object-fit: contain;
}

/* ===== mini slideshow ===== */
.mini-slideshow {
  width: 100%; height: 100%;
  display: flex; flex-direction: column;
  position: relative;
  overflow: hidden;
}
.ms-track {
  flex: 1; min-height: 0;
  position: relative;
}
.ms-slide {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  object-fit: contain;
  border-radius: 0.6cqh;
  opacity: 0;
  transition: opacity 0.35s ease;
  pointer-events: none;
}
.ms-slide.ms-active {
  opacity: 1;
  pointer-events: auto;
}
.ms-nav {
  flex: 0 0 auto;
  display: flex; align-items: center; justify-content: center;
  gap: 1.5cqw;
  padding: 0.6cqh 0;
}
.ms-prev, .ms-next {
  background: none; border: 1px solid var(--hairline);
  border-radius: 50%;
  width: 2.4cqw; height: 2.4cqw;
  font-size: 1.4cqw; line-height: 1;
  cursor: pointer;
  color: var(--ink);
  display: flex; align-items: center; justify-content: center;
  transition: background 0.2s, border-color 0.2s;
}
.ms-prev:hover, .ms-next:hover {
  background: var(--accent);
  border-color: var(--accent);
  color: #fff;
}
.ms-counter {
  font-size: 0.9cqw;
  color: var(--muted);
  font-variant-numeric: tabular-nums;
}

/* ===== detail iframe ===== */
.detail-iframe {
  width: 100%; height: 100%;
  border: none;
  border-radius: 0.6cqh;
  background: #fff;
}

/* reveal button + hidden image */
.reveal-wrap {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  height: 100%;
  position: relative;
}
.reveal-wrap .reveal-img {
  opacity: 0;
  transition: opacity 0.5s ease;
  pointer-events: none;
}
.reveal-wrap.revealed .reveal-img {
  opacity: 1;
  pointer-events: auto;
}
.detail-code-block {
  background: #1e1e2e;
  color: #cdd6f4;
  font-family: 'Fira Code', 'Consolas', 'Monaco', monospace;
  font-size: 0.78cqw;
  line-height: 1.55;
  border-radius: 0.6cqh;
  padding: 1.2cqh 1.2cqw;
  margin: 0;
  overflow-y: auto;
  overflow-x: auto;
  flex: 1;
  min-height: 0;
  white-space: pre;
  tab-size: 2;
  -moz-tab-size: 2;
}
.detail-code-block::-webkit-scrollbar {
  width: 6px; height: 6px;
}
.detail-code-block::-webkit-scrollbar-track {
  background: transparent;
}
.detail-code-block::-webkit-scrollbar-thumb {
  background: rgba(255,255,255,0.18);
  border-radius: 3px;
}
.detail-code-block::-webkit-scrollbar-thumb:hover {
  background: rgba(255,255,255,0.3);
}
.detail-code-block .hl-kw { color: #cba6f7; }
.detail-code-block .hl-str { color: #a6e3a1; }
.detail-code-block .hl-cm { color: #6c7086; font-style: italic; }
.detail-code-block .hl-fn { color: #89b4fa; }
.detail-code-block .hl-num { color: #fab387; }
.detail-code-label {
  font-family: 'Inter', sans-serif;
  font-size: 0.72cqw;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: #a6adc8;
  display: inline-block;
  padding: 0.3cqh 0;
  flex: 0 0 auto;
}

.aside-img {
  max-width: 100%;
  max-height: 16cqh;
  border-radius: 0.4cqh;
  margin: 0.4cqh 0;
  object-fit: contain;
  display: block;
}
.ov-img {
  max-width: 100%;
  max-height: 22cqh;
  border-radius: 0.8cqh;
  margin-top: 1.2cqh;
  object-fit: contain;
  display: block;
}
.detail-code {
  display: block;
  background: rgba(10,10,10,0.06);
  border-radius: 0.6cqh;
  padding: 1.2cqh 1.4cqw;
  font-family: 'JetBrains Mono', 'Fira Code', monospace;
  font-size: 0.72cqw;
  line-height: 1.6;
  white-space: pre-wrap;
  word-break: break-all;
  color: var(--ink);
  overflow: auto;
  max-height: 30cqh;
}
.detail-link {
  display: flex;
  align-items: center;
  justify-content: center;
  flex: 1 1 0;
  min-height: 0;
  color: var(--ink);
  font-family: 'Archivo Black', 'Noto Sans SC', sans-serif;
  font-weight: 900;
  font-size: 3cqw;
  letter-spacing: -0.02em;
  text-decoration: none;
  background: none;
  border: none;
  transition: color 0.25s;
}
.detail-link:hover {
  color: var(--accent);
}

/* detail text sizes */
.detail-big {
  font-family: 'Archivo Black', 'Noto Sans SC', sans-serif;
  font-size: 2.4cqw;
  font-weight: 900;
  line-height: 1.15;
  color: var(--ink);
}
.detail-mid {
  font-size: 1.2cqw;
  font-weight: 500;
  line-height: 1.65;
  color: var(--ink);
}
.detail-mid .accent-text { color: var(--accent); font-weight: 700; }
.detail-quote {
  font-family: 'Fira Code', 'Consolas', monospace;
  font-size: 0.92cqw;
  line-height: 1.6;
  color: var(--ink);
  background: rgba(0,0,0,0.04);
  border-left: 3px solid var(--accent);
  padding: 1.2cqh 1.4cqw;
  margin: 0;
  border-radius: 0 0.4cqh 0.4cqh 0;
}
.inline-code {
  font-family: 'Fira Code', 'Consolas', monospace;
  font-size: 0.88em;
  background: rgba(0,0,0,0.06);
  padding: 0.15em 0.4em;
  border-radius: 0.3em;
}

.click-reveal {
  cursor: pointer;
  position: relative;
}
.click-reveal img { display: none; }
.click-reveal .click-reveal-hint {
  display: inline-block;
  color: var(--accent);
  font-weight: 600;
  font-size: 0.92cqw;
  padding: 1cqh 0;
}
.click-reveal.revealed img { display: block; }
.click-reveal.revealed .click-reveal-hint { display: none; }

/* overview content styles */
.acc-overview .ov-kicker {
  font-family: 'Inter', 'Noto Sans SC', sans-serif;
  font-size: 0.82cqw;
  letter-spacing: 0.22em;
  text-transform: uppercase;
  color: var(--ink);
  margin-bottom: 1.6cqh;
  opacity: 0.7;
  font-weight: 500;
}
.acc-overview .ov-title {
  font-family: 'Archivo Black', 'Noto Sans SC', sans-serif;
  font-weight: 900;
  font-size: 5cqw;
  line-height: 0.94;
  letter-spacing: -0.025em;
  margin-bottom: 2.4cqh;
  color: var(--ink);
}
.acc-overview .ov-title .accent { color: var(--accent); }
.acc-overview .ov-lead {
  font-size: 1.18cqw;
  font-weight: 500;
  line-height: 1.55;
  color: var(--ink);
  margin-bottom: 2.4cqh;
  max-width: 36cqw;
}
.acc-overview .ov-note {
  font-size: 0.92cqw;
  color: var(--muted);
  line-height: 1.65;
  max-width: 34cqw;
}
.acc-overview .ov-note strong { color: var(--ink); font-weight: 600; }

/* ============ chapter modules grid ============ */
.chapter-modules {
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  height: 100%;
  margin-top: 0;
}
.chapter-modules .mod {
  background: none;
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
  border: none;
  border-radius: 0;
  padding: 1cqh 0;
  text-align: left;
  border-bottom: 1px solid var(--hairline);
}
.chapter-modules .mod:last-child {
  border-bottom: none;
}
.chapter-modules .mod .mod-num {
  font-family: 'Archivo Black', sans-serif;
  font-size: 0.88cqw;
  letter-spacing: 0.18em;
  color: var(--accent);
  margin-bottom: 0.4cqh;
}
.chapter-modules .mod .mod-name {
  font-family: 'Archivo Black', 'Noto Sans SC', sans-serif;
  font-size: 5.6cqw;
  font-weight: 900;
  line-height: 0.95;
  letter-spacing: -0.03em;
  white-space: nowrap;
}
.chapter-modules .mod .mod-tag {
  font-size: 0.84cqw;
  color: var(--muted);
  margin-top: 0.4cqh;
  line-height: 1.5;
}

/* ============ typography ============ */
.kicker {
  font-family: 'Inter', 'Noto Sans SC', sans-serif;
  font-size: 0.82cqw;
  letter-spacing: 0.22em;
  text-transform: uppercase;
  color: var(--ink);
  margin-bottom: 1.2cqh;
  opacity: 0.7;
  font-weight: 500;
}
.headline {
  font-family: 'Archivo Black', 'Noto Sans SC', sans-serif;
  font-weight: 900;
  font-size: 5.6cqw;
  line-height: 0.94;
  letter-spacing: -0.025em;
  margin-bottom: 2.4cqh;
  color: var(--ink);
}
.headline .en { font-family: 'Archivo Black', sans-serif; text-transform: uppercase; }
.headline .accent { color: var(--accent); }
.subhead {
  font-size: 1.18cqw;
  font-weight: 400;
  line-height: 1.55;
  color: var(--ink);
  margin-bottom: 3cqh;
  max-width: 42cqw;
}
.subhead strong { font-weight: 700; }
.subhead .accent-text { color: var(--accent); font-weight: 700; }
.body-note {
  font-size: 0.92cqw;
  font-weight: 400;
  line-height: 1.65;
  color: var(--ink);
  max-width: 38cqw;
  margin-bottom: 1.8cqh;
}
.body-note strong { font-weight: 700; }
.body-note .accent-text { color: var(--accent); font-weight: 600; }
.social {
  display: flex; gap: 1.2cqw;
  font-size: 0.88cqw;
  font-weight: 500;
  color: var(--ink);
  margin-top: auto;
  padding-top: 2cqh;
}
.social a { color: inherit; text-decoration: none; opacity: 0.7; transition: opacity 0.2s; pointer-events: auto; }
.social a:hover { opacity: 1; }

.stat .num {
  font-family: 'Archivo Black', 'Noto Sans SC', sans-serif;
  font-size: 1.85cqw;
  line-height: 1.05;
  letter-spacing: -0.015em;
  text-transform: uppercase;
  margin-bottom: 1cqh;
  color: var(--ink);
}
.stat .num .accent { color: var(--accent); }
.stat .note {
  font-size: 0.88cqw;
  color: var(--muted);
  line-height: 1.55;
  max-width: 26cqw;
  font-weight: 400;
}
.stat .note strong { color: var(--ink); font-weight: 600; }

/* ============ BOTTOM strip ============ */
.bottom-strip-anchor {
  position: absolute;
  left: 3cqw; right: 3cqw; bottom: 4cqh;
  z-index: 25;
  display: flex; align-items: center; justify-content: space-between;
  gap: 1.5cqw;
  border-top: 1px solid rgba(10,10,10,0.14);
  padding-top: 2cqh;
  transition: opacity 0.4s ease;
}
.chapter-row {
  display: flex; align-items: center; gap: 1.5cqw;
  flex-wrap: wrap;
  font-family: 'Archivo Black', 'Noto Sans SC', sans-serif;
  font-size: 0.92cqw;
  letter-spacing: 0.02em;
  color: var(--ink);
}
.chapter-row .ch {
  display: inline-flex; align-items: center; gap: 0.5cqw;
  opacity: 0.32; transition: opacity 0.3s ease;
  cursor: pointer; pointer-events: auto;
}
.chapter-row .ch.active { opacity: 1; color: var(--accent); }
.chapter-row .ch .mark {
  width: 0.85cqw; height: 0.85cqw; min-width: 9px; min-height: 9px;
  display: inline-block;
  background: currentColor;
}
.chapter-row .ch .mark.circle { border-radius: 50%; }
.chapter-row .ch .mark.square { border-radius: 1px; }
.chapter-row .ch .mark.tri {
  background: transparent;
  border-left: 0.42cqw solid transparent;
  border-right: 0.42cqw solid transparent;
  border-bottom: 0.72cqw solid currentColor;
}
.chapter-row .ch .mark.bar { height: 0.32cqw; min-height: 4px; border-radius: 1px; }
.chapter-row .ch .mark.dot { border-radius: 50%; width: 0.5cqw; height: 0.5cqw; min-width: 5px; min-height: 5px; }
.footer-meta {
  font-family: 'Inter', monospace;
  font-size: 0.82cqw;
  color: var(--muted);
  letter-spacing: 0.16em;
  text-transform: uppercase;
  font-weight: 500;
}

.stage-inner[data-active="0"] .bottom-strip-anchor { opacity: 0; pointer-events: none; }

/* ============ scroll hint ============ */
.scroll-hint {
  position: absolute;
  bottom: 3cqh; left: 50%;
  transform: translateX(-50%);
  z-index: 40;
  font-family: 'Inter', monospace;
  font-size: 0.78cqw;
  letter-spacing: 0.28em;
  text-transform: uppercase;
  color: var(--ink);
  opacity: 1;
  transition: opacity 0.4s ease;
  display: flex; align-items: center; gap: 0.6rem;
  pointer-events: none; font-weight: 600;
}
.scroll-hint .bar {
  display: inline-block; width: 1px; height: 1.3rem;
  background: currentColor;
  animation: drop 1.6s ease-in-out infinite;
  transform-origin: top;
}
@keyframes drop {
  0%   { transform: scaleY(0); transform-origin: top; }
  50%  { transform: scaleY(1); transform-origin: top; }
  51%  { transform: scaleY(1); transform-origin: bottom; }
  100% { transform: scaleY(0); transform-origin: bottom; }
}

.wait-corner {
  position: absolute;
  bottom: 4cqh; right: 3cqw;
  z-index: 35;
  font-family: 'Inter', monospace;
  font-size: 0.82cqw;
  letter-spacing: 0.28em;
  text-transform: uppercase;
  color: var(--ink);
  opacity: 0;
  transition: opacity 0.4s ease;
  font-weight: 500;
}
.stage-inner[data-active="0"] .wait-corner { opacity: 0.55; }

/* ===== global timer ===== */
.global-timer {
  position: fixed;
  bottom: 1.2vh;
  left: 1.2vw;
  font-family: 'Inter', monospace;
  font-size: 1.1vw;
  font-variant-numeric: tabular-nums;
  color: rgba(255, 255, 255, 0.45);
  z-index: 9999;
  pointer-events: none;
  letter-spacing: 0.05em;
}

@media (max-width: 720px) {
  .nav-menu { display: none; }
  .nav-link { display: none; }
  .chapter-row .ch:nth-child(n+5) { display: none; }
}

制作化石一份,存档自原项目目录(获取日期 2026-07-07):

assets/prompt-v0.md —— 初版滚动开场的一次成型 prompt(原文写于 2026-05-16)
# 滚动叙事开场 — tap4fun 公司简介

做一个**滚动驱动**的单页 PPT 开场。用户向下滚动,背景在 3 个静帧场景之间用 2 段过渡视频无缝衔接,前景文案随场景切换。**不循环**,滚到底停在最后一帧。

---

## 一、资产清单

当前目录文件(路径用相对路径,**已去除空格**):

```
./scene1.png        场景1静帧(开场)
./scene2.png        场景2静帧(中段)
./scene3.png        场景3静帧(终场)
./trans1to2.mp4     场景1→2 过渡视频
./trans2to3.mp4     场景2→3 过渡视频
./reference.png     排版参考图(仅用于学习版式,不出现在页面里)
```

**重要**:没有一条贯穿始终的 main 视频。叙事是 "静帧 → 过渡视频 → 静帧 → 过渡视频 → 静帧" 的 5 段混合结构。

---

## 二、叙事结构与滚动映射

页面总高度 = `500vh`。把全局 scroll 进度 `p ∈ [0, 1]` 划成 5 个区间:

| 区间 | p 范围 | 背景层 | 文案 |
|---|---|---|---|
| ① | 0.00 – 0.20 | `scene1.png` 静帧 | 场景1文案常显 |
| ② | 0.20 – 0.40 | `trans1to2.mp4` 播放 | 文案 fade 1→2 |
| ③ | 0.40 – 0.60 | `scene2.png` 静帧 | 场景2文案常显 |
| ④ | 0.60 – 0.80 | `trans2to3.mp4` 播放 | 文案 fade 2→3 |
| ⑤ | 0.80 – 1.00 | `scene3.png` 静帧 | 场景3文案 + CTA |

视频驱动规则:
- 在区间 ② 内:`trans1to2.currentTime = ((p - 0.20) / 0.20) × trans1to2.duration`
- 在区间 ④ 内:`trans2to3.currentTime = ((p - 0.60) / 0.20) × trans2to3.duration`
- 静帧区间:对应图片 `opacity:1`,所有视频 `opacity:0`
- 过渡区间:对应视频 `opacity:1`,所有图片 `opacity:0`
- 层切换用 `0.3s ease` opacity 过渡,避免硬切

**不循环**。滚动到底停在场景3,CTA 可点击。

---

## 三、文案内容(tap4fun 公司简介)

所有文案沿用 reference.png 的版式骨架:**顶部导航 + 左上大标题/副标/CTA + 右上两组统计 + 底部品牌行 + 社交图标**。

顶部导航(三个场景共享,固定):
- 左:`tap4fun` logo
- 中:About / Games / Studio / Careers / Contact
- 右:`Press Kit`(次按钮)/ `Join Us`(主按钮)

### 场景 1 — Brand Vision
- 主标题:`BUILDING WORLDS THAT ENDURE`
- 副标:Global mobile strategy, crafted in Chengdu since 2008.
- 描述(CTA 下方小字):A studio of builders, designers, and engineers shaping interactive worlds that millions return to.
- CTA pill:`Discover tap4fun →`
- 右上统计 ×2:
  - `200M+ PLAYERS WORLDWIDE` — From day-one launches to franchises that still grow today.
  - `15+ YEARS IN THE INDUSTRY` — Pioneers of cross-cultural SLG, scaling across regions.

### 场景 2 — Portfolio
- 主标题:`GAMES THAT DEFINE A GENRE`
- 副标:Strategy titles that turn empires into stories.
- 描述:From real-time war to long-tail kingdoms — operated live, every hour, every region.
- CTA pill:`Explore our games →`
- 右上统计 ×2:
  - `10+ FLAGSHIP TITLES` — Invasion: Modern Empire · Last Empire–War Z · Age of Z Origins.
  - `150+ COUNTRIES SERVED` — 20+ languages, 24/7 live operations.

### 场景 3 — Team & Future
- 主标题:`THE NEXT WORLD STARTS HERE`
- 副标:Builders, designers, engineers — one studio, one mission.
- 描述:We are hiring across engineering, art, game design, and live operations.
- CTA pill:`Join the studio →`
- 右上统计 ×2:
  - `800+ TEAM MEMBERS` — Engineering, art, ops, and live-game craft, under one roof.
  - `∞ POSSIBILITIES` — AI, cross-platform, new frontiers in interactive entertainment.
- 底部品牌行(只在场景3显示):`Invasion  ·  Last Empire  ·  Age of Z  ·  Kings Throne  ·  Hero Clash`
- 社交图标:Facebook · X · LinkedIn · Bilibili

---

## 四、排版语言(学习 reference.png)

观察 reference.png 后还原以下要点:

- **字体**
  - 主标题:粗体无衬线 condensed/grotesk,全大写,字重对比极强 → 用 Google Fonts `Archivo Black``Anton`
  - 正文/副标:现代无衬线 → `Inter` 400/500
  - 统计数字:与主标同字体保持一致性
- **字号层级**:主标 `clamp(2.8rem, 6vw, 5.5rem)` / 统计数字 `clamp(1.8rem, 3vw, 2.6rem)` / 副标 `1rem` / 描述 `0.85rem`
- **对齐**:非对称三栏——左上 / 右上 / 底部一行;导航横贯顶部
- **颜色**:背景由场景图决定,文字默认黑色 `#0a0a0a`;CTA pill 黑底白字带白色圆形 → 箭头 icon;强调色橙 `#FF6A1A`(用在 hover / 强调小元素)
- **装饰**:CTA 是 pill button 带末尾圆形 → 图标;统计数字下有 1 行细描述;底部品牌名用 `·` 分隔
- **气质**:极简、留白大、字重对比强、设计克制
- **关键原则****三个场景共用同一套版式骨架**,只换文字内容。滚动时让用户感到"画面在变、版式在呼吸",而不是切到一个完全不同的页面。

---

## 五、技术实现

技术栈:**纯 HTML + CSS + JavaScript 单文件**,不用任何框架。直接通过 Google Fonts CDN 引字体。

### 背景层结构
```html
<div class="bg-layer">
  <img id="s1" src="./scene1.png">
  <video id="v12" src="./trans1to2.mp4" muted playsinline preload="auto"></video>
  <img id="s2" src="./scene2.png">
  <video id="v23" src="./trans2to3.mp4" muted playsinline preload="auto"></video>
  <img id="s3" src="./scene3.png">
</div>
```
所有 5 个元素:`position: fixed; inset: 0; width: 100%; height: 100%; object-fit: cover; transition: opacity 0.3s ease;`

### 视频要点(避坑)
- `muted` + `playsinline` + `preload="auto"` 必须有
- **不要** `autoplay`**不要** `controls`**不要** `loop`
- iOS Safari 必须 `playsinline`(小写)
- 视频用 `currentTime` 驱动,不调用 `play()`

### 滚动驱动
```js
let targetT12 = 0, targetT23 = 0;
function onScroll() {
  const p = window.scrollY / (document.body.scrollHeight - window.innerHeight);
  // 计算各层 opacity 与视频 currentTime
  // 用变量缓存目标值,rAF 里再赋给 video.currentTime
}
function tick() {
  v12.currentTime += (targetT12 - v12.currentTime) * 0.25; // 缓动 seek
  v23.currentTime += (targetT23 - v23.currentTime) * 0.25;
  requestAnimationFrame(tick);
}
window.addEventListener('scroll', onScroll, { passive: true });
requestAnimationFrame(tick);
```
关键:scroll 事件只更新目标值,真正的 `currentTime` 赋值放在 rAF 里做缓动,避免连续 seek 卡顿。

### 文案层
- 三段文案各自 `position: fixed`,按 reference.png 三栏骨架布局
- opacity 由所在区间决定,`transition: opacity 0.4s ease`
- 同时只有一段处于 `opacity:1`

### 响应式
- 断点 `≤768px`:左右两栏堆叠为单列;导航折叠为汉堡(或简化为只显示 logo + Join Us);主标题用 `clamp()` 自适应
- 底部品牌行在移动端换行展示,分隔点保留

### 其他
- 页面初始 `body { overflow-x: hidden }`,纵向 scroll height 由一个 `<div style="height:500vh"></div>` spacer 撑开
- 字体预加载,避免 FOUT
- 不需要做加载进度条,但首屏前确保 `scene1.png` 已 decode(用 `<link rel="preload">``img.decode()`
---

## 六、输出

**直接输出完整 `index.html` 单文件**(含内联 CSS 与 JS,引用 Google Fonts CDN)。资产用相对路径引用,文件命名见【一】。不要输出说明文字,只要 HTML。