Ver Fonte

deck_index 双概览重写:网格(iframe)60%/无限画廊(图片)40% + 自适应/防点击失效

- 概览墙去掉 preserve-3d,整墙作单个倾斜平面,修复顶排卡片点不到(命中测试不可靠)
- 自适应:一屏放得下→对角倾斜居中铺满;页多→舒适大小竖向滚动,不再缩成邮票
- 无限画廊:洗牌排布看完所有页才重复(去掉规则重复) + img缩略图扛性能
- 网格用iframe真页面(清晰所见即所得),图片仅画廊用
- 未指定时按秒数随机 grid60%/gallery40%,可 ?ov= 或 window.DECK_OVERVIEW 固定
- 新增 scripts/gen_deck_thumbs.mjs 生成画廊缩略图(1600px防hover发虚)
- slide-decks.md 补两种概览模式说明 + 三条实战硬约束

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
alchaincyf há 2 semanas atrás
pai
commit
3f5e731959
3 ficheiros alterados com 302 adições e 336 exclusões
  1. 228 336
      assets/deck_index.html
  2. 19 0
      references/slide-decks.md
  3. 55 0
      scripts/gen_deck_thumbs.mjs

+ 228 - 336
assets/deck_index.html

@@ -4,283 +4,145 @@
 <meta charset="UTF-8">
 <title>Deck · Multi-file Slide Index</title>
 <!--
-  deck_index.html — 多文件 slide deck 的拼接器(双模式)
+  deck_index.html — 多文件 slide deck 拼接器 · 双概览模式
 
-  配合「每页一个独立 HTML」架构使用。与单文件 deck_stage.js 对比
-  · 每页独立作用域(CSS/JS 都隔离),一页出 bug 不影响其他页
-  · 单页可直接在浏览器打开验证,不依赖 JS goTo()
-  · 多 agent 可并行做不同页,merge 时零冲突
-  · 适合 ≥15 页的讲座/课件/长 deck
+  概览(默认进入,两种模式按 getSeconds()%5 随机:20% 画廊 / 80% 网格)
+   · grid  自适应网格墙:列数随页数+视口自适应,行多则倾斜变平,scale-to-fit 居中铺满(任意页数都不溢出/不失真)
+   · gallery 无限画廊:固定卡片大小,无缝无限平铺 + 缓慢漂移 + 轻微呼吸;一个 tile 含全部页(洗牌),看完所有页才重复
+  演示 present:单页 fit 缩放翻页。ESC / ⊞ 回概览。
+  性能:manifest 每项可带 thumb(预渲染缩略图),概览即用 <img> 平铺,避免 N 个 iframe 同时加载。
 
-  两种模式(body[data-mode] 控制):
-  · 概览墙 overview(默认进入):所有页缩略成 3D 透视卡片墙,景深斜铺、悬浮投影。
-      点任意卡片 → 从该页进入演示;右下角「▶ 开始演示」从第 1 页(或记忆页)进入。
-  · 全屏演示 present:单页 fit 缩放翻页。ESC 或左上「⊞ 概览」回到概览墙。
-
-  用法:
-    1. 把本文件复制到 deck 根目录,重命名 index.html
-    2. 在同目录建 slides/ 子目录,放每一页独立 HTML
-    3. 编辑下方 MANIFEST 数组,按顺序列出文件名和人类可读标签
-    4. 每张 slide HTML 建议尺寸 1920×1080,自带背景/字体;不要依赖外层 CSS
-
-  共享资源(如果需要):
-    · shared/tokens.css  — 跨页 CSS 变量(色板/字号)
-    · shared/chrome.html — 页眉页脚可复用片段
-    · 每页 HTML 自己 <link> 进去即可
-
-  键盘(演示模式):← / → / Space / PgUp / PgDown / Home / End / 1-9 跳页 / P 打印 / ESC 回概览
+  用法:复制为 index.html;建 slides/ 放每页独立 HTML;编辑 MANIFEST。
+  可选覆盖:window.DECK_OVERVIEW = 'grid' | 'gallery'(不写则随机);URL ?ov=grid|gallery 临时强制。
+  键盘(演示):← / → / Space / PgUp / PgDown / Home / End / 1-9 / P 打印 / ESC 回概览
 -->
-
-<!-- ═══════════════════════════════════════════════════════ -->
-<!-- EDIT THIS — deck 所有页按顺序列出                        -->
-<!-- ═══════════════════════════════════════════════════════ -->
 <script>
   window.DECK_MANIFEST = [
-    { file: "slides/01-cover.html",       label: "Cover" },
-    { file: "slides/02-quote.html",       label: "Opening Quote" },
-    { file: "slides/03-intro.html",       label: "Self-intro" },
-    // 继续往下加。file 是相对本文件的路径,label 用于计数器
+    { file: "slides/01-cover.html", label: "Cover" /*, thumb: "thumbs/01.jpg" */ },
   ];
-
-  // 固定 canvas 尺寸。每页 HTML 都应该按这个尺寸设计。
   window.DECK_WIDTH = 1920;
   window.DECK_HEIGHT = 1080;
+  window.GALLERY_CARD_W = 300;       // 画廊卡片基准宽度
+  window.GALLERY_DRIFT_SECONDS = 80; // 画廊漂移一圈时长
+  // window.DECK_OVERVIEW = 'grid';  // 取消注释可固定概览模式
 </script>
 
 <style>
   * { box-sizing: border-box; margin: 0; padding: 0; }
-  html, body {
-    height: 100%;
-    background: #0a0a0a;
-    overflow: hidden;
-    font-family: -apple-system, "PingFang SC", sans-serif;
-  }
+  html, body { height: 100%; overflow: hidden; font-family: -apple-system, "PingFang SC", sans-serif; background: #0a0a0a; }
 
-  /* ── 模式显隐:默认 overview ───────────────────────────── */
-  body[data-mode="overview"] { background: #f0eee9; overflow: auto; }
+  .overview { position: fixed; inset: 0; }
+  body[data-mode="present"] .overview { display: none; }
+  body[data-mode="present"] .start-btn { display: none; }
   body[data-mode="overview"] #present-ui { display: none; }
-  body[data-mode="present"]  #overview   { display: none; }
-  body[data-mode="present"]  .start-btn  { display: none; }
-
-  /* ════════════════════════════════════════════════════════ */
-  /* 模式 A · 概览墙(3D 透视卡片墙)                            */
-  /* ════════════════════════════════════════════════════════ */
-  #overview {
-    min-height: 100%;
-    padding: 7vw 5vw 14vw;
-    /* 透视容器:景深 */
-    perspective: 1600px;
-    perspective-origin: 50% 38%;
-  }
-  .wall {
-    display: grid;
-    grid-template-columns: repeat(4, 1fr);
-    gap: 2.4vw;
-    max-width: 1500px;
-    margin: 0 auto;
-    transform-style: preserve-3d;
-    /* 近处向左下、远处向右上铺开 */
-    transform: rotateX(28deg) rotateZ(-14deg);
-  }
-  .card {
-    position: relative;
-    aspect-ratio: 16 / 9;
-    border-radius: 10px;
-    overflow: hidden;
-    background: #fff;
-    cursor: pointer;
-    box-shadow: 0 18px 40px rgba(40, 34, 24, 0.22),
-                0 4px 12px rgba(40, 34, 24, 0.14);
-    transition: transform 0.45s cubic-bezier(.2,.7,.2,1),
-                box-shadow 0.45s cubic-bezier(.2,.7,.2,1);
-    transform-style: preserve-3d;
-    will-change: transform;
-  }
-  .card:hover {
-    /* 抬起 + 微微「正过来」给预览感 */
-    transform: translateZ(80px) rotateX(-14deg) rotateZ(14deg);
-    box-shadow: 0 40px 80px rgba(40, 34, 24, 0.32),
-                0 10px 24px rgba(40, 34, 24, 0.20);
-    z-index: 2;
-  }
-  .card .thumb {
-    position: absolute;
-    top: 0; left: 0;
-    width: 1920px;
-    height: 1080px;
-    transform-origin: top left;
-    /* scale 由 JS 按卡片实际宽度设置 */
-    pointer-events: none;
+  body[data-ov="grid"]    #ov-gallery { display: none; }
+  body[data-ov="gallery"] #ov-grid    { display: none; }
+  body[data-ov="gallery"][data-mode="overview"] { background: #0a0805; }
+  body[data-ov="grid"][data-mode="overview"]    { background: #efece4; }
+
+  /* ════════ 模式 1 · 自适应网格墙(一屏装得下→居中轻斜;装不下→舒适大小竖向滚动)════════ */
+  #ov-grid { display: flex; justify-content: center; perspective: 3200px; perspective-origin: 50% 42%;
+    background: radial-gradient(120% 90% at 50% 0%, #f4f1e9, #e7e3d8 75%); }
+  body[data-grid="fit"]    #ov-grid { overflow: hidden;   align-items: center; }
+  body[data-grid="scroll"] #ov-grid { overflow-y: auto; overflow-x: hidden; align-items: flex-start; padding: 74px 0 110px; }
+  .wall-wrap { }
+  body[data-grid="fit"]    .wall-wrap { transform: translateY(-3vh); }
+  .wall { display: grid; gap: 20px; transform-origin: center center; margin: 0 auto; }  /* 不用 preserve-3d:整墙作单个倾斜平面,命中测试可靠 */
+  #ov-grid .card {
+    position: relative; aspect-ratio: 16/9; border-radius: 9px; overflow: hidden; background: #fff; cursor: pointer;
+    box-shadow: 0 16px 34px rgba(40,34,24,0.20), 0 3px 10px rgba(40,34,24,0.12);
+    transition: transform .28s cubic-bezier(.2,.7,.2,1), box-shadow .28s;
   }
-  .card .thumb iframe {
-    width: 1920px;
-    height: 1080px;
-    border: 0;
-    display: block;
-    background: #fff;
-    pointer-events: none;
+  #ov-grid .card:hover {
+    transform: scale(1.18);
+    box-shadow: 0 48px 92px rgba(40,34,24,0.36), 0 14px 30px rgba(40,34,24,0.22); z-index: 20;
   }
-  .card .num {
-    position: absolute;
-    bottom: 8px; left: 10px;
-    background: rgba(20, 16, 10, 0.72);
-    color: #fff;
-    font-size: 12px;
-    line-height: 1;
-    padding: 5px 9px;
-    border-radius: 6px;
-    font-variant-numeric: tabular-nums;
-    letter-spacing: 0.04em;
-    z-index: 3;
-    pointer-events: none;
-  }
-  #overview-title {
-    text-align: center;
-    color: #5a5346;
-    font-size: 15px;
-    letter-spacing: 0.18em;
-    margin: 0 auto 5vw;
-    text-transform: uppercase;
-    opacity: 0.7;
-  }
-  .start-btn {
-    position: fixed;
-    bottom: 28px; right: 28px;
-    background: #1a1712;
-    color: #fff;
-    border: 0;
-    padding: 14px 26px;
-    border-radius: 999px;
-    font-size: 15px;
-    font-family: inherit;
-    letter-spacing: 0.04em;
-    cursor: pointer;
-    z-index: 200;
-    box-shadow: 0 8px 28px rgba(40, 34, 24, 0.28);
-    transition: transform 0.18s, box-shadow 0.18s;
-  }
-  .start-btn:hover { transform: translateY(-2px); box-shadow: 0 12px 34px rgba(40, 34, 24, 0.36); }
 
-  /* ════════════════════════════════════════════════════════ */
-  /* 模式 B · 全屏演示                                          */
-  /* ════════════════════════════════════════════════════════ */
-  #stage {
-    position: fixed;
-    top: 50%; left: 50%;
-    transform-origin: top left;
-    will-change: transform;
-    background: #fff;
-    box-shadow: 0 10px 60px rgba(0,0,0,0.4);
-    /* size set by JS from DECK_WIDTH/HEIGHT */
+  /* ════════ 模式 2 · 无限画廊 ════════ */
+  #ov-gallery { overflow: hidden; perspective: 2200px; perspective-origin: 50% 42%;
+    background: radial-gradient(130% 120% at 50% 38%, #1b1610, #0a0805 82%); }
+  .stage3d { --rx: 18; --rz: 9; position: absolute; inset: 0; transform-style: preserve-3d; transform: scale(1.22) rotateX(18deg) rotateZ(-9deg); transform-origin: center center; }
+  .breathe { position: absolute; inset: 0; animation: breathe 26s ease-in-out infinite; transform-origin: center center; transform-style: preserve-3d; }
+  .drift-layer { position: absolute; top: 0; left: 0; will-change: transform; animation: drift var(--driftSec, 80s) linear infinite; transform-style: preserve-3d; }
+  #ov-gallery:hover .drift-layer { animation-play-state: paused; }
+  .gallery { display: grid; }
+  #ov-gallery .card {
+    position: relative; width: 100%; aspect-ratio: 16/9; border-radius: 8px; overflow: hidden; background: #fff; cursor: pointer;
+    box-shadow: 0 14px 30px rgba(0,0,0,0.42), 0 3px 9px rgba(0,0,0,0.3);
+    transition: transform .34s cubic-bezier(.2,.7,.2,1), box-shadow .34s; transform-style: preserve-3d; will-change: transform;
   }
-  #stage iframe {
-    width: 100%;
-    height: 100%;
-    border: 0;
-    display: block;
-    background: #fff;
+  #ov-gallery .card:hover {
+    transform: translateZ(60px) rotateX(calc(var(--rx,18)*-1deg)) rotateZ(calc(var(--rz,9)*1deg)) scale(1.06);
+    box-shadow: 0 30px 64px rgba(0,0,0,0.55), 0 8px 20px rgba(0,0,0,0.4); z-index: 30;
   }
-  .counter {
-    position: fixed;
-    bottom: 20px;
-    right: 20px;
-    background: rgba(0,0,0,0.65);
-    color: #fff;
-    padding: 6px 14px;
-    border-radius: 999px;
-    font-size: 13px;
-    letter-spacing: 0.05em;
-    font-variant-numeric: tabular-nums;
-    z-index: 100;
-    user-select: none;
-    opacity: 0.7;
-    transition: opacity 0.2s;
-  }
-  .counter:hover { opacity: 1; }
+  .vignette { position: fixed; inset: 0; pointer-events: none; z-index: 20; box-shadow: inset 0 0 240px 70px rgba(8,6,3,0.62); }
+  @keyframes drift { from { transform: translate3d(0,0,0); } to { transform: translate3d(calc(var(--dx)*-1), calc(var(--dy)*-1), 0); } }
+  @keyframes breathe { 0%,100% { transform: scale(1.0); } 50% { transform: scale(1.045); } }
+
+  /* 卡片通用内容 */
+  .card .thumb { position: absolute; top: 0; left: 0; width: 1920px; height: 1080px; transform-origin: top left; pointer-events: none; }
+  .card .thumb iframe { width: 1920px; height: 1080px; border: 0; display: block; background: #fff; pointer-events: none; }
+  .card .thumb-img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; display: block; pointer-events: none; }
+  .card .num { position: absolute; bottom: 6px; left: 7px; background: rgba(16,12,7,0.76); color: #fff; font-size: 11px; line-height: 1; padding: 4px 7px; border-radius: 5px; font-variant-numeric: tabular-nums; letter-spacing: 0.03em; z-index: 3; pointer-events: none; max-width: 90%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+  #ov-gallery .card .num { opacity: 0; transition: opacity .25s; }
+  #ov-gallery .card:hover .num { opacity: 1; }
+
+  .overview-title { position: fixed; top: 24px; left: 0; right: 0; text-align: center; font-size: 13px; letter-spacing: 0.22em; text-transform: uppercase; z-index: 40; pointer-events: none; }
+  body[data-ov="grid"]    .overview-title { color: #5a5346; opacity: 0.66; }
+  body[data-ov="gallery"] .overview-title { color: rgba(244,238,224,0.7); text-shadow: 0 2px 12px rgba(0,0,0,0.6); }
+
+  .start-btn { position: fixed; bottom: 26px; right: 26px; border: 0; padding: 13px 24px; border-radius: 999px; font-size: 15px; font-family: inherit; letter-spacing: 0.04em; cursor: pointer; z-index: 200; transition: transform .18s, box-shadow .18s; }
+  body[data-ov="grid"]    .start-btn { background: #1a1712; color: #fff; box-shadow: 0 8px 28px rgba(40,34,24,0.28); }
+  body[data-ov="gallery"] .start-btn { background: #f4eee0; color: #1a1712; box-shadow: 0 10px 30px rgba(0,0,0,0.4); }
+  .start-btn:hover { transform: translateY(-2px); }
+
+  /* ════════ 演示模式 ════════ */
+  #stage { position: fixed; top: 50%; left: 50%; transform-origin: top left; will-change: transform; background: #fff; box-shadow: 0 10px 60px rgba(0,0,0,0.4); }
+  #stage iframe { width: 100%; height: 100%; border: 0; display: block; background: #fff; }
+  .counter { position: fixed; bottom: 20px; right: 20px; background: rgba(0,0,0,0.65); color: #fff; padding: 6px 14px; border-radius: 999px; font-size: 13px; letter-spacing: 0.05em; font-variant-numeric: tabular-nums; z-index: 100; opacity: 0.7; }
   .counter .label { color: rgba(255,255,255,0.7); margin-left: 8px; }
-  .overview-btn {
-    position: fixed;
-    top: 20px; left: 20px;
-    background: rgba(0,0,0,0.55);
-    color: rgba(255,255,255,0.85);
-    border: 0;
-    padding: 7px 14px;
-    border-radius: 999px;
-    font-size: 13px;
-    font-family: inherit;
-    letter-spacing: 0.04em;
-    cursor: pointer;
-    z-index: 100;
-    opacity: 0.6;
-    transition: opacity 0.2s;
-  }
+  .overview-btn { position: fixed; top: 20px; left: 20px; background: rgba(0,0,0,0.55); color: rgba(255,255,255,0.85); border: 0; padding: 7px 14px; border-radius: 999px; font-size: 13px; font-family: inherit; cursor: pointer; z-index: 100; opacity: 0.6; }
   .overview-btn:hover { opacity: 1; }
-  .nav-zone {
-    position: fixed;
-    top: 0; bottom: 0;
-    width: 15%;
-    cursor: pointer;
-    z-index: 50;
-  }
-  .nav-zone.left  { left: 0; }
-  .nav-zone.right { right: 0; }
-  .nav-hint {
-    position: absolute;
-    top: 50%; transform: translateY(-50%);
-    width: 44px; height: 44px;
-    border-radius: 999px;
-    background: rgba(255,255,255,0.08);
-    color: rgba(255,255,255,0.6);
-    display: flex;
-    align-items: center;
-    justify-content: center;
-    font-size: 22px;
-    opacity: 0;
-    transition: opacity 0.2s;
-  }
-  .nav-zone.left  .nav-hint { left: 20px; }
-  .nav-zone.right .nav-hint { right: 20px; }
+  .nav-zone { position: fixed; top: 0; bottom: 0; width: 15%; cursor: pointer; z-index: 50; }
+  .nav-zone.left { left: 0; } .nav-zone.right { right: 0; }
+  .nav-hint { position: absolute; top: 50%; transform: translateY(-50%); width: 44px; height: 44px; border-radius: 999px; background: rgba(255,255,255,0.08); color: rgba(255,255,255,0.6); display: flex; align-items: center; justify-content: center; font-size: 22px; opacity: 0; transition: opacity .2s; }
+  .nav-zone.left .nav-hint { left: 20px; } .nav-zone.right .nav-hint { right: 20px; }
   .nav-zone:hover .nav-hint { opacity: 1; }
 
-  /* Print: one slide per page, no navigation UI */
   @media print {
     @page { size: 1920px 1080px; margin: 0; }
     html, body { background: #fff; overflow: visible; height: auto; }
-    body[data-mode] #overview { display: none !important; }
+    body[data-mode] .overview { display: none !important; }
     #stage { position: static; transform: none !important; box-shadow: none; }
     .counter, .nav-zone, .overview-btn, .start-btn { display: none !important; }
-    /* In print mode we render all slides sequentially — see JS */
     .print-stack { display: block; }
-    .print-stack iframe {
-      width: 1920px;
-      height: 1080px;
-      page-break-after: always;
-      display: block;
-    }
+    .print-stack iframe { width: 1920px; height: 1080px; page-break-after: always; display: block; }
   }
 </style>
 </head>
 <body data-mode="overview">
 
-<!-- ── 模式 A · 概览墙 ─────────────────────────────────────── -->
-<div id="overview">
-  <div id="overview-title">Overview · 点击任意页进入演示</div>
-  <div class="wall" id="wall"></div>
+<div id="ov-grid" class="overview">
+  <div class="overview-title">Overview · 点击任意页进入演示</div>
+  <div class="wall-wrap"><div class="wall" id="wall"></div></div>
 </div>
+
+<div id="ov-gallery" class="overview">
+  <div class="stage3d"><div class="breathe"><div class="drift-layer" id="driftLayer"><div class="gallery" id="gallery"></div></div></div></div>
+  <div class="vignette"></div>
+  <div class="overview-title">Infinite Gallery · 悬停暂停 · 点击任意页进入演示</div>
+</div>
+
 <button class="start-btn" id="startBtn">▶ 开始演示</button>
 
-<!-- ── 模式 B · 全屏演示 ───────────────────────────────────── -->
 <div id="present-ui">
-  <div id="stage">
-    <iframe id="frame" src="about:blank"></iframe>
-  </div>
+  <div id="stage"><iframe id="frame" src="about:blank"></iframe></div>
   <button class="overview-btn" id="overviewBtn">⊞ 概览</button>
   <div class="nav-zone left"  id="navL"><div class="nav-hint">‹</div></div>
   <div class="nav-zone right" id="navR"><div class="nav-hint">›</div></div>
   <div class="counter" id="counter">1 / 1</div>
 </div>
 
-<!-- Print-only stack: populated on beforeprint, stripped on afterprint -->
 <div class="print-stack" id="printStack" style="display:none;"></div>
 
 <script>
@@ -288,159 +150,189 @@
   const W = window.DECK_WIDTH || 1920;
   const H = window.DECK_HEIGHT || 1080;
   const deck = window.DECK_MANIFEST || [];
+  const CARD_W = window.GALLERY_CARD_W || 300;
+  const DRIFT_SEC = window.GALLERY_DRIFT_SECONDS || 80;
   const stage = document.getElementById('stage');
   const frame = document.getElementById('frame');
   const counter = document.getElementById('counter');
   const printStack = document.getElementById('printStack');
   const wall = document.getElementById('wall');
+  const gallery = document.getElementById('gallery');
+  const driftLayer = document.getElementById('driftLayer');
   const storageKey = 'deck-index-' + location.pathname;
   let current = 0;
 
-  stage.style.width  = W + 'px';
-  stage.style.height = H + 'px';
+  stage.style.width = W + 'px'; stage.style.height = H + 'px';
+  driftLayer.style.setProperty('--driftSec', DRIFT_SEC + 's');
+
+  // 概览模式:URL ?ov= > window.DECK_OVERVIEW > 随机(秒%5==0 → 20% 画廊)
+  const qp = new URLSearchParams(location.search).get('ov');
+  // 用户未指定时按秒数随机:网格(iframe 真页面) 60% / 无限画廊(图片缩略图) 40%。可用 ?ov=grid|gallery 或 window.DECK_OVERVIEW 固定。
+  const OVERVIEW = qp || window.DECK_OVERVIEW || ((new Date().getSeconds() % 5 < 2) ? 'gallery' : 'grid');
+  document.body.setAttribute('data-ov', OVERVIEW === 'gallery' ? 'gallery' : 'grid');
 
-  /* ── 模式切换 ─────────────────────────────────────────── */
   function setMode(mode) {
     document.body.setAttribute('data-mode', mode);
     if (mode === 'present') { fit(); show(current); }
+    else { requestAnimationFrame(buildOverview); }
   }
 
-  /* ════════ 模式 A · 概览墙 ════════ */
-  function buildWall() {
-    wall.innerHTML = '';
-    deck.forEach((item, i) => {
-      const card = document.createElement('div');
-      card.className = 'card';
-      card.dataset.idx = i;
-
+  /* ─ 媒体(缩略图优先,否则 iframe) ─ */
+  function makeCard(idx, useScale, useImg) {
+    const card = document.createElement('div');
+    card.className = 'card'; card.dataset.idx = idx;
+    const item = deck[idx];
+    if (useImg && item.thumb) {
+      // 仅画廊用:预渲染缩略图,扛无限平铺的性能
+      const im = document.createElement('img');
+      im.className = 'thumb-img'; im.src = item.thumb; im.decoding = 'async';
+      card.appendChild(im);
+    } else {
+      // 网格默认用:真实 HTML 子页面(清晰、所见即所得)
       const thumb = document.createElement('div');
       thumb.className = 'thumb';
+      if (useScale != null) thumb.style.transform = 'scale(' + useScale + ')';
       const ifr = document.createElement('iframe');
-      ifr.src = item.file;
-      ifr.setAttribute('scrolling', 'no');
-      thumb.appendChild(ifr);
+      ifr.src = item.file; ifr.setAttribute('scrolling', 'no');
+      thumb.appendChild(ifr); card.appendChild(thumb);
+    }
+    const num = document.createElement('div');
+    num.className = 'num';
+    num.textContent = (idx + 1) + (item.label ? ' · ' + item.label : '');
+    card.appendChild(num);
+    card.addEventListener('click', () => { current = idx; setMode('present'); });
+    return card;
+  }
 
-      const num = document.createElement('div');
-      num.className = 'num';
-      num.textContent = (i + 1) + (item.label ? ' · ' + item.label : '');
+  function buildOverview() {
+    if (document.body.getAttribute('data-mode') !== 'overview') return;
+    if (OVERVIEW === 'gallery') buildGallery(); else buildGrid();
+  }
 
-      card.appendChild(thumb);
-      card.appendChild(num);
-      card.addEventListener('click', () => { current = i; setMode('present'); });
-      wall.appendChild(card);
-    });
-    scaleThumbs();
+  /* ════════ 网格墙(自适应) ════════ */
+  function buildGrid() {
+    const n = deck.length; if (!n) return;
+    wall.innerHTML = '';
+    for (let i = 0; i < n; i++) wall.appendChild(makeCard(i, null, false));  // 网格 = iframe 真页面
+    layoutWall();
+  }
+  function layoutWall() {
+    const n = deck.length; if (!n) return;
+    const vw = innerWidth, vh = innerHeight, gap = 26;          // 大间距:倾斜后行也绝不重叠 → 每张可点
+    const target = 290;
+    let cols = Math.max(3, Math.min(9, Math.floor((vw * 0.94 + gap) / (target + gap))));
+    cols = Math.min(cols, n);
+    const rows = Math.ceil(n / cols);
+    const cardW = Math.min(target + 40, (vw * 0.94 - (cols - 1) * gap) / cols);
+    const cardH = cardW * 9 / 16;
+    const baseW = cols * cardW + (cols - 1) * gap;
+    const wallH = rows * cardH + (rows - 1) * gap;
+    wall.style.gridTemplateColumns = 'repeat(' + cols + ', 1fr)';
+    wall.style.gap = gap + 'px';
+    wall.style.width = baseW + 'px';
+    if (wallH <= vh * 0.80) {
+      // 一屏装得下:对角倾斜(rotateZ 主导 + 克制 rotateX,大 perspective→几乎不前后压缩→不重叠),居中铺满
+      document.body.setAttribute('data-grid', 'fit');
+      const rx = Math.max(9, Math.min(15, 15 - (rows - 2) * 1.4));
+      const rz = Math.max(5, Math.min(9, 9 - (rows - 2) * 0.8));
+      wall.style.setProperty('--rx', rx); wall.style.setProperty('--rz', rz);
+      const tilt = 'rotateX(' + rx + 'deg) rotateZ(-' + rz + 'deg)';
+      wall.style.transform = tilt;
+      const r = wall.getBoundingClientRect();
+      let s = Math.min(vw * 0.92 / r.width, vh * 0.80 / r.height);
+      s = Math.max(0.6, Math.min(s, 1.6));
+      wall.style.transform = 'scale(' + s + ') ' + tilt;
+    } else {
+      // 页太多:舒适大小 + 竖向滚动;仅轻 rotateX 后仰(rotateZ 会让高墙横向裁切),平铺不重叠 → 稳定可点
+      document.body.setAttribute('data-grid', 'scroll');
+      wall.style.setProperty('--rx', 12); wall.style.setProperty('--rz', 0);
+      wall.style.transform = 'rotateX(12deg)';
+    }
+    wall.querySelectorAll('.card .thumb').forEach(t => { const c = t.parentElement; if (c.clientWidth) t.style.transform = 'scale(' + (c.clientWidth / W) + ')'; });
   }
 
-  // 把 1920×1080 的缩略 iframe 缩放到卡片实际宽度
-  function scaleThumbs() {
-    document.querySelectorAll('.card').forEach(card => {
-      const thumb = card.querySelector('.thumb');
-      if (!thumb) return;
-      const w = card.clientWidth;
-      if (!w) return;
-      thumb.style.transform = 'scale(' + (w / W) + ')';
-    });
+  /* ════════ 无限画廊(洗牌不重复) ════════ */
+  function mulberry32(a) { return function () { a |= 0; a = a + 0x6D2B79F5 | 0; let t = Math.imul(a ^ a >>> 15, 1 | a); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; return ((t ^ t >>> 14) >>> 0) / 4294967296; }; }
+  function shuffledRange(n, seed) { const rnd = mulberry32(seed); const a = Array.from({ length: n }, (_, i) => i); for (let i = n - 1; i > 0; i--) { const j = Math.floor(rnd() * (i + 1)); const tmp = a[i]; a[i] = a[j]; a[j] = tmp; } return a; }
+
+  function buildGallery() {
+    const n = deck.length; if (!n) return;
+    const vw = innerWidth, vh = innerHeight, gap = 18;
+    const cardW = CARD_W, cardH = cardW * 9 / 16;
+    const cellW = cardW + gap, cellH = cardH + gap;
+    const visCols = Math.ceil(vw / cellW), visRows = Math.ceil(vh / cellH);
+    // tile:宽够铺满;行数取「够铺满」与「装下全部 N 页」的较大者 → 看完所有页才重复
+    const tileCols = visCols + 1;
+    const tileRows = Math.max(visRows + 1, Math.ceil(n / tileCols));
+    const tileCells = tileCols * tileRows;
+    const perm = shuffledRange(n, 0x9e3779b9);        // 洗牌一次,打散规则感
+    const tilePage = new Array(tileCells);
+    for (let k = 0; k < tileCells; k++) tilePage[k] = perm[k % n];
+
+    const cols = tileCols * 2, rows = tileRows * 2;    // 2× tile 保证无缝漂移
+    driftLayer.style.setProperty('--dx', (tileCols * cellW) + 'px');
+    driftLayer.style.setProperty('--dy', (tileRows * cellH) + 'px');
+    gallery.style.gridTemplateColumns = 'repeat(' + cols + ', ' + cardW + 'px)';
+    gallery.style.gridAutoRows = cardH + 'px';
+    gallery.style.gap = gap + 'px';
+    gallery.innerHTML = '';
+    const scale = cardW / W;
+    for (let r = 0; r < rows; r++) {
+      for (let c = 0; c < cols; c++) {
+        const idx = tilePage[(r % tileRows) * tileCols + (c % tileCols)];
+        gallery.appendChild(makeCard(idx, scale, true));  // 画廊 = 图片缩略图(性能)
+      }
+    }
   }
 
-  /* ════════ 模式 B · 全屏演示 ════════ */
+  /* ════════ 演示 ════════ */
   function fit() {
-    const s = Math.min(window.innerWidth / W, window.innerHeight / H);
-    const x = (window.innerWidth  - W * s) / 2;
-    const y = (window.innerHeight - H * s) / 2;
-    stage.style.transform = `translate(${x}px, ${y}px) scale(${s})`;
-    stage.style.top = '0';
-    stage.style.left = '0';
+    const s = Math.min(innerWidth / W, innerHeight / H);
+    stage.style.transform = 'translate(' + ((innerWidth - W * s) / 2) + 'px, ' + ((innerHeight - H * s) / 2) + 'px) scale(' + s + ')';
+    stage.style.top = '0'; stage.style.left = '0';
   }
-
   function show(idx) {
     if (idx < 0 || idx >= deck.length) return;
     current = idx;
-    const wanted = deck[idx].file;
-    if (frame.getAttribute('src') !== wanted) frame.src = wanted;
-    counter.innerHTML = `${idx + 1} / ${deck.length} <span class="label">${deck[idx].label || ''}</span>`;
+    if (frame.getAttribute('src') !== deck[idx].file) frame.src = deck[idx].file;
+    counter.innerHTML = (idx + 1) + ' / ' + deck.length + ' <span class="label">' + (deck[idx].label || '') + '</span>';
     try { localStorage.setItem(storageKey, String(idx)); } catch (_) {}
-    if (location.hash !== '#' + (idx + 1)) {
-      history.replaceState(null, '', '#' + (idx + 1));
-    }
+    if (location.hash !== '#' + (idx + 1)) history.replaceState(null, '', '#' + (idx + 1));
   }
-
   function next() { show(Math.min(current + 1, deck.length - 1)); }
   function prev() { show(Math.max(current - 1, 0)); }
 
-  // Keyboard
   document.addEventListener('keydown', (e) => {
     if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
-    // ESC 回概览(演示模式下)
-    if (e.key === 'Escape') {
-      if (document.body.getAttribute('data-mode') === 'present') { e.preventDefault(); setMode('overview'); }
-      return;
-    }
+    if (e.key === 'Escape') { if (document.body.getAttribute('data-mode') === 'present') { e.preventDefault(); setMode('overview'); } return; }
     if (e.key === 'p' || e.key === 'P') { window.print(); return; }
-    // 其余翻页键仅在演示模式生效
     if (document.body.getAttribute('data-mode') !== 'present') return;
     switch (e.key) {
       case 'ArrowRight': case ' ': case 'PageDown': e.preventDefault(); next(); break;
-      case 'ArrowLeft':  case 'PageUp':              e.preventDefault(); prev(); break;
-      case 'Home':                                    e.preventDefault(); show(0); break;
-      case 'End':                                     e.preventDefault(); show(deck.length - 1); break;
-      default:
-        if (e.key >= '1' && e.key <= '9') {
-          const i = parseInt(e.key, 10) - 1;
-          if (i < deck.length) { e.preventDefault(); show(i); }
-        }
+      case 'ArrowLeft': case 'PageUp': e.preventDefault(); prev(); break;
+      case 'Home': e.preventDefault(); show(0); break;
+      case 'End': e.preventDefault(); show(deck.length - 1); break;
+      default: if (e.key >= '1' && e.key <= '9') { const i = parseInt(e.key, 10) - 1; if (i < deck.length) { e.preventDefault(); show(i); } }
     }
   });
-
   document.getElementById('navL').addEventListener('click', prev);
   document.getElementById('navR').addEventListener('click', next);
   document.getElementById('startBtn').addEventListener('click', () => setMode('present'));
   document.getElementById('overviewBtn').addEventListener('click', () => setMode('overview'));
-  window.addEventListener('resize', () => { fit(); scaleThumbs(); });
-  window.addEventListener('hashchange', () => {
-    const m = location.hash.match(/^#(\d+)$/);
-    if (m) { current = parseInt(m[1], 10) - 1; setMode('present'); }
-  });
+  let rT;
+  window.addEventListener('resize', () => { fit(); clearTimeout(rT); rT = setTimeout(buildOverview, 140); });
+  window.addEventListener('hashchange', () => { const m = location.hash.match(/^#(\d+)$/); if (m) { current = parseInt(m[1], 10) - 1; setMode('present'); } });
 
-  // 起始页:hash > localStorage > 0(决定 current;hash 还会直接进演示)
   const hashMatch = location.hash.match(/^#(\d+)$/);
-  if (hashMatch) {
-    current = Math.min(parseInt(hashMatch[1], 10) - 1, deck.length - 1);
-  } else {
-    try {
-      const v = parseInt(localStorage.getItem(storageKey), 10);
-      if (!isNaN(v) && v >= 0 && v < deck.length) current = v;
-    } catch (_) {}
-  }
+  if (hashMatch) current = Math.min(parseInt(hashMatch[1], 10) - 1, deck.length - 1);
+  else { try { const v = parseInt(localStorage.getItem(storageKey), 10); if (!isNaN(v) && v >= 0 && v < deck.length) current = v; } catch (_) {} }
 
-  // 先把演示舞台准备好(即便默认在概览,CSS 隐藏它)
-  fit();
-  show(current);
+  fit(); show(current);
+  if (hashMatch) setMode('present'); else { setMode('overview'); buildOverview(); }
 
-  buildWall();
-
-  // 默认进概览;但若 URL 带 #N,直接进演示从该页开始
-  if (hashMatch) setMode('present');
-  else setMode('overview');
-
-  // Print: build a stack of all iframes so browser prints every slide
-  window.addEventListener('beforeprint', () => {
-    printStack.innerHTML = '';
-    deck.forEach(item => {
-      const f = document.createElement('iframe');
-      f.src = item.file;
-      printStack.appendChild(f);
-    });
-    printStack.style.display = 'block';
-    stage.style.display = 'none';
-  });
-  window.addEventListener('afterprint', () => {
-    printStack.innerHTML = '';
-    printStack.style.display = 'none';
-    stage.style.display = '';
-  });
+  window.addEventListener('beforeprint', () => { printStack.innerHTML = ''; deck.forEach(item => { const f = document.createElement('iframe'); f.src = item.file; printStack.appendChild(f); }); printStack.style.display = 'block'; stage.style.display = 'none'; });
+  window.addEventListener('afterprint', () => { printStack.innerHTML = ''; printStack.style.display = 'none'; stage.style.display = ''; });
 })();
 </script>
-
 </body>
 </html>

+ 19 - 0
references/slide-decks.md

@@ -291,6 +291,25 @@ window.DECK_MANIFEST = [
 
 拼接器已内置:键盘导航(←/→/Home/End/数字键/P 打印)、scale + letterbox、右下计数器、localStorage 记忆、hash 跳页、打印模式(遍历 iframe 按页输出 PDF)。
 
+#### 两种概览模式(自适应 + 防踩坑,2026-06 重写)
+
+打开 deck 默认进**概览**,用户未指定时按秒数随机:**网格 grid 60% / 无限画廊 gallery 40%**(可用 URL `?ov=grid|gallery` 或 `window.DECK_OVERVIEW='grid'|'gallery'` 固定)。
+
+- **网格 grid(默认主力)**:用 **iframe 渲染真实子页面**(清晰、所见即所得、无需缩略图)。**自适应**:能一屏放下→对角倾斜居中铺满;页多放不下→卡片保持舒适大小、**竖向滚动**(绝不把几十页硬塞一屏缩成邮票)。
+- **无限画廊 gallery**:所有页**无缝无限平铺 + 缓慢漂移 + 轻微呼吸缩放**,一个 tile 含全部页(洗牌排布,看完所有页才重复)。瓦片多,**必须用 `<img>` 缩略图**扛性能(见下),没 thumb 时回退 iframe。
+
+🛑 **三条来自实战的硬约束(改这个文件前必读,否则会重蹈覆辙)**:
+1. **概览墙绝不用 `transform-style: preserve-3d` 做卡片墙**。preserve-3d 的 3D 场景里浏览器对「往后退的卡片」(顶排)命中测试不可靠 → 顶排点不到、中排时好时坏。**正解**:整墙作**单个被 3D 倾斜的平面**(不开 preserve-3d),所有卡片共面,点击反投影到一个平面 → 可靠。hover 用 2D `scale` 不用 `translateZ`。
+2. **任意页数都要自适应**:固定列数 + 给整墙写死强倾斜,页一多就溢出塌角/透视失真。必须按页数+视口算列数、行多则倾斜变平、一屏放不下就滚动。
+3. **缩略图分辨率别太低**:画廊缩略图 < 1000px,hover 放大后发虚。默认 1600px。
+
+**为画廊生成缩略图**:用 `scripts/gen_deck_thumbs.mjs`(playwright 截每页 + sharp 降采样):
+```bash
+npm install playwright sharp
+node gen_deck_thumbs.mjs --slides slides --out thumbs --width 1600
+```
+然后给 MANIFEST 每项加 `thumb: "thumbs/<同名>.jpg"`。网格模式忽略 thumb(始终 iframe),只有画廊模式用它。
+
 ### 单页验证(这是多文件架构的杀手级优势)
 
 每张 slide 都是独立 HTML。**做完一张就在浏览器双击打开看**:

+ 55 - 0
scripts/gen_deck_thumbs.mjs

@@ -0,0 +1,55 @@
+#!/usr/bin/env node
+/**
+ * gen_deck_thumbs.mjs — 为多文件 deck 每页生成缩略图(给 deck_index.html 的「无限画廊」概览用)。
+ *
+ * 背景:deck_index.html 有两种概览——
+ *   · 网格 grid(默认 60%):用 iframe 渲染真实子页面,清晰、所见即所得,无需缩略图。
+ *   · 无限画廊 gallery(40%):把所有页无缝无限平铺 + 缓慢漂移,几十~上百个瓦片若都用 iframe 会很卡,
+ *     所以画廊改用 <img> 缩略图——同一张图复用多次浏览器只解码一次,流畅。
+ *   本脚本就是给画廊准备这批缩略图。grid 模式不需要它。
+ *
+ * 用法(复制到 deck 项目根目录,装依赖后运行):
+ *   npm install playwright sharp
+ *   node gen_deck_thumbs.mjs --slides slides --out thumbs [--width 1600] [--quality 86]
+ *
+ * 然后在 index.html 的 MANIFEST 给每项加 thumb(与 file 同名 .jpg):
+ *   { file: "slides/01-cover.html", thumb: "thumbs/01-cover.jpg", label: "封面" }
+ * deck_index.html 仅在画廊模式用 thumb;网格模式始终用 file(iframe)。没有 thumb 时画廊回退 iframe。
+ *
+ * 提示:缩略图分辨率别太低(默认 1600px),否则画廊里卡片 hover 放大后会发虚。
+ */
+import { chromium } from 'playwright';
+import sharp from 'sharp';
+import fs from 'fs';
+import path from 'path';
+
+const arg = (n, d) => { const i = process.argv.indexOf('--' + n); return i > -1 && process.argv[i + 1] ? process.argv[i + 1] : d; };
+const slidesDir = arg('slides', 'slides');
+const outDir = arg('out', 'thumbs');
+const width = parseInt(arg('width', '1600'), 10);
+const quality = parseInt(arg('quality', '86'), 10);
+const W = parseInt(arg('canvas-w', '1920'), 10);
+const H = parseInt(arg('canvas-h', '1080'), 10);
+
+if (!fs.existsSync(slidesDir)) { console.error('找不到 slides 目录: ' + slidesDir); process.exit(1); }
+fs.mkdirSync(outDir, { recursive: true });
+const files = fs.readdirSync(slidesDir).filter(f => f.endsWith('.html')).sort();
+if (!files.length) { console.error('slides 目录里没有 .html'); process.exit(1); }
+
+const browser = await chromium.launch();
+const page = await browser.newPage({ viewport: { width: W, height: H }, deviceScaleFactor: 1 });
+let ok = 0;
+for (const f of files) {
+  const base = f.replace(/\.html$/, '');
+  const out = path.join(outDir, base + '.jpg');
+  try {
+    await page.goto('file://' + path.resolve(slidesDir, f), { waitUntil: 'load' });
+    await page.waitForTimeout(2800);                 // 等 webfont / 图片 paint
+    const buf = await page.screenshot({ type: 'png', clip: { x: 0, y: 0, width: W, height: H } });
+    await sharp(buf).resize(width).jpeg({ quality }).toFile(out);
+    ok++; console.log('[ok] ' + out);
+  } catch (e) { console.error('[FAIL] ' + f + ': ' + e.message); }
+}
+await browser.close();
+console.log(`\n=== ${ok}/${files.length} 张缩略图 → ${outDir}/ ===`);
+console.log('在 index.html 的 MANIFEST 每项加 thumb: "' + outDir + '/<同名>.jpg"(仅画廊模式用到)');