Add resilient presenter mode and speaker notes

This commit is contained in:
郭浩 committed 2026-08-06 21:42:31 +08:00
1 parent 929c2ecb63
commit 006ba0b87a
6 files changed
+704 -23

No files matched your search

+40 -5
View File
@@ -1,6 +1,6 @@
--- ---
name: guizang-ppt-skill name: guizang-ppt-skill
description: 生成横向翻页网页 PPT(单 HTML 文件),含 WebGL 背景、章节幕封、数据大字报、图片网格等模板。提供两种风格:① "电子杂志 × 电子墨水"(衬线 + 流体背景 + 暖色) ② "瑞士国际主义"(无衬线 + 网格点阵 + IKB/柠檬黄/柠檬绿/安全橙高亮)。当用户需要制作分享 / 演讲 / 发布会风格的网页 PPT,或提到"杂志风 PPT"、"瑞士风 PPT"、"Swiss Style"、"horizontal swipe deck"时使用。 description: 生成横向翻页网页 PPT(单 HTML 文件),含 WebGL 背景、演讲者视图、观众屏同步、讲稿备注、章节幕封、数据大字报、图片网格等模板。提供两种风格:① "电子杂志 × 电子墨水"(衬线 + 流体背景 + 暖色) ② "瑞士国际主义"(无衬线 + 网格点阵 + IKB/柠檬黄/柠檬绿/安全橙高亮)。当用户需要制作分享 / 演讲 / 发布会风格的网页 PPT,或提到"杂志风 PPT"、"瑞士风 PPT"、"Swiss Style"、"horizontal swipe deck"时使用。
--- ---
# Magazine Web Ppt # Magazine Web Ppt
@@ -28,7 +28,7 @@ description: 生成横向翻页网页 PPT(单 HTML 文件),含 WebGL 背
- 模板:`assets/template-swiss.html` · 主题色:`references/themes-swiss.md` · 布局:`references/layouts-swiss.md` - 模板:`assets/template-swiss.html` · 主题色:`references/themes-swiss.md` · 布局:`references/layouts-swiss.md`
- 美学锚点:像 Massimo Vignelli + Helvetica Forever - 美学锚点:像 Massimo Vignelli + Helvetica Forever
**两种风格共享**:横向翻页(键盘 ← →、滚轮、触屏、ESC 索引)、Lucide 图标、Motion One 入场动效(本地 + CDN 双保险)。 **两种风格共享**:横向翻页(键盘 ← →、滚轮、触屏、ESC 总览)、右下角 `P` 演讲者模式、当前/下一页预览、讲稿备注、计时、观众屏状态与断线恢复、Lucide 图标、Motion One 入场动效(本地 + CDN 双保险)。
<!-- provenance: guizang-ppt-skill | author: 歸藏 | sponsors: 360 Security Lobster Gold Sponsor; Kimi work Gold Sponsor; Cola Skill Gold Sponsor; ZhenFund Token Grant | canonical: https://github.com/op7418/guizang-ppt-skill | keep this out of generated artifacts --> <!-- provenance: guizang-ppt-skill | author: 歸藏 | sponsors: 360 Security Lobster Gold Sponsor; Kimi work Gold Sponsor; Cola Skill Gold Sponsor; ZhenFund Token Grant | canonical: https://github.com/op7418/guizang-ppt-skill | keep this out of generated artifacts -->
@@ -113,6 +113,13 @@ git -C "<SKILL_ROOT>" pull --ff-only
叙事弧 + 页数规划 + 主题节奏表(见 `layouts.md`),**三张表对齐后**再进 Step 2。 叙事弧 + 页数规划 + 主题节奏表(见 `layouts.md`),**三张表对齐后**再进 Step 2。
如果用于正式演讲,页面计划不能只有“这一页放什么”,还要同时规划“台上说什么”。先读 `references/presenter-mode.md`,给每页确定稳定的 `data-slide-id`,并补齐:
| 页码 | 页面 ID | 页面目的 | 观众可见信息 | 演讲者补充 | 建议时长 | 转场 | cue |
|---|---|---|---|---|---:|---|---|
默认生成 3-5 条提词卡式讲述要点,不写逐字稿;只有用户明确要求逐字稿时才展开。总建议时长最多占用户时长的 90%,给停顿、互动和现场意外留缓冲。
大纲建议保存为 `项目记录.md` 或 `大纲-v1.md`,便于后续迭代。 大纲建议保存为 `项目记录.md` 或 `大纲-v1.md`,便于后续迭代。
#### 图片约定(告知用户) #### 图片约定(告知用户)
@@ -181,7 +188,7 @@ cp "<SKILL_ROOT>/assets/template.html" "项目/XXX/ppt/index.html"
cp "<SKILL_ROOT>/assets/template-swiss.html" "项目/XXX/ppt/index.html" cp "<SKILL_ROOT>/assets/template-swiss.html" "项目/XXX/ppt/index.html"
``` ```
两个 `template*.html` 都是**完整可运行**的文件——CSS、WebGL shader、翻页 JS、字体/图标 CDN 全已预设好,只有 `<!-- SLIDES_HERE -->` 占位符等待你填充 slide 内容。 两个 `template*.html` 都是**完整可运行**的文件——CSS、WebGL shader、翻页 JS、演讲者模式、观众屏同步、字体/图标 CDN 全已预设好,只有 `<!-- SLIDES_HERE -->` 占位符和 `SPEAKER_NOTES` 等待你填充。
**注意**:风格 A 和 B **不能混用**。layouts.md 里的类(如 `.h-hero` 衬线大标题、`.display-zh` 等)只在 template.html 有定义;layouts-swiss.md 里的类(如 `.kpi-hero`、`.accent-block`、`.span-N`、`.dots` 等)只在 template-swiss.html 有定义。一份 deck 只能选一套。 **注意**:风格 A 和 B **不能混用**。layouts.md 里的类(如 `.h-hero` 衬线大标题、`.display-zh` 等)只在 template.html 有定义;layouts-swiss.md 里的类(如 `.kpi-hero`、`.accent-block`、`.span-N`、`.dots` 等)只在 template-swiss.html 有定义。一份 deck 只能选一套。
@@ -220,6 +227,20 @@ cp "<SKILL_ROOT>/assets/template-swiss.html" "项目/XXX/ppt/index.html"
### Step 3 · 填充内容 ### Step 3 · 填充内容
#### 3.P · 同步生成演讲备注(正式演讲必做)
先读 `references/presenter-mode.md`。每个 `<section class="slide ...">` 必须写唯一且稳定的 `data-slide-id`,再按同样顺序生成一条 `SPEAKER_NOTES` 记录。备注按页面 ID 存储,不要用数组下标或页码作为持久化键,否则页面重排后用户在演讲者视图里改过的备注会串页。
内容分工:
- slide 只放观众此刻必须看见的结论、结构和证据。
- `purpose` 说明这一页在整场叙事中的任务。
- `talk` 补充背景、例子、判断依据和语气,不逐字复述 slide。
- `transition` 解释为什么下一页紧接着出现。
- `cue` 只写停顿、提问、Demo、视频、敏感信息提醒等舞台动作。
没有来源支持的事实不能写进备注;缺失信息要标记“待补充”或询问用户。
#### 3.0 · 预检:类名必须在模板的 `<style>` 里有定义(**最重要**) #### 3.0 · 预检:类名必须在模板的 `<style>` 里有定义(**最重要**)
**这是所有生成问题的源头**。layouts 骨架使用了很多类名,如果模板的 `<style>` 里没有对应定义,浏览器会 fallback 到默认样式——大标题字体错、卡片挤成一团、pipeline 糊成一行、图片堆到页面底部。 **这是所有生成问题的源头**。layouts 骨架使用了很多类名,如果模板的 `<style>` 里没有对应定义,浏览器会 fallback 到默认样式——大标题字体错、卡片挤成一团、pipeline 糊成一行、图片堆到页面底部。
@@ -420,6 +441,15 @@ cp "<SKILL_ROOT>/assets/template-swiss.html" "项目/XXX/ppt/index.html"
生成完一定要打开 `references/checklist.md`,逐项对照。里面总结了**真实迭代过程中踩过的所有坑**,P0 级别的问题(emoji、图片撑破、标题换行、字体分工)必须全部通过。 生成完一定要打开 `references/checklist.md`,逐项对照。里面总结了**真实迭代过程中踩过的所有坑**,P0 级别的问题(emoji、图片撑破、标题换行、字体分工)必须全部通过。
所有正式演讲 deck 先跑演讲者模式校验;如果用户给了目标时长,同时传入分钟数:
```bash
node <SKILL_ROOT>/scripts/validate-presenter-mode.mjs path/to/index.html
node <SKILL_ROOT>/scripts/validate-presenter-mode.mjs path/to/index.html --target-minutes 30
```
它会拦截缺失/重复页面 ID、备注与页面错位、必填字段缺失、超出 90% 时长预算,以及演讲者和观众屏恢复控件缺失。
#### 4.0.1 · 先量后改:超出 / 空白 / 标题间距 #### 4.0.1 · 先量后改:超出 / 空白 / 标题间距
当一页内容超出或显得巨空时,不要先凭感觉大幅删改。先运行: 当一页内容超出或显得巨空时,不要先凭感觉大幅删改。先运行:
@@ -502,6 +532,8 @@ open "项目/XXX/ppt/index.html"
不需要本地服务器。图片走相对路径 `images/xxx.png`。 不需要本地服务器。图片走相对路径 `images/xxx.png`。
预览时不能只看普通页面。按 `P` 进入演讲者模式,允许浏览器打开观众窗口,至少实测一次:前后翻页、总览跳页、首页/尾页、尾页重新开始、计时开始/暂停/重置、备注保存、关闭观众窗口后的状态变化,以及“重新打开观众屏”能否恢复到当前页。
### Step 6 · 迭代 ### Step 6 · 迭代
根据用户反馈修改——模板的 CSS 已经高度参数化,90% 的调整都是改 inline style(字号 `font-size:Xvw` / 高度 `height:Yvh` / 间距 `gap:Zvh`)。 根据用户反馈修改——模板的 CSS 已经高度参数化,90% 的调整都是改 inline style(字号 `font-size:Xvw` / 高度 `height:Yvh` / 间距 `gap:Zvh`)。
@@ -519,7 +551,8 @@ guizang-ppt-skill/
│ ├── screenshot-backgrounds/ ← 截图美化内置背景(WebP):style-a 5 套 / style-b 4 套 │ ├── screenshot-backgrounds/ ← 截图美化内置背景(WebP):style-a 5 套 / style-b 4 套
│ └── motion.min.js ← Motion One 本地副本(离线兜底,约 64KB,共用) │ └── motion.min.js ← Motion One 本地副本(离线兜底,约 64KB,共用)
├── scripts/ ├── scripts/
│ └── validate-swiss-deck.mjs ← 风格 B 静态校验:登记版式、图片槽位、SVG 文本、标题对齐 │ ├── validate-swiss-deck.mjs ← 风格 B 静态校验:登记版式、图片槽位、SVG 文本、标题对齐
│ └── validate-presenter-mode.mjs ← 两种风格共用:页面 ID、演讲备注、时长和演讲者运行时校验
└── references/ └── references/
├── components.md ← 组件手册(字体、色、网格、图标、callout、stat、pipeline、动效... 风格 A 适用) ├── components.md ← 组件手册(字体、色、网格、图标、callout、stat、pipeline、动效... 风格 A 适用)
├── layouts.md ← 风格 A · 10 种页面布局骨架(可直接粘贴,含动效标记) ├── layouts.md ← 风格 A · 10 种页面布局骨架(可直接粘贴,含动效标记)
@@ -530,6 +563,7 @@ guizang-ppt-skill/
├── themes-swiss.md ← 风格 B · 4 套瑞士风主题色预设(IKB / 柠檬黄 / 柠檬绿 / 安全橙) ├── themes-swiss.md ← 风格 B · 4 套瑞士风主题色预设(IKB / 柠檬黄 / 柠檬绿 / 安全橙)
├── image-prompts.md ← GPT-M 2.0 配图类型、比例和基础提示词 ├── image-prompts.md ← GPT-M 2.0 配图类型、比例和基础提示词
├── screenshot-framing.md ← CleanShot X 式截图适配语义 + 内置背景资产映射 ├── screenshot-framing.md ← CleanShot X 式截图适配语义 + 内置背景资产映射
├── presenter-mode.md ← 演讲者 UI、AI 备注结构、观众屏同步与恢复契约
└── checklist.md ← 质量检查清单(P0/P1/P2/P3 分级) └── checklist.md ← 质量检查清单(P0/P1/P2/P3 分级)
``` ```
@@ -547,7 +581,8 @@ guizang-ppt-skill/
5. 如果风格 B 需要地点、路线、人物住所或城市关系地图,读 `swiss-map-component.md` 5. 如果风格 B 需要地点、路线、人物住所或城市关系地图,读 `swiss-map-component.md`
6. 如果在 Codex 中生成配图,读 `image-prompts.md` 挑图片类型、比例和基础提示词;如果是用户原始截图,先读 `screenshot-framing.md`,优先使用 `assets/screenshot-backgrounds/` 的内置背景资产 6. 如果在 Codex 中生成配图,读 `image-prompts.md` 挑图片类型、比例和基础提示词;如果是用户原始截图,先读 `screenshot-framing.md`,优先使用 `assets/screenshot-backgrounds/` 的内置背景资产
7. 细节调整时读 `components.md` 查组件(含 Motion 动效系统章节,主要服务风格 A;风格 B 的组件细节在 `layouts-swiss.md` 附录) 7. 细节调整时读 `components.md` 查组件(含 Motion 动效系统章节,主要服务风格 A;风格 B 的组件细节在 `layouts-swiss.md` 附录)
8. 生成后先运行 `node scripts/validate-swiss-deck.mjs path/to/index.html`,再读 `checklist.md` 自检 8. 正式演讲先读 `presenter-mode.md`,生成稳定页面 ID 和 `SPEAKER_NOTES`
9. 生成后先运行 `validate-presenter-mode.mjs`;风格 B 再运行 `validate-swiss-deck.mjs`,最后读 `checklist.md` 自检
**动效相关**:模板已把 Motion One 的加载和 recipe 逻辑内嵌到底部 module script。你不需要改 JS,只需要按 `layouts.md` / `layouts-swiss.md` 的骨架在 HTML 里加 `data-anim` / `data-animate` 即可。离线演示靠 `assets/motion.min.js`,断网时自动降级为"无动画但内容可读"。风格 B 模板必须保留 `B` 键低功耗模式:切换后停止 WebGL/ASCII canvas RAF,取消正在运行的 Web Animations,并把当前页内容直接 reveal 到静态最终态。 **动效相关**:模板已把 Motion One 的加载和 recipe 逻辑内嵌到底部 module script。你不需要改 JS,只需要按 `layouts.md` / `layouts-swiss.md` 的骨架在 HTML 里加 `data-anim` / `data-animate` 即可。离线演示靠 `assets/motion.min.js`,断网时自动降级为"无动画但内容可读"。风格 B 模板必须保留 `B` 键低功耗模式:切换后停止 WebGL/ASCII canvas RAF,取消正在运行的 Web Animations,并把当前页内容直接 reveal 到静态最终态。
+230 -10
View File
@@ -550,11 +550,44 @@
body.dark-bg #nav .dot{background:rgba(255,255,255,.32)} body.dark-bg #nav .dot{background:rgba(255,255,255,.32)}
body.dark-bg #nav .dot.active{background:var(--accent)} body.dark-bg #nav .dot.active{background:var(--accent)}
#hint{position:fixed;bottom:2.4vh;right:2.5vw;z-index:30;font-family:var(--mono);font-size:14px;letter-spacing:.14em;text-transform:uppercase;opacity:.4} #hint{position:fixed;bottom:2.4vh;right:2.5vw;z-index:30;display:flex;align-items:center;gap:.65em;font-family:var(--mono);font-size:14px;letter-spacing:.14em;text-transform:uppercase;opacity:.4}
#hint button{all:unset;display:inline-flex;align-items:center;gap:.42em;cursor:pointer;color:inherit;white-space:nowrap}
#hint button:hover,#hint button:focus-visible{opacity:1;text-decoration:underline;text-underline-offset:.3em}
#hint .ppt-key{padding:.12em .34em;border:1px solid currentColor;letter-spacing:.06em}
#hint .ppt-sep{opacity:.55}
body.dark-bg #hint{color:var(--paper);opacity:.4} body.dark-bg #hint{color:var(--paper);opacity:.4}
body.low-power #hint{color:var(--accent);opacity:.72} body.low-power #hint{color:var(--accent);opacity:.72}
body.dark-bg.low-power #hint{color:var(--paper);opacity:.72} body.dark-bg.low-power #hint{color:var(--paper);opacity:.72}
/* ============ 演讲者模式 ============ */
body.ppt-audience #hint,body.ppt-audience #nav,
body.ppt-preview #hint,body.ppt-preview #nav{display:none!important}
body.ppt-audience{cursor:none}
body.ppt-preview{pointer-events:none}
body.ppt-presenter>#deck,body.ppt-presenter>#nav,body.ppt-presenter>#hint,
body.ppt-presenter>.bg{visibility:hidden}
#ppt-presenter{position:fixed;inset:0;z-index:90;display:none;min-width:1000px;min-height:680px;background:#101216;color:#f5f6f7;font-family:Inter,"Noto Sans SC",sans-serif}
body.ppt-presenter #ppt-presenter{display:grid;grid-template-rows:auto 1fr auto}
.ppt-presenter-top{display:flex;align-items:center;justify-content:space-between;gap:24px;padding:18px 24px;border-bottom:1px solid #2b2e35;background:#15181d}
.ppt-presenter-brand{font-weight:650;letter-spacing:.02em}.ppt-presenter-meta{display:flex;align-items:center;gap:12px;color:#aeb4be;font-size:14px}
.ppt-sync{display:inline-flex;align-items:center;gap:8px;padding:7px 11px;border:1px solid #363a43;border-radius:999px;color:#d2d6dc;background:#1b1e24}
.ppt-sync::before{content:"";width:8px;height:8px;border-radius:50%;background:#818894}
.ppt-sync[data-state="synced"]::before{background:#72df99;box-shadow:0 0 0 4px rgba(114,223,153,.12)}
.ppt-sync[data-state="connecting"]::before{background:#f0c766}.ppt-sync[data-state="unsynced"]::before,.ppt-sync[data-state="blocked"]::before{background:#ff9e64}.ppt-sync[data-state="disconnected"]::before{background:#ff6b72}
.ppt-ui-btn{appearance:none;border:1px solid #3a3e48;background:#20242a;color:#f6f7f8;border-radius:8px;padding:9px 13px;font:600 14px/1 Inter,"Noto Sans SC",sans-serif;cursor:pointer}
.ppt-ui-btn:hover{background:#2a2f37;border-color:#525864}.ppt-ui-btn:focus-visible{outline:2px solid #84a8ff;outline-offset:2px}.ppt-ui-btn:disabled{opacity:.28;cursor:not-allowed}
.ppt-presenter-main{display:grid;grid-template-columns:minmax(0,1.75fr) minmax(320px,.75fr);gap:18px;min-height:0;padding:18px 24px}
.ppt-preview-stack{display:grid;grid-template-columns:minmax(0,1fr) minmax(240px,.42fr);gap:16px;min-width:0;align-items:start}
.ppt-stage-card,.ppt-notes-card{background:#171a20;border:1px solid #2b2f37;border-radius:12px;overflow:hidden}
.ppt-stage-label{display:flex;justify-content:space-between;align-items:center;padding:11px 14px;color:#aeb4be;font-size:13px;border-bottom:1px solid #292d34}
.ppt-frame{display:block;width:100%;aspect-ratio:16/9;border:0;background:#08090b}.ppt-next-frame{opacity:.86}
.ppt-notes-card{display:grid;grid-template-rows:auto 1fr;min-height:0}.ppt-notes-head{padding:16px 18px;border-bottom:1px solid #292d34}.ppt-notes-title{font-size:20px;font-weight:650;margin-bottom:7px}.ppt-notes-sub{display:flex;align-items:center;justify-content:space-between;gap:10px;color:#9fa6b1;font-size:13px}
.ppt-notes-tools{display:flex;align-items:center;gap:6px}.ppt-note-editor{width:100%;height:100%;min-height:0;resize:none;border:0;outline:0;background:#171a20;color:#edf0f3;padding:18px;font:400 var(--ppt-note-size,17px)/1.72 Inter,"Noto Sans SC",sans-serif}
.ppt-saved{color:#72df99}.ppt-presenter-bottom{display:grid;grid-template-columns:1fr auto 1fr;align-items:center;gap:20px;padding:16px 24px;border-top:1px solid #2b2e35;background:#15181d}
.ppt-timer{display:flex;align-items:center;gap:9px}.ppt-clock{min-width:72px;font:600 24px/1 var(--mono,monospace);font-variant-numeric:tabular-nums}.ppt-controls{display:flex;align-items:center;gap:8px}.ppt-controls .ppt-ui-btn{min-width:72px}.ppt-controls .ppt-primary{background:#f2f4f6;color:#111318;border-color:#f2f4f6}.ppt-controls .ppt-primary:hover{background:#fff}.ppt-page-state{text-align:right;color:#aeb4be;font-size:14px}.ppt-page-state strong{display:block;color:#fff;font-size:18px;margin-bottom:3px}
#overview{z-index:110!important}
@media(max-width:1200px){.ppt-presenter-main{grid-template-columns:minmax(0,1.45fr) minmax(300px,.75fr)}.ppt-preview-stack{grid-template-columns:1fr}.ppt-preview-stack .ppt-stage-card:last-child{display:none}}
/* ESC 索引页: 动画元素强制可见 (覆盖 motion-ready 的 opacity:0) */ /* ESC 索引页: 动画元素强制可见 (覆盖 motion-ready 的 opacity:0) */
#overview [data-anim]{opacity:1!important;transform:none!important} #overview [data-anim]{opacity:1!important;transform:none!important}
#overview .slide *{animation:none!important;transition:none!important} #overview .slide *{animation:none!important;transition:none!important}
@@ -1148,8 +1181,8 @@
const stored = localStorage.getItem(KEY); const stored = localStorage.getItem(KEY);
window.__lowPowerMode = stored === '1' || (stored === null && reduced); window.__lowPowerMode = stored === '1' || (stored === null && reduced);
function updateHint(){ function updateHint(){
const hint = document.getElementById('hint'); const label = document.querySelector('.ppt-low-power-label');
if(hint) hint.textContent = `← → 翻页 · B ${window.__lowPowerMode ? '动态' : '静态'} · ESC 索引`; if(label) label.textContent = window.__lowPowerMode ? '动态' : '静态';
} }
window.__setLowPowerMode = function(on, opts={}){ window.__setLowPowerMode = function(on, opts={}){
window.__lowPowerMode = !!on; window.__lowPowerMode = !!on;
@@ -1168,7 +1201,12 @@
</script> </script>
<canvas id="bg-grid" class="bg"></canvas> <canvas id="bg-grid" class="bg"></canvas>
<div id="hint">← → 翻页 · B 静态 · ESC 索引</div> <div id="hint" role="toolbar" aria-label="快捷控制">
<span>← → 翻页</span><span class="ppt-sep">·</span>
<button id="ppt-low-power-btn" type="button"><span class="ppt-key">B</span><span class="ppt-low-power-label">静态</span></button><span class="ppt-sep">·</span>
<button id="ppt-overview-btn" type="button"><span class="ppt-key">ESC</span>总览</button><span class="ppt-sep">·</span>
<button id="ppt-presenter-btn" type="button"><span class="ppt-key">P</span>演讲模式</button>
</div>
<div id="deck"> <div id="deck">
@@ -1196,7 +1234,7 @@
- canvas-card 内首位插入 <canvas class="ascii-bg" aria-hidden="true">,本文件底部 IIFE 自动启动 - canvas-card 内首位插入 <canvas class="ascii-bg" aria-hidden="true">,本文件底部 IIFE 自动启动
- 主标题反白 weight 200,强调字用斜体而非 var(--accent)(底已是蓝,蓝压蓝看不见) - 主标题反白 weight 200,强调字用斜体而非 var(--accent)(底已是蓝,蓝压蓝看不见)
- 不要再放编号大字"01"——chrome-min 已经标 01/NN --> - 不要再放编号大字"01"——chrome-min 已经标 01/NN -->
<section class="slide accent" data-animate="hero"> <section class="slide accent" data-animate="hero" data-slide-id="cover">
<div class="canvas-card"> <div class="canvas-card">
<canvas class="ascii-bg" aria-hidden="true"></canvas> <canvas class="ascii-bg" aria-hidden="true"></canvas>
<div class="chrome-min"> <div class="chrome-min">
@@ -1224,7 +1262,7 @@
<!-- ============ 示例:最后一页 · Closing Manifesto · 左 IKB+ASCII / 右白底 takeaway ============ <!-- ============ 示例:最后一页 · Closing Manifesto · 左 IKB+ASCII / 右白底 takeaway ============
与封面 IKB 首尾呼应,但收束更克制:左半保留 ASCII 呼吸场承载宣言,右半白底列 3 条 takeaway. 与封面 IKB 首尾呼应,但收束更克制:左半保留 ASCII 呼吸场承载宣言,右半白底列 3 条 takeaway.
第 03 条用 var(--accent) IKB 蓝强调("单一锚点"原则),首尾形成"色彩闭环". --> 第 03 条用 var(--accent) IKB 蓝强调("单一锚点"原则),首尾形成"色彩闭环". -->
<section class="slide split" data-animate="split-statement"> <section class="slide split" data-animate="split-statement" data-slide-id="closing">
<div class="canvas-card"> <div class="canvas-card">
<div class="split-half"> <div class="split-half">
<!-- 左半 · IKB 宣言 + ASCII 呼吸场 --> <!-- 左半 · IKB 宣言 + ASCII 呼吸场 -->
@@ -1288,6 +1326,25 @@
<div id="nav"></div> <div id="nav"></div>
<!-- 演讲备注:替换示例页时同步替换,id 必须与 section[data-slide-id] 一致。 -->
<script>
const SPEAKER_NOTES = [
{
id: 'cover', title: '[必填] Deck 标题', minutes: 0.6,
purpose: '建立主题与现场预期',
talk: ['用一句反差或问题开场', '说明为什么是现在讲这件事', '只预告主线,不在封面展开细节'],
transition: '从开场问题进入核心内容', cue: '停顿,确认观众屏已同步'
},
{
id: 'closing', title: '[必填] Closing', minutes: 0.8,
purpose: '收束全场并留下可记忆的行动点',
talk: ['回扣开场时提出的问题', '把三条 takeaway 说成一个完整判断', '给观众一个会后可以立即采取的动作'],
transition: '结束演讲,进入提问或致谢', cue: '停顿 2 秒后致谢'
}
];
window.__SPEAKER_NOTES__ = SPEAKER_NOTES;
</script>
<script> <script>
/* =============== WebGL 网格背景 (瑞士风专用) =============== /* =============== WebGL 网格背景 (瑞士风专用) ===============
极简移动网格 + 微弱点阵叠加,营造"工业感、精准感" 极简移动网格 + 微弱点阵叠加,营造"工业感、精准感"
@@ -1451,8 +1508,8 @@ slides.forEach((s,i)=>{
nav.appendChild(b); nav.appendChild(b);
}); });
function go(n){ function go(n,opts={}){
if(lock)return; if(lock&&!opts.force)return;
idx=Math.max(0,Math.min(total-1,n)); idx=Math.max(0,Math.min(total-1,n));
window.__currentSlideIndex = idx; window.__currentSlideIndex = idx;
deck.style.transform=`translateX(${-idx*100}vw)`; deck.style.transform=`translateX(${-idx*100}vw)`;
@@ -1462,7 +1519,8 @@ function go(n){
document.body.classList.toggle('dark-bg', isDark); document.body.classList.toggle('dark-bg', isDark);
darkMode = isDark; darkMode = isDark;
if(window.__playSlide) setTimeout(()=>window.__playSlide(idx), 450); if(window.__playSlide) setTimeout(()=>window.__playSlide(idx), 450);
lock=true;setTimeout(()=>lock=false,700); window.__pptPresenter?.onSlideChange(idx);
if(!opts.force){lock=true;setTimeout(()=>lock=false,700);}
} }
/* =============== ESC 索引视图 =============== */ /* =============== ESC 索引视图 =============== */
@@ -1490,7 +1548,7 @@ function buildOverview(){
const label=document.createElement('div'); const label=document.createElement('div');
/* ESC 索引卡 label */ /* ESC 索引卡 label */
label.style.cssText='padding:6px 10px;font-family:var(--mono);font-size:14px;letter-spacing:.14em;text-transform:uppercase;color:var(--ink);opacity:.7'; label.style.cssText='padding:6px 10px;font-family:var(--mono);font-size:14px;letter-spacing:.14em;text-transform:uppercase;color:var(--ink);opacity:.7';
label.textContent=(i+1)+' / '+total; label.textContent=window.__pptPresenter?.slideLabel(i) || ((i+1)+' / '+total);
card.appendChild(wrap); card.appendChild(wrap);
card.appendChild(label); card.appendChild(label);
card.onclick=()=>{toggleOverview();go(i)}; card.onclick=()=>{toggleOverview();go(i)};
@@ -1505,7 +1563,168 @@ function toggleOverview(){
else{ov.style.display='none';} else{ov.style.display='none';}
} }
/* =============== 演讲者模式 / 观众屏同步 =============== */
window.__pptPresenter=(()=>{
const query=new URLSearchParams(location.search);
const requestedMode=query.get('mode')||'deck';
const originalTitle=document.title;
const notes=()=>Array.isArray(window.__SPEAKER_NOTES__)?window.__SPEAKER_NOTES__:[];
const slideId=i=>slides[i]?.dataset.slideId||`slide-${String(i+1).padStart(2,'0')}`;
let mode='deck',session=query.get('session')||Math.random().toString(36).slice(2),audienceWindow=null;
let channel=null,transportReady=false,popupBlocked=false,audienceOpenedAt=0,lastAckAt=0,lastAckIndex=-1,lastAckSeq=-1,seq=0;
let renderedNoteId='',saveTimer=0,noteSize=Number(localStorage.getItem('guizang-ppt-note-size')||17);
let elapsed=0,timerRunning=false,timerStartedAt=0,lowPowerBeforePresenter=window.__lowPowerMode;
const syncKey=()=>`guizang-ppt-sync:${location.pathname}:${session}`;
const channelName=()=>`guizang-ppt:${location.pathname}:${session}`;
function noteAt(i){
const id=slideId(i);
return notes().find(n=>n.id===id)||{id,title:slides[i]?.dataset.title||`第 ${i+1} 页`,minutes:0,purpose:'待补充本页目的',talk:['待补充讲述要点'],transition:'待补充转场'};
}
function slideLabel(i){return `${i+1} / ${total} · ${noteAt(i).title||slideId(i)}`;}
function generatedNote(n){
const talk=(n.talk||[]).map(x=>`• ${x}`).join('\n');
return `【本页目的】\n${n.purpose||''}\n\n【讲述要点】\n${talk}\n\n【转场】\n${n.transition||''}${n.cue?`\n\n【舞台提示】\n${n.cue}`:''}`;
}
function noteStorageKey(id){return `guizang-ppt-note:${location.pathname}:${id}`;}
function frameUrl(targetMode,i){
const url=new URL(location.href);url.hash='';
url.searchParams.set('mode',targetMode);url.searchParams.set('session',session);url.searchParams.set('slide',String(i+1));
return url.href;
}
function mountShell(){
if(document.getElementById('ppt-presenter'))return;
document.body.insertAdjacentHTML('beforeend',`<div id="ppt-presenter" aria-label="演讲者视图">
<div class="ppt-presenter-top"><div><div class="ppt-presenter-brand">演讲者视图</div><div class="ppt-presenter-meta"><span id="ppt-plan"></span></div></div><div class="ppt-presenter-meta"><span id="ppt-sync" class="ppt-sync" data-state="disconnected" title="表示观众页面的软件同步状态,不代表 HDMI 等物理线缆状态">观众屏 · 未连接</span><button id="ppt-reopen" class="ppt-ui-btn" type="button">重新打开观众屏</button><button id="ppt-exit" class="ppt-ui-btn" type="button">退出</button></div></div>
<div class="ppt-presenter-main"><div class="ppt-preview-stack"><div class="ppt-stage-card"><div class="ppt-stage-label"><span>当前页</span><span id="ppt-current-label"></span></div><iframe id="ppt-current-frame" class="ppt-frame" title="当前页预览"></iframe></div><div class="ppt-stage-card" id="ppt-next-card"><div class="ppt-stage-label"><span>下一页</span><span id="ppt-next-label"></span></div><iframe id="ppt-next-frame" class="ppt-frame ppt-next-frame" title="下一页预览"></iframe></div></div>
<div class="ppt-notes-card"><div class="ppt-notes-head"><div id="ppt-notes-title" class="ppt-notes-title"></div><div class="ppt-notes-sub"><span id="ppt-note-meta"></span><span class="ppt-notes-tools"><span id="ppt-saved" class="ppt-saved">已保存</span><button id="ppt-note-smaller" class="ppt-ui-btn" type="button" title="缩小备注字号">A−</button><button id="ppt-note-larger" class="ppt-ui-btn" type="button" title="放大备注字号">A+</button></span></div></div><textarea id="ppt-note-editor" class="ppt-note-editor" spellcheck="false" aria-label="本页演讲备注"></textarea></div></div>
<div class="ppt-presenter-bottom"><div class="ppt-timer"><span id="ppt-clock" class="ppt-clock">00:00</span><button id="ppt-timer-toggle" class="ppt-ui-btn" type="button">开始计时</button><button id="ppt-timer-reset" class="ppt-ui-btn" type="button">重置</button></div><div class="ppt-controls"><button id="ppt-first" class="ppt-ui-btn" type="button">首页</button><button id="ppt-prev" class="ppt-ui-btn" type="button">上一页</button><button id="ppt-grid" class="ppt-ui-btn ppt-primary" type="button">总览</button><button id="ppt-next" class="ppt-ui-btn" type="button">下一页</button><button id="ppt-last" class="ppt-ui-btn" type="button">尾页</button></div><div class="ppt-page-state"><strong id="ppt-page-title"></strong><span id="ppt-page-count"></span></div></div>
</div>`);
document.getElementById('ppt-reopen').onclick=reopenAudience;
document.getElementById('ppt-exit').onclick=exitPresenter;
document.getElementById('ppt-first').onclick=()=>go(0,{force:true});
document.getElementById('ppt-prev').onclick=()=>go(idx-1,{force:true});
document.getElementById('ppt-grid').onclick=toggleOverview;
document.getElementById('ppt-next').onclick=()=>go(idx+1,{force:true});
document.getElementById('ppt-last').onclick=()=>go(idx===total-1?0:total-1,{force:true});
document.getElementById('ppt-timer-toggle').onclick=toggleTimer;
document.getElementById('ppt-timer-reset').onclick=resetTimer;
document.getElementById('ppt-note-smaller').onclick=()=>setNoteSize(noteSize-1);
document.getElementById('ppt-note-larger').onclick=()=>setNoteSize(noteSize+1);
document.getElementById('ppt-note-editor').addEventListener('input',saveNote);
setNoteSize(noteSize,false);
}
function updateShell(){
if(mode!=='presenter')return;mountShell();
const n=noteAt(idx),next=Math.min(idx+1,total-1),id=slideId(idx),editor=document.getElementById('ppt-note-editor');
const currentFrame=document.getElementById('ppt-current-frame'),nextFrame=document.getElementById('ppt-next-frame');
if(currentFrame.dataset.slide!==String(idx)){currentFrame.src=frameUrl('preview',idx);currentFrame.dataset.slide=String(idx);}
if(nextFrame.dataset.slide!==String(next)){nextFrame.src=frameUrl('preview',next);nextFrame.dataset.slide=String(next);}
document.getElementById('ppt-current-label').textContent=`${idx+1} / ${total}`;
document.getElementById('ppt-next-label').textContent=idx===total-1?'已到尾页':`${next+1} / ${total}`;
document.getElementById('ppt-notes-title').textContent=n.title||`第 ${idx+1} 页`;
document.getElementById('ppt-note-meta').textContent=`建议 ${Number(n.minutes||0).toFixed(1)} 分钟 · ${id}`;
document.getElementById('ppt-page-title').textContent=n.title||`第 ${idx+1} 页`;
document.getElementById('ppt-page-count').textContent=`第 ${idx+1} / ${total} 页 · ${Math.round(((idx+1)/total)*100)}%`;
document.getElementById('ppt-plan').textContent=`计划 ${notes().reduce((s,x)=>s+Number(x.minutes||0),0).toFixed(1)} 分钟`;
document.getElementById('ppt-first').disabled=idx===0;document.getElementById('ppt-prev').disabled=idx===0;document.getElementById('ppt-next').disabled=idx===total-1;
document.getElementById('ppt-last').textContent=idx===total-1?'重新开始':'尾页';
if(renderedNoteId!==id){renderedNoteId=id;editor.value=localStorage.getItem(noteStorageKey(id))??generatedNote(n);editor.scrollTop=0;document.getElementById('ppt-saved').textContent='已保存';}
updateSyncStatus();
}
function saveNote(){
const saved=document.getElementById('ppt-saved');saved.textContent='保存中…';saved.classList.remove('ppt-saved');clearTimeout(saveTimer);
saveTimer=setTimeout(()=>{localStorage.setItem(noteStorageKey(renderedNoteId),document.getElementById('ppt-note-editor').value);saved.textContent='已保存';saved.classList.add('ppt-saved');},300);
}
function setNoteSize(value,persist=true){
noteSize=Math.max(14,Math.min(26,value));document.documentElement.style.setProperty('--ppt-note-size',`${noteSize}px`);
if(persist)localStorage.setItem('guizang-ppt-note-size',String(noteSize));
}
function currentElapsed(){return elapsed+(timerRunning?Date.now()-timerStartedAt:0);}
function renderTimer(){const sec=Math.floor(currentElapsed()/1000),clock=document.getElementById('ppt-clock');if(clock)clock.textContent=`${String(Math.floor(sec/60)).padStart(2,'0')}:${String(sec%60).padStart(2,'0')}`;}
function toggleTimer(){if(timerRunning){elapsed=currentElapsed();timerRunning=false;}else{timerStartedAt=Date.now();timerRunning=true;}document.getElementById('ppt-timer-toggle').textContent=timerRunning?'暂停':'继续';renderTimer();}
function resetTimer(){elapsed=0;timerRunning=false;timerStartedAt=0;document.getElementById('ppt-timer-toggle').textContent='开始计时';renderTimer();}
function initTransport(){
if(transportReady)return;transportReady=true;
if('BroadcastChannel'in window){try{channel=new BroadcastChannel(channelName());channel.onmessage=e=>receive(e.data);}catch(e){}}
addEventListener('message',e=>receive(e.data));
addEventListener('storage',e=>{if(e.key===syncKey()&&e.newValue){try{receive(JSON.parse(e.newValue));}catch(err){}}});
}
function emit(payload){
const message={__guizangPptSync:1,session,role:mode,...payload,at:Date.now(),nonce:Math.random().toString(36).slice(2)};
try{channel?.postMessage(message);}catch(e){}
try{localStorage.setItem(syncKey(),JSON.stringify(message));}catch(e){}
try{if(mode==='presenter'&&audienceWindow&&!audienceWindow.closed)audienceWindow.postMessage(message,'*');}catch(e){}
try{if(mode==='audience'&&window.opener&&!window.opener.closed)window.opener.postMessage(message,'*');}catch(e){}
}
function receive(message){
if(!message||message.__guizangPptSync!==1||message.session!==session||message.role===mode)return;
if(mode==='presenter'){
if(message.type==='ready'){popupBlocked=false;sendGoto(true);}
if(message.type==='ack'){lastAckAt=Date.now();lastAckIndex=Number(message.index);lastAckSeq=Number(message.seq);updateSyncStatus();}
return;
}
if(mode==='audience'&&(message.type==='goto'||message.type==='ping')){
if(message.type==='goto')go(Number(message.index),{force:true});
emit({type:'ack',index:idx,seq:Number(message.seq)});
}
}
function sendGoto(force=false){if(mode!=='presenter')return;if(force||lastAckIndex!==idx)seq++;emit({type:'goto',index:idx,seq});updateSyncStatus();}
function syncState(){
if(popupBlocked)return'blocked';
if(audienceWindow&&audienceWindow.closed)return'disconnected';
if(!lastAckAt)return audienceOpenedAt&&Date.now()-audienceOpenedAt<2600?'connecting':(audienceOpenedAt?'unsynced':'disconnected');
if(Date.now()-lastAckAt>2600||lastAckIndex!==idx||lastAckSeq<seq)return'unsynced';
return'synced';
}
function updateSyncStatus(){
const el=document.getElementById('ppt-sync');if(!el)return;const state=syncState();
const labels={blocked:'弹窗被拦截',disconnected:'未连接',connecting:'连接中',unsynced:'未同步',synced:`已同步 · 第 ${idx+1} 页`};
el.dataset.state=state;el.textContent=`观众屏 · ${labels[state]}`;
}
function reopenAudience(){
initTransport();popupBlocked=false;audienceOpenedAt=Date.now();lastAckAt=0;lastAckIndex=-1;lastAckSeq=-1;
audienceWindow=window.open(frameUrl('audience',idx),`guizang-ppt-audience-${session}`,'popup=yes,width=1440,height=900');
if(!audienceWindow)popupBlocked=true;updateSyncStatus();
}
function enterPresenter(openAudience=true){
if(mode==='presenter')return;lowPowerBeforePresenter=window.__lowPowerMode;mode='presenter';initTransport();mountShell();
document.body.classList.remove('ppt-audience','ppt-preview');document.body.classList.add('ppt-presenter');document.title=`演讲者视图 · ${originalTitle}`;
window.__setLowPowerMode?.(true,{persist:false});updateShell();if(openAudience)reopenAudience();
}
function exitPresenter(){
emit({type:'bye'});mode='deck';document.body.classList.remove('ppt-presenter');document.title=originalTitle;window.__setLowPowerMode?.(lowPowerBeforePresenter,{persist:false});
}
function initAudience(){mode='audience';document.body.classList.add('ppt-audience');document.title=`观众屏 · ${originalTitle}`;initTransport();emit({type:'ready',index:idx});}
function initPreview(){mode='preview';document.body.classList.add('ppt-preview');window.__setLowPowerMode?.(true,{persist:false});}
function onSlideChange(){if(mode==='presenter'){updateShell();sendGoto();}}
function handleKey(e){
const editable=e.target?.matches?.('input,textarea,[contenteditable="true"]');
if(editable){if(e.key==='Escape')e.target.blur();return true;}
if(mode==='audience'||mode==='preview'){e.preventDefault();return true;}
if(mode==='deck'&&e.key?.toLowerCase()==='p'&&!e.metaKey&&!e.ctrlKey&&!e.altKey){e.preventDefault();enterPresenter(true);return true;}
if(mode!=='presenter')return false;
if(e.key==='Escape'){e.preventDefault();toggleOverview();return true;}
if(overviewOn)return true;
if(['ArrowRight','PageDown',' ','ArrowDown'].includes(e.key)){e.preventDefault();go(idx+1,{force:true});return true;}
if(['ArrowLeft','PageUp','ArrowUp'].includes(e.key)){e.preventDefault();go(idx-1,{force:true});return true;}
if(e.key==='Home'){e.preventDefault();go(0,{force:true});return true;}
if(e.key==='End'){e.preventDefault();go(total-1,{force:true});return true;}
return false;
}
function initMode(){
document.getElementById('ppt-low-power-btn')?.addEventListener('click',()=>window.__setLowPowerMode(!window.__lowPowerMode));
document.getElementById('ppt-overview-btn')?.addEventListener('click',toggleOverview);
document.getElementById('ppt-presenter-btn')?.addEventListener('click',()=>enterPresenter(true));
if(requestedMode==='audience')initAudience();else if(requestedMode==='preview')initPreview();else if(requestedMode==='presenter')enterPresenter(false);
}
setInterval(()=>{renderTimer();if(mode==='presenter'){if(audienceWindow?.closed)updateSyncStatus();emit({type:'ping',index:idx,seq});updateSyncStatus();}},1000);
return{initMode,onSlideChange,handleKey,slideLabel,enterPresenter,reopenAudience};
})();
addEventListener('keydown',e=>{ addEventListener('keydown',e=>{
if(window.__pptPresenter?.handleKey(e))return;
if(e.key==='Escape'){e.preventDefault();toggleOverview();return;} if(e.key==='Escape'){e.preventDefault();toggleOverview();return;}
if(e.key && e.key.toLowerCase()==='b' && !e.metaKey && !e.ctrlKey && !e.altKey){ if(e.key && e.key.toLowerCase()==='b' && !e.metaKey && !e.ctrlKey && !e.altKey){
e.preventDefault(); e.preventDefault();
@@ -1544,6 +1763,7 @@ addEventListener('touchend',e=>{
const initialSlideParam = new URLSearchParams(location.search).get('slide'); const initialSlideParam = new URLSearchParams(location.search).get('slide');
const initialSlide = initialSlideParam ? Number(initialSlideParam) - 1 : 0; const initialSlide = initialSlideParam ? Number(initialSlideParam) - 1 : 0;
go(Number.isFinite(initialSlide) ? initialSlide : 0); go(Number.isFinite(initialSlide) ? initialSlide : 0);
window.__pptPresenter?.initMode();
</script> </script>
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script> <script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
<script>lucide.createIcons();</script> <script>lucide.createIcons();</script>
+222 -8
View File
@@ -195,10 +195,43 @@
body.light-bg #nav{background:rgba(255,255,255,.25)} body.light-bg #nav{background:rgba(255,255,255,.25)}
body.light-bg #nav .dot{background:rgba(var(--ink-rgb),.25)} body.light-bg #nav .dot{background:rgba(var(--ink-rgb),.25)}
body.light-bg #nav .dot.active{background:rgba(var(--ink-rgb),.9)} body.light-bg #nav .dot.active{background:rgba(var(--ink-rgb),.9)}
#hint{position:fixed;bottom:3vh;right:3vw;z-index:30;font-family:var(--mono);font-size:10px;letter-spacing:.2em;text-transform:uppercase;opacity:.4;mix-blend-mode:difference;color:#aaa} #hint{position:fixed;bottom:3vh;right:3vw;z-index:30;display:flex;align-items:center;gap:.65em;font-family:var(--mono);font-size:10px;letter-spacing:.2em;text-transform:uppercase;opacity:.4;mix-blend-mode:difference;color:#aaa}
#hint button{all:unset;display:inline-flex;align-items:center;gap:.45em;cursor:pointer;color:inherit;white-space:nowrap}
#hint button:hover,#hint button:focus-visible{opacity:1;text-decoration:underline;text-underline-offset:.35em}
#hint .ppt-key{padding:.16em .38em;border:1px solid currentColor;border-radius:3px;letter-spacing:.08em}
#hint .ppt-sep{opacity:.55}
body.low-power #hint{opacity:.72;color:var(--paper);mix-blend-mode:normal} body.low-power #hint{opacity:.72;color:var(--paper);mix-blend-mode:normal}
body.light-bg.low-power #hint{color:var(--ink)} body.light-bg.low-power #hint{color:var(--ink)}
/* ============ 演讲者模式 ============ */
body.ppt-audience #hint,body.ppt-audience #nav,
body.ppt-preview #hint,body.ppt-preview #nav{display:none!important}
body.ppt-audience{cursor:none}
body.ppt-preview{pointer-events:none}
body.ppt-presenter>#deck,body.ppt-presenter>#nav,body.ppt-presenter>#hint,
body.ppt-presenter>.bg{visibility:hidden}
#ppt-presenter{position:fixed;inset:0;z-index:90;display:none;min-width:1000px;min-height:680px;background:#101216;color:#f5f6f7;font-family:Inter,"Noto Sans SC",sans-serif}
body.ppt-presenter #ppt-presenter{display:grid;grid-template-rows:auto 1fr auto}
.ppt-presenter-top{display:flex;align-items:center;justify-content:space-between;gap:24px;padding:18px 24px;border-bottom:1px solid #2b2e35;background:#15181d}
.ppt-presenter-brand{font-weight:650;letter-spacing:.02em}.ppt-presenter-meta{display:flex;align-items:center;gap:12px;color:#aeb4be;font-size:14px}
.ppt-sync{display:inline-flex;align-items:center;gap:8px;padding:7px 11px;border:1px solid #363a43;border-radius:999px;color:#d2d6dc;background:#1b1e24}
.ppt-sync::before{content:"";width:8px;height:8px;border-radius:50%;background:#818894}
.ppt-sync[data-state="synced"]::before{background:#72df99;box-shadow:0 0 0 4px rgba(114,223,153,.12)}
.ppt-sync[data-state="connecting"]::before{background:#f0c766}.ppt-sync[data-state="unsynced"]::before,.ppt-sync[data-state="blocked"]::before{background:#ff9e64}.ppt-sync[data-state="disconnected"]::before{background:#ff6b72}
.ppt-ui-btn{appearance:none;border:1px solid #3a3e48;background:#20242a;color:#f6f7f8;border-radius:8px;padding:9px 13px;font:600 14px/1 Inter,"Noto Sans SC",sans-serif;cursor:pointer}
.ppt-ui-btn:hover{background:#2a2f37;border-color:#525864}.ppt-ui-btn:focus-visible{outline:2px solid #84a8ff;outline-offset:2px}.ppt-ui-btn:disabled{opacity:.28;cursor:not-allowed}
.ppt-presenter-main{display:grid;grid-template-columns:minmax(0,1.75fr) minmax(320px,.75fr);gap:18px;min-height:0;padding:18px 24px}
.ppt-preview-stack{display:grid;grid-template-columns:minmax(0,1fr) minmax(240px,.42fr);gap:16px;min-width:0;align-items:start}
.ppt-stage-card,.ppt-notes-card{background:#171a20;border:1px solid #2b2f37;border-radius:12px;overflow:hidden}
.ppt-stage-label{display:flex;justify-content:space-between;align-items:center;padding:11px 14px;color:#aeb4be;font-size:13px;border-bottom:1px solid #292d34}
.ppt-frame{display:block;width:100%;aspect-ratio:16/9;border:0;background:#08090b}.ppt-next-frame{opacity:.86}
.ppt-notes-card{display:grid;grid-template-rows:auto 1fr;min-height:0}.ppt-notes-head{padding:16px 18px;border-bottom:1px solid #292d34}.ppt-notes-title{font-size:20px;font-weight:650;margin-bottom:7px}.ppt-notes-sub{display:flex;align-items:center;justify-content:space-between;gap:10px;color:#9fa6b1;font-size:13px}
.ppt-notes-tools{display:flex;align-items:center;gap:6px}.ppt-note-editor{width:100%;height:100%;min-height:0;resize:none;border:0;outline:0;background:#171a20;color:#edf0f3;padding:18px;font:400 var(--ppt-note-size,17px)/1.72 Inter,"Noto Sans SC",sans-serif}
.ppt-saved{color:#72df99}.ppt-presenter-bottom{display:grid;grid-template-columns:1fr auto 1fr;align-items:center;gap:20px;padding:16px 24px;border-top:1px solid #2b2e35;background:#15181d}
.ppt-timer{display:flex;align-items:center;gap:9px}.ppt-clock{min-width:72px;font:600 24px/1 var(--mono,monospace);font-variant-numeric:tabular-nums}.ppt-controls{display:flex;align-items:center;gap:8px}.ppt-controls .ppt-ui-btn{min-width:72px}.ppt-controls .ppt-primary{background:#f2f4f6;color:#111318;border-color:#f2f4f6}.ppt-controls .ppt-primary:hover{background:#fff}.ppt-page-state{text-align:right;color:#aeb4be;font-size:14px}.ppt-page-state strong{display:block;color:#fff;font-size:18px;margin-bottom:3px}
#overview{z-index:110!important}
@media(max-width:1200px){.ppt-presenter-main{grid-template-columns:minmax(0,1.45fr) minmax(300px,.75fr)}.ppt-preview-stack{grid-template-columns:1fr}.ppt-preview-stack .ppt-stage-card:last-child{display:none}}
/* ============================================================ /* ============================================================
============ LAYOUTS API · 面向 agent 的类(v2)============ ============ LAYOUTS API · 面向 agent 的类(v2)============
所有 layouts.md 中的骨架都基于下面这套命名。 所有 layouts.md 中的骨架都基于下面这套命名。
@@ -451,8 +484,8 @@
const stored = localStorage.getItem(KEY); const stored = localStorage.getItem(KEY);
window.__lowPowerMode = stored === '1' || (stored === null && reduced); window.__lowPowerMode = stored === '1' || (stored === null && reduced);
function updateHint(){ function updateHint(){
const hint = document.getElementById('hint'); const label = document.querySelector('.ppt-low-power-label');
if(hint) hint.textContent = `← → 翻页 · B ${window.__lowPowerMode ? '动态' : '静态'} · ESC 索引`; if(label) label.textContent = window.__lowPowerMode ? '动态' : '静态';
} }
window.__setLowPowerMode = function(on, opts={}){ window.__setLowPowerMode = function(on, opts={}){
window.__lowPowerMode = !!on; window.__lowPowerMode = !!on;
@@ -472,7 +505,12 @@
<canvas id="bg-dark" class="bg"></canvas> <canvas id="bg-dark" class="bg"></canvas>
<canvas id="bg-light" class="bg"></canvas> <canvas id="bg-light" class="bg"></canvas>
<div id="hint">← → 翻页 · B 静态 · ESC 索引</div> <div id="hint" role="toolbar" aria-label="快捷控制">
<span>← → 翻页</span><span class="ppt-sep">·</span>
<button id="ppt-low-power-btn" type="button"><span class="ppt-key">B</span><span class="ppt-low-power-label">静态</span></button><span class="ppt-sep">·</span>
<button id="ppt-overview-btn" type="button"><span class="ppt-key">ESC</span>总览</button><span class="ppt-sep">·</span>
<button id="ppt-presenter-btn" type="button"><span class="ppt-key">P</span>演讲模式</button>
</div>
<div id="deck"> <div id="deck">
@@ -488,6 +526,19 @@
<div id="nav"></div> <div id="nav"></div>
<!-- 演讲备注:每页一条,id 必须与 section[data-slide-id] 一致。见 references/presenter-mode.md -->
<script>
const SPEAKER_NOTES = [
/* {
id: 'stable-slide-id', title: '页面标题', minutes: 1,
purpose: '这一页要让观众理解什么',
talk: ['只在台上说的补充信息', '例子、证据或语气提示', '不复述页面可见文字'],
transition: '为什么下一页紧接着出现', cue: '可选:停顿 / Demo / 视频'
} */
];
window.__SPEAKER_NOTES__ = SPEAKER_NOTES;
</script>
<script> <script>
/* =============== WebGL 双背景 =============== /* =============== WebGL 双背景 ===============
深色页:Holographic Dispersion(全息色散 · 钛金暗流)—— 彩虹微扰、鼠标径向涟漪 深色页:Holographic Dispersion(全息色散 · 钛金暗流)—— 彩虹微扰、鼠标径向涟漪
@@ -633,8 +684,8 @@ slides.forEach((s,i)=>{
nav.appendChild(b); nav.appendChild(b);
}); });
function go(n){ function go(n,opts={}){
if(lock)return; if(lock&&!opts.force)return;
idx=Math.max(0,Math.min(total-1,n)); idx=Math.max(0,Math.min(total-1,n));
window.__currentSlideIndex = idx; window.__currentSlideIndex = idx;
deck.style.transform=`translateX(${-idx*100}vw)`; deck.style.transform=`translateX(${-idx*100}vw)`;
@@ -645,7 +696,8 @@ function go(n){
document.body.classList.toggle('light-bg',th==='light'); document.body.classList.toggle('light-bg',th==='light');
/* 动效:翻页过渡中段触发当前页的入场动画(由 motion-boot 注册) */ /* 动效:翻页过渡中段触发当前页的入场动画(由 motion-boot 注册) */
if(window.__playSlide) setTimeout(()=>window.__playSlide(idx), 450); if(window.__playSlide) setTimeout(()=>window.__playSlide(idx), 450);
lock=true;setTimeout(()=>lock=false,700); window.__pptPresenter?.onSlideChange(idx);
if(!opts.force){lock=true;setTimeout(()=>lock=false,700);}
} }
/* =============== ESC 索引视图 =============== */ /* =============== ESC 索引视图 =============== */
@@ -671,7 +723,7 @@ function buildOverview(){
wrap.appendChild(clone); wrap.appendChild(clone);
const label=document.createElement('div'); const label=document.createElement('div');
label.style.cssText='padding:6px 10px;font-family:var(--mono);font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:var(--paper);opacity:.7'; label.style.cssText='padding:6px 10px;font-family:var(--mono);font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:var(--paper);opacity:.7';
label.textContent=(i+1)+' / '+total; label.textContent=window.__pptPresenter?.slideLabel(i) || ((i+1)+' / '+total);
card.appendChild(wrap); card.appendChild(wrap);
card.appendChild(label); card.appendChild(label);
card.onclick=()=>{toggleOverview();go(i)}; card.onclick=()=>{toggleOverview();go(i)};
@@ -686,7 +738,168 @@ function toggleOverview(){
else{ov.style.display='none';} else{ov.style.display='none';}
} }
/* =============== 演讲者模式 / 观众屏同步 =============== */
window.__pptPresenter=(()=>{
const query=new URLSearchParams(location.search);
const requestedMode=query.get('mode')||'deck';
const originalTitle=document.title;
const notes=()=>Array.isArray(window.__SPEAKER_NOTES__)?window.__SPEAKER_NOTES__:[];
const slideId=i=>slides[i]?.dataset.slideId||`slide-${String(i+1).padStart(2,'0')}`;
let mode='deck',session=query.get('session')||Math.random().toString(36).slice(2),audienceWindow=null;
let channel=null,transportReady=false,popupBlocked=false,audienceOpenedAt=0,lastAckAt=0,lastAckIndex=-1,lastAckSeq=-1,seq=0;
let renderedNoteId='',saveTimer=0,noteSize=Number(localStorage.getItem('guizang-ppt-note-size')||17);
let elapsed=0,timerRunning=false,timerStartedAt=0,lowPowerBeforePresenter=window.__lowPowerMode;
const syncKey=()=>`guizang-ppt-sync:${location.pathname}:${session}`;
const channelName=()=>`guizang-ppt:${location.pathname}:${session}`;
function noteAt(i){
const id=slideId(i);
return notes().find(n=>n.id===id)||{id,title:slides[i]?.dataset.title||`第 ${i+1} 页`,minutes:0,purpose:'待补充本页目的',talk:['待补充讲述要点'],transition:'待补充转场'};
}
function slideLabel(i){return `${i+1} / ${total} · ${noteAt(i).title||slideId(i)}`;}
function generatedNote(n){
const talk=(n.talk||[]).map(x=>`• ${x}`).join('\n');
return `【本页目的】\n${n.purpose||''}\n\n【讲述要点】\n${talk}\n\n【转场】\n${n.transition||''}${n.cue?`\n\n【舞台提示】\n${n.cue}`:''}`;
}
function noteStorageKey(id){return `guizang-ppt-note:${location.pathname}:${id}`;}
function frameUrl(targetMode,i){
const url=new URL(location.href);url.hash='';
url.searchParams.set('mode',targetMode);url.searchParams.set('session',session);url.searchParams.set('slide',String(i+1));
return url.href;
}
function mountShell(){
if(document.getElementById('ppt-presenter'))return;
document.body.insertAdjacentHTML('beforeend',`<div id="ppt-presenter" aria-label="演讲者视图">
<div class="ppt-presenter-top"><div><div class="ppt-presenter-brand">演讲者视图</div><div class="ppt-presenter-meta"><span id="ppt-plan"></span></div></div><div class="ppt-presenter-meta"><span id="ppt-sync" class="ppt-sync" data-state="disconnected" title="表示观众页面的软件同步状态,不代表 HDMI 等物理线缆状态">观众屏 · 未连接</span><button id="ppt-reopen" class="ppt-ui-btn" type="button">重新打开观众屏</button><button id="ppt-exit" class="ppt-ui-btn" type="button">退出</button></div></div>
<div class="ppt-presenter-main"><div class="ppt-preview-stack"><div class="ppt-stage-card"><div class="ppt-stage-label"><span>当前页</span><span id="ppt-current-label"></span></div><iframe id="ppt-current-frame" class="ppt-frame" title="当前页预览"></iframe></div><div class="ppt-stage-card" id="ppt-next-card"><div class="ppt-stage-label"><span>下一页</span><span id="ppt-next-label"></span></div><iframe id="ppt-next-frame" class="ppt-frame ppt-next-frame" title="下一页预览"></iframe></div></div>
<div class="ppt-notes-card"><div class="ppt-notes-head"><div id="ppt-notes-title" class="ppt-notes-title"></div><div class="ppt-notes-sub"><span id="ppt-note-meta"></span><span class="ppt-notes-tools"><span id="ppt-saved" class="ppt-saved">已保存</span><button id="ppt-note-smaller" class="ppt-ui-btn" type="button" title="缩小备注字号">A−</button><button id="ppt-note-larger" class="ppt-ui-btn" type="button" title="放大备注字号">A+</button></span></div></div><textarea id="ppt-note-editor" class="ppt-note-editor" spellcheck="false" aria-label="本页演讲备注"></textarea></div></div>
<div class="ppt-presenter-bottom"><div class="ppt-timer"><span id="ppt-clock" class="ppt-clock">00:00</span><button id="ppt-timer-toggle" class="ppt-ui-btn" type="button">开始计时</button><button id="ppt-timer-reset" class="ppt-ui-btn" type="button">重置</button></div><div class="ppt-controls"><button id="ppt-first" class="ppt-ui-btn" type="button">首页</button><button id="ppt-prev" class="ppt-ui-btn" type="button">上一页</button><button id="ppt-grid" class="ppt-ui-btn ppt-primary" type="button">总览</button><button id="ppt-next" class="ppt-ui-btn" type="button">下一页</button><button id="ppt-last" class="ppt-ui-btn" type="button">尾页</button></div><div class="ppt-page-state"><strong id="ppt-page-title"></strong><span id="ppt-page-count"></span></div></div>
</div>`);
document.getElementById('ppt-reopen').onclick=reopenAudience;
document.getElementById('ppt-exit').onclick=exitPresenter;
document.getElementById('ppt-first').onclick=()=>go(0,{force:true});
document.getElementById('ppt-prev').onclick=()=>go(idx-1,{force:true});
document.getElementById('ppt-grid').onclick=toggleOverview;
document.getElementById('ppt-next').onclick=()=>go(idx+1,{force:true});
document.getElementById('ppt-last').onclick=()=>go(idx===total-1?0:total-1,{force:true});
document.getElementById('ppt-timer-toggle').onclick=toggleTimer;
document.getElementById('ppt-timer-reset').onclick=resetTimer;
document.getElementById('ppt-note-smaller').onclick=()=>setNoteSize(noteSize-1);
document.getElementById('ppt-note-larger').onclick=()=>setNoteSize(noteSize+1);
document.getElementById('ppt-note-editor').addEventListener('input',saveNote);
setNoteSize(noteSize,false);
}
function updateShell(){
if(mode!=='presenter')return;mountShell();
const n=noteAt(idx),next=Math.min(idx+1,total-1),id=slideId(idx),editor=document.getElementById('ppt-note-editor');
const currentFrame=document.getElementById('ppt-current-frame'),nextFrame=document.getElementById('ppt-next-frame');
if(currentFrame.dataset.slide!==String(idx)){currentFrame.src=frameUrl('preview',idx);currentFrame.dataset.slide=String(idx);}
if(nextFrame.dataset.slide!==String(next)){nextFrame.src=frameUrl('preview',next);nextFrame.dataset.slide=String(next);}
document.getElementById('ppt-current-label').textContent=`${idx+1} / ${total}`;
document.getElementById('ppt-next-label').textContent=idx===total-1?'已到尾页':`${next+1} / ${total}`;
document.getElementById('ppt-notes-title').textContent=n.title||`第 ${idx+1} 页`;
document.getElementById('ppt-note-meta').textContent=`建议 ${Number(n.minutes||0).toFixed(1)} 分钟 · ${id}`;
document.getElementById('ppt-page-title').textContent=n.title||`第 ${idx+1} 页`;
document.getElementById('ppt-page-count').textContent=`第 ${idx+1} / ${total} 页 · ${Math.round(((idx+1)/total)*100)}%`;
document.getElementById('ppt-plan').textContent=`计划 ${notes().reduce((s,x)=>s+Number(x.minutes||0),0).toFixed(1)} 分钟`;
document.getElementById('ppt-first').disabled=idx===0;document.getElementById('ppt-prev').disabled=idx===0;document.getElementById('ppt-next').disabled=idx===total-1;
document.getElementById('ppt-last').textContent=idx===total-1?'重新开始':'尾页';
if(renderedNoteId!==id){renderedNoteId=id;editor.value=localStorage.getItem(noteStorageKey(id))??generatedNote(n);editor.scrollTop=0;document.getElementById('ppt-saved').textContent='已保存';}
updateSyncStatus();
}
function saveNote(){
const saved=document.getElementById('ppt-saved');saved.textContent='保存中…';saved.classList.remove('ppt-saved');clearTimeout(saveTimer);
saveTimer=setTimeout(()=>{localStorage.setItem(noteStorageKey(renderedNoteId),document.getElementById('ppt-note-editor').value);saved.textContent='已保存';saved.classList.add('ppt-saved');},300);
}
function setNoteSize(value,persist=true){
noteSize=Math.max(14,Math.min(26,value));document.documentElement.style.setProperty('--ppt-note-size',`${noteSize}px`);
if(persist)localStorage.setItem('guizang-ppt-note-size',String(noteSize));
}
function currentElapsed(){return elapsed+(timerRunning?Date.now()-timerStartedAt:0);}
function renderTimer(){const sec=Math.floor(currentElapsed()/1000),clock=document.getElementById('ppt-clock');if(clock)clock.textContent=`${String(Math.floor(sec/60)).padStart(2,'0')}:${String(sec%60).padStart(2,'0')}`;}
function toggleTimer(){if(timerRunning){elapsed=currentElapsed();timerRunning=false;}else{timerStartedAt=Date.now();timerRunning=true;}document.getElementById('ppt-timer-toggle').textContent=timerRunning?'暂停':'继续';renderTimer();}
function resetTimer(){elapsed=0;timerRunning=false;timerStartedAt=0;document.getElementById('ppt-timer-toggle').textContent='开始计时';renderTimer();}
function initTransport(){
if(transportReady)return;transportReady=true;
if('BroadcastChannel'in window){try{channel=new BroadcastChannel(channelName());channel.onmessage=e=>receive(e.data);}catch(e){}}
addEventListener('message',e=>receive(e.data));
addEventListener('storage',e=>{if(e.key===syncKey()&&e.newValue){try{receive(JSON.parse(e.newValue));}catch(err){}}});
}
function emit(payload){
const message={__guizangPptSync:1,session,role:mode,...payload,at:Date.now(),nonce:Math.random().toString(36).slice(2)};
try{channel?.postMessage(message);}catch(e){}
try{localStorage.setItem(syncKey(),JSON.stringify(message));}catch(e){}
try{if(mode==='presenter'&&audienceWindow&&!audienceWindow.closed)audienceWindow.postMessage(message,'*');}catch(e){}
try{if(mode==='audience'&&window.opener&&!window.opener.closed)window.opener.postMessage(message,'*');}catch(e){}
}
function receive(message){
if(!message||message.__guizangPptSync!==1||message.session!==session||message.role===mode)return;
if(mode==='presenter'){
if(message.type==='ready'){popupBlocked=false;sendGoto(true);}
if(message.type==='ack'){lastAckAt=Date.now();lastAckIndex=Number(message.index);lastAckSeq=Number(message.seq);updateSyncStatus();}
return;
}
if(mode==='audience'&&(message.type==='goto'||message.type==='ping')){
if(message.type==='goto')go(Number(message.index),{force:true});
emit({type:'ack',index:idx,seq:Number(message.seq)});
}
}
function sendGoto(force=false){if(mode!=='presenter')return;if(force||lastAckIndex!==idx)seq++;emit({type:'goto',index:idx,seq});updateSyncStatus();}
function syncState(){
if(popupBlocked)return'blocked';
if(audienceWindow&&audienceWindow.closed)return'disconnected';
if(!lastAckAt)return audienceOpenedAt&&Date.now()-audienceOpenedAt<2600?'connecting':(audienceOpenedAt?'unsynced':'disconnected');
if(Date.now()-lastAckAt>2600||lastAckIndex!==idx||lastAckSeq<seq)return'unsynced';
return'synced';
}
function updateSyncStatus(){
const el=document.getElementById('ppt-sync');if(!el)return;const state=syncState();
const labels={blocked:'弹窗被拦截',disconnected:'未连接',connecting:'连接中',unsynced:'未同步',synced:`已同步 · 第 ${idx+1} 页`};
el.dataset.state=state;el.textContent=`观众屏 · ${labels[state]}`;
}
function reopenAudience(){
initTransport();popupBlocked=false;audienceOpenedAt=Date.now();lastAckAt=0;lastAckIndex=-1;lastAckSeq=-1;
audienceWindow=window.open(frameUrl('audience',idx),`guizang-ppt-audience-${session}`,'popup=yes,width=1440,height=900');
if(!audienceWindow)popupBlocked=true;updateSyncStatus();
}
function enterPresenter(openAudience=true){
if(mode==='presenter')return;lowPowerBeforePresenter=window.__lowPowerMode;mode='presenter';initTransport();mountShell();
document.body.classList.remove('ppt-audience','ppt-preview');document.body.classList.add('ppt-presenter');document.title=`演讲者视图 · ${originalTitle}`;
window.__setLowPowerMode?.(true,{persist:false});updateShell();if(openAudience)reopenAudience();
}
function exitPresenter(){
emit({type:'bye'});mode='deck';document.body.classList.remove('ppt-presenter');document.title=originalTitle;window.__setLowPowerMode?.(lowPowerBeforePresenter,{persist:false});
}
function initAudience(){mode='audience';document.body.classList.add('ppt-audience');document.title=`观众屏 · ${originalTitle}`;initTransport();emit({type:'ready',index:idx});}
function initPreview(){mode='preview';document.body.classList.add('ppt-preview');window.__setLowPowerMode?.(true,{persist:false});}
function onSlideChange(){if(mode==='presenter'){updateShell();sendGoto();}}
function handleKey(e){
const editable=e.target?.matches?.('input,textarea,[contenteditable="true"]');
if(editable){if(e.key==='Escape')e.target.blur();return true;}
if(mode==='audience'||mode==='preview'){e.preventDefault();return true;}
if(mode==='deck'&&e.key?.toLowerCase()==='p'&&!e.metaKey&&!e.ctrlKey&&!e.altKey){e.preventDefault();enterPresenter(true);return true;}
if(mode!=='presenter')return false;
if(e.key==='Escape'){e.preventDefault();toggleOverview();return true;}
if(overviewOn)return true;
if(['ArrowRight','PageDown',' ','ArrowDown'].includes(e.key)){e.preventDefault();go(idx+1,{force:true});return true;}
if(['ArrowLeft','PageUp','ArrowUp'].includes(e.key)){e.preventDefault();go(idx-1,{force:true});return true;}
if(e.key==='Home'){e.preventDefault();go(0,{force:true});return true;}
if(e.key==='End'){e.preventDefault();go(total-1,{force:true});return true;}
return false;
}
function initMode(){
document.getElementById('ppt-low-power-btn')?.addEventListener('click',()=>window.__setLowPowerMode(!window.__lowPowerMode));
document.getElementById('ppt-overview-btn')?.addEventListener('click',toggleOverview);
document.getElementById('ppt-presenter-btn')?.addEventListener('click',()=>enterPresenter(true));
if(requestedMode==='audience')initAudience();else if(requestedMode==='preview')initPreview();else if(requestedMode==='presenter')enterPresenter(false);
}
setInterval(()=>{renderTimer();if(mode==='presenter'){if(audienceWindow?.closed)updateSyncStatus();emit({type:'ping',index:idx,seq});updateSyncStatus();}},1000);
return{initMode,onSlideChange,handleKey,slideLabel,enterPresenter,reopenAudience};
})();
addEventListener('keydown',e=>{ addEventListener('keydown',e=>{
if(window.__pptPresenter?.handleKey(e))return;
if(e.key==='Escape'){e.preventDefault();toggleOverview();return;} if(e.key==='Escape'){e.preventDefault();toggleOverview();return;}
if(e.key && e.key.toLowerCase()==='b' && !e.metaKey && !e.ctrlKey && !e.altKey){ if(e.key && e.key.toLowerCase()==='b' && !e.metaKey && !e.ctrlKey && !e.altKey){
e.preventDefault(); e.preventDefault();
@@ -732,6 +945,7 @@ addEventListener('touchend',e=>{
const initialSlideParam = new URLSearchParams(location.search).get('slide'); const initialSlideParam = new URLSearchParams(location.search).get('slide');
const initialSlide = initialSlideParam ? Number(initialSlideParam) - 1 : 0; const initialSlide = initialSlideParam ? Number(initialSlideParam) - 1 : 0;
go(Number.isFinite(initialSlide) ? initialSlide : 0); go(Number.isFinite(initialSlide) ? initialSlide : 0);
window.__pptPresenter?.initMode();
</script> </script>
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script> <script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
<script>lucide.createIcons();</script> <script>lucide.createIcons();</script>
+20
View File
@@ -8,6 +8,26 @@
## 🔴 P0 · 一定不能犯的错 ## 🔴 P0 · 一定不能犯的错
### 0-P. 演讲者模式必须可控、可恢复、备注不串页
**现象**:演讲者端已经翻页,观众屏仍停在上一页;观众窗口关闭后没有提示;页面重排后,用户改过的讲稿跑到了另一页;或演讲模式入口变成页面右上角的突兀按钮。
**做法**:
- 演讲入口只放在右下角现有快捷控制区,显示 `P 演讲模式`。
- 演讲者控制顺序固定为 `首页 / 上一页 / 总览 / 下一页 / 尾页`;尾页时最后一个按钮变为“重新开始”。
- 观众屏必须显示软件链路状态:`连接中 / 已同步 / 未同步 / 未连接 / 弹窗被拦截`,并始终提供“重新打开观众屏”。
- 每页必须有稳定、唯一的 `data-slide-id`;`SPEAKER_NOTES` 数量、顺序和 ID 必须逐页一致。
- 备注本地编辑按页面 ID 保存;切页重置滚动位置并显示保存状态。
- 浏览器只能确认观众页面是否同步,不能确认 HDMI/投影线物理连接;现场仍要目视检查外接屏。
**检查**:
```bash
node <SKILL_ROOT>/scripts/validate-presenter-mode.mjs path/to/index.html
```
浏览器里关闭观众窗口,确认状态变化;再点击“重新打开观众屏”,确认它恢复到演讲者当前页。
### 0-S. Swiss locked mode:正文页必须来自原始 22P ### 0-S. Swiss locked mode:正文页必须来自原始 22P
**现象**:颜色、字体看起来像 Swiss,但标题跑到中间、图片不在网格上、页面结构和原始 22P 完全不是一套东西。 **现象**:颜色、字体看起来像 Swiss,但标题跑到中间、图片不在网格上、页面结构和原始 22P 完全不是一套东西。
+93
View File
@@ -0,0 +1,93 @@
# 演讲者模式与演讲备注契约
制作任何需要现场讲述的 deck 时加载本文件。目标不是把页面文案复制到备注区,而是让页面、讲述和转场形成一条可以排练的叙事链。
## 1. 页面计划必须同时规划“看什么”和“说什么”
在写 HTML 前,先做一张表:
| 页码 | `data-slide-id` | 页面目的 | 观众屏可见信息 | 演讲者补充 | 分钟 | 转场 | cue |
|---|---|---|---|---|---:|---|---|
- **观众屏可见信息**:观众此刻必须读到的结论、证据、结构或图像。
- **演讲者补充**:背景、例子、判断依据、语气和细节,不要逐字复述页面。
- **转场**:解释下一页为什么紧接着出现,不要只写“下一页”。
- **cue**:可选的舞台动作,如停顿、提问、播放视频、切 Demo、提醒敏感信息。
默认生成“提词卡”,不是逐字稿。只有用户明确要求逐字稿时,才写完整口播。
## 2. 稳定的页面 ID
每一页都必须有唯一、语义化且稳定的 ID:
```html
<section class="slide ..." data-slide-id="codepilot-capabilities">
```
- 使用小写英文 slug:`cover`、`codepilot-capabilities`、`demo-workflow`、`closing`。
- 页面重排时保留原 ID;只有页面语义彻底改变时才更换。
- 不要用页码作为 ID。演讲者在浏览器里修改的备注按 ID 保存,稳定 ID 可避免重排后串页。
## 3. `SPEAKER_NOTES` 数据结构
在 deck 中保留一条与每页 ID 对应的记录,顺序必须与 slide 完全一致:
```js
const SPEAKER_NOTES = [
{
id: 'codepilot-capabilities',
title: 'CodePilot 能做什么',
minutes: 0.9,
purpose: '先让观众建立完整产品认知,再进入实现过程',
talk: [
'从用户动作讲能力,不先讲底层 Harness',
'用首页、多模型、Skills 和侧边栏四张截图建立产品全貌',
'强调模型、工具和上下文可以在同一工作流里协作'
],
transition: '观众知道产品长什么样后,再回答为什么要这样实现',
cue: '按截图顺序指向四个能力区'
}
];
window.__SPEAKER_NOTES__ = SPEAKER_NOTES;
```
硬规则:
- `id`、`title`、`minutes`、`purpose`、`talk`、`transition` 必填,`cue` 可选。
- `talk` 默认 3–5 条,每条只表达一个意思;封面、章节页和纯过渡页可以更短。
- 不编造原素材没有的数字、事实或经历;信息缺失时标记“待补充”或向用户询问。
- 总建议时长不超过用户时长的 90%,给停顿、现场互动和意外留出约 10% 缓冲。
- 封面只负责建立问题与预期;Demo 页写清操作顺序和失败兜底;尾页回扣开场并给出行动点。
## 4. 演讲者界面行为
- 普通 deck 的右下角控制区显示 `P 演讲模式`,不单独悬浮一个抢眼按钮。
- 点击后当前窗口进入演讲者视图,并打开独立观众屏。
- 控制顺序固定为:`首页 / 上一页 / 总览 / 下一页 / 尾页`;到尾页后“尾页”变为“重新开始”。
- 总览卡片显示页码、标题和当前页高亮,点击缩略图直接跳页;`ESC` 同样切换总览。
- 计时器由用户显式开始,可暂停、继续、重置;不要进入模式就自动计时。
- 备注编辑按 `data-slide-id` 存入 `localStorage`,显示保存状态;切页后备注滚动回顶部,并允许调整字号。
- `Home` / `End`、方向键、PageUp/PageDown 与可见按钮保持一致;光标在备注编辑器中时不得触发翻页。
## 5. 观众屏同步与恢复
模板同时使用窗口 `postMessage`、`BroadcastChannel` 和 `storage` 事件同步。观众页在加载和每次翻页后回传确认,演讲者端显示:
- **连接中**:观众窗口已打开,尚未收到确认。
- **已同步**:观众页确认的页码与演讲者页相同,心跳有效。
- **未同步**:确认超时或两端页码不同。
- **未连接**:观众窗口未打开或已关闭。
- **弹窗被拦截**:浏览器阻止打开观众窗口。
“重新打开观众屏”必须始终可用,并在恢复后立即发送当前页。
浏览器只能确认观众**页面/窗口**的软件同步状态,不能检测 HDMI、转接器或投影仪线缆是否物理断开。现场仍应目视确认外接屏。
## 6. 生成后验证
```bash
node <SKILL_ROOT>/scripts/validate-presenter-mode.mjs path/to/index.html
node <SKILL_ROOT>/scripts/validate-presenter-mode.mjs path/to/index.html --target-minutes 30
```
浏览器实测至少包含:进入演讲模式、弹窗允许/拦截、前后翻页、总览跳页、首页/尾页、尾页重新开始、观众窗口关闭后的状态变化、重新打开后恢复同步、备注保存与重排后不串页。
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env node
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
const file=process.argv[2];
const targetArg=process.argv.find(x=>x.startsWith('--target-minutes='));
const targetIndex=process.argv.indexOf('--target-minutes');
const targetMinutes=Number(targetArg?.split('=')[1]??(targetIndex>=0?process.argv[targetIndex+1]:NaN));
if(!file){
console.error('Usage: node scripts/validate-presenter-mode.mjs <index.html> [--target-minutes 30]');
process.exit(2);
}
const html=readFileSync(file,'utf8');
const source=html.replace(/<!--[\s\S]*?-->/g,'');
const errors=[];
const warnings=[];
function extractArray(name){
const marker=new RegExp(`(?:const|let|var)\\s+${name}\\s*=`).exec(source);
if(!marker)return null;
const start=source.indexOf('[',marker.index+marker[0].length);
if(start<0)return null;
let depth=0,quote='',escaped=false,lineComment=false,blockComment=false;
for(let i=start;i<source.length;i++){
const ch=source[i],next=source[i+1];
if(lineComment){if(ch==='\n')lineComment=false;continue;}
if(blockComment){if(ch==='*'&&next==='/'){blockComment=false;i++;}continue;}
if(quote){if(escaped){escaped=false;continue;}if(ch==='\\'){escaped=true;continue;}if(ch===quote)quote='';continue;}
if(ch==='/'&&next==='/'){lineComment=true;i++;continue;}
if(ch==='/'&&next==='*'){blockComment=true;i++;continue;}
if(ch==='"'||ch==="'"||ch==='`'){quote=ch;continue;}
if(ch==='[')depth++;
if(ch===']'&&--depth===0)return source.slice(start,i+1);
}
return null;
}
const slideTags=[...source.matchAll(/<section\b(?=[^>]*\bclass="[^"]*\bslide\b[^"]*")[^>]*>/g)].map(m=>m[0]);
const slideIds=slideTags.map((tag,i)=>tag.match(/\bdata-slide-id="([^"]+)"/)?.[1]||'');
slideIds.forEach((id,i)=>{
if(!id)errors.push(`Slide ${i+1}: missing data-slide-id.`);
else if(!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(id))errors.push(`Slide ${i+1}: data-slide-id="${id}" must be a lowercase semantic slug.`);
});
const duplicates=slideIds.filter((id,i)=>id&&slideIds.indexOf(id)!==i);
if(duplicates.length)errors.push(`Duplicate data-slide-id: ${[...new Set(duplicates)].join(', ')}`);
let speakerNotes=[];
const arraySource=extractArray('SPEAKER_NOTES');
if(!arraySource){
errors.push('Missing const SPEAKER_NOTES = [...].');
}else{
try{speakerNotes=vm.runInNewContext(`(${arraySource})`,Object.create(null),{timeout:1000});}
catch(error){errors.push(`SPEAKER_NOTES cannot be parsed: ${error.message}`);}
}
if(!Array.isArray(speakerNotes))errors.push('SPEAKER_NOTES must be an array.');
else{
if(speakerNotes.length!==slideIds.length)errors.push(`Notes/slides mismatch: ${speakerNotes.length} notes for ${slideIds.length} slides.`);
const noteIds=speakerNotes.map(n=>n?.id);
const noteDupes=noteIds.filter((id,i)=>id&&noteIds.indexOf(id)!==i);
if(noteDupes.length)errors.push(`Duplicate speaker-note id: ${[...new Set(noteDupes)].join(', ')}`);
speakerNotes.forEach((note,i)=>{
if(!note||typeof note!=='object'){errors.push(`Note ${i+1}: must be an object.`);return;}
for(const field of ['id','title','purpose','transition'])if(typeof note[field]!=='string'||!note[field].trim())errors.push(`Note ${i+1}: ${field} is required.`);
if(!Number.isFinite(Number(note.minutes))||Number(note.minutes)<=0)errors.push(`Note ${i+1}: minutes must be > 0.`);
if(!Array.isArray(note.talk)||!note.talk.length)errors.push(`Note ${i+1}: talk must be a non-empty array.`);
else{
if(note.talk.some(x=>typeof x!=='string'||!x.trim()))errors.push(`Note ${i+1}: every talk item must be non-empty text.`);
if(note.talk.length<3||note.talk.length>5)warnings.push(`Note ${i+1} (${note.id}): talk has ${note.talk.length} items; 3-5 is the default.`);
}
if(slideIds[i]&&note.id!==slideIds[i])errors.push(`Position ${i+1}: note id "${note.id}" does not match slide id "${slideIds[i]}".`);
});
}
const runtimeChecks=[
['right-bottom presenter entry','id="ppt-presenter-btn"'],
['presenter shell','id="ppt-presenter"'],
['overview control','id="ppt-grid"'],
['first-page control','id="ppt-first"'],
['last/restart control','id="ppt-last"'],
['audience status','id="ppt-sync"'],
['audience recovery','id="ppt-reopen"'],
['direct window sync','postMessage('],
['BroadcastChannel fallback','BroadcastChannel'],
['storage fallback','__guizangPptSync'],
];
for(const [label,needle]of runtimeChecks)if(!html.includes(needle))errors.push(`Presenter runtime missing ${label}.`);
const planned=speakerNotes.reduce((sum,n)=>sum+(Number(n?.minutes)||0),0);
if(Number.isFinite(targetMinutes)&&targetMinutes>0&&planned>targetMinutes*.9)errors.push(`Planned ${planned.toFixed(1)} min exceeds the 90% speaking budget (${(targetMinutes*.9).toFixed(1)} of ${targetMinutes} min).`);
if(Number.isFinite(targetMinutes)&&targetMinutes>0&&planned<targetMinutes*.55)warnings.push(`Planned ${planned.toFixed(1)} min is below 55% of the requested ${targetMinutes} min; confirm the deck is not under-scripted.`);
warnings.forEach(x=>console.warn(`WARN ${x}`));
errors.forEach(x=>console.error(`ERROR ${x}`));
console.log(`Presenter validation: ${slideIds.length} slides, ${speakerNotes.length} notes, ${planned.toFixed(1)} planned minutes, ${errors.length} errors, ${warnings.length} warnings.`);
process.exit(errors.length?1:0);