diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 681addb..6a03bbe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,8 @@ jobs: run: | for f in skills/html-slide-deck/scripts/*.mjs skills/html-slide-deck/references/*.mjs skills/html-slide-deck/references/offline-build-template/scripts/*.mjs; do node --check "$f"; done node --check skills/html-slide-deck/references/offline-build-template/build.js + for f in skills/html-slide-deck/references/live-poll/*.mjs skills/html-slide-deck/references/live-poll/screen/*.js skills/html-slide-deck/references/live-poll/worker/src/*.js; do node --check "$f"; done + node -e "JSON.parse(require('fs').readFileSync('skills/html-slide-deck/references/live-poll/worker/src/polls.json','utf8'))" python -m py_compile skills/html-slide-deck/scripts/montage.py examples/pptx-edits-back-to-html.py bash -n install.sh skills/html-slide-deck/scripts/*.sh pip install ruff && ruff check skills/html-slide-deck/scripts/montage.py examples/pptx-edits-back-to-html.py diff --git a/.gitignore b/.gitignore index 2e68810..1cc798a 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ node_modules/ __pycache__/ .DS_Store .rt-frame.md +.dev.vars +.wrangler/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e71e10..7137895 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +- 新增觀眾手機即時投票 `skills/html-slide-deck/references/live-poll/`:Cloudflare Worker+Durable Object 後端(手機頁、題目 JSON、WebSocket 推播、斷線輪詢)、投影片端 `screen/live-poll.js`/`.css`、`load-test.mjs`(150 支手機壓測);新腳本 `scripts/poll-slides.mjs` 把占位 section 換成加入頁與投票頁並注入講者端程式。源自 2026-10 一場約 150 人講座的實際做法。 +- 新增 `references/interactive-pages.md`:互動地圖(經緯度投影 SVG、距離圈)、製作技巧頁(格式比較表+AI 協作清單)、PPTX 轉 HTML 插入互動頁與頁碼重編。`docs/TECHNIQUES.md` 補 4.5–4.7,README 同步。 +- X 簡報技巧整理的「流程與設計原則」「現成 Skill 與工具」併入 skill:`references/design-principles.md`,SKILL.md 第 1 步要求規劃前先讀;原 docs 檔標註兩處一起改。 + - 新增 `docs/html-slides-tips-from-x-2026-10-17.md`:整理自 X 使用者分享的 HTML 簡報流程原則、現成 Skill/工具與本專案固定規則(含來源連結);README 目錄樹補連結。 - 修正:固定規則原本只在 `docs/`,`install.sh` 不會安裝、skill 也沒引用。改為在兩個 skill 各附 `references/fixed-rules.md`(規則文字照抄),並在各自 `SKILL.md` 要求先讀並遵守;README 開頭簡介補上這兩點。 diff --git a/README.md b/README.md index 0774744..928cc24 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ reveal.js 簡報製作流程,包成兩個 Agent Skill。
Claude Code・Codex CLI・Gemini CLI・Grok Build 都能裝。
-安裝後的 skill 內建作者的固定規則(`references/fixed-rules.md`);X 上分享的簡報技巧整理見 [docs/html-slides-tips-from-x-2026-10-17.md](docs/html-slides-tips-from-x-2026-10-17.md)。 +安裝後的 skill 內建作者的固定規則(`references/fixed-rules.md`)與規劃設計原則(`references/design-principles.md`);X 上分享的簡報技巧整理見 [docs/html-slides-tips-from-x-2026-10-17.md](docs/html-slides-tips-from-x-2026-10-17.md)。 [![CI](https://github.com/keanu77/htmlpptskill/actions/workflows/ci.yml/badge.svg)](https://github.com/keanu77/htmlpptskill/actions) [![License: MIT](https://img.shields.io/badge/License-MIT-teal.svg)](LICENSE) @@ -18,7 +18,7 @@ Claude Code・Codex CLI・Gemini CLI・Grok Build 都能裝。
實際講座成品:封面 GSAP 逐字浮現、七張卡片一次翻面、頁內真的能作答的測驗、網站+repo 雙 QR。54 頁、3 MB、拔掉網路照樣播。

-安裝 · 10 分鐘上手 · 快捷鍵、換場與動畫技巧 · 疑難排解 +安裝 · 10 分鐘上手 · 現場互動 · 快捷鍵、換場與動畫技巧 · 疑難排解

@@ -35,12 +35,13 @@ Claude Code・Codex CLI・Gemini CLI・Grok Build 都能裝。
| 主辦單位要 PPTX,你只有 HTML | `render-slides` → `build-handout`:一頁一圖的 PPTX,講者備註進備忘稿;PDF 一行指令 | | 想要「炫一點」但怕翻車 | 效果 skill 分穩重/中間/驚豔三檔,內建減少動態效果與 QA 靜態模式 | | 講者在 PPTX 上手改了字,改動回不去 HTML | 附完整實例:從 PPTX 抽文字、解 QR 圖、回灌 HTML | +| 想讓台下一起作答,又不想把觀眾導去別的平台 | 內附手機即時投票:掃 QR 作答、投影片即時統計、按 `A` 揭曉同步到手機;150 支手機實測,斷網照樣能揭曉 | ## 兩個 skill | Skill | 一句話 | 什麼時候會被觸發 | |---|---|---| -| **`html-slide-deck`** | 從大綱到交付的主流程:版型、圖片、QR、互動頁、逐頁 QA、講義 PPTX/PDF、原生可編輯 PPTX | 「做一份 HTML 簡報」「reveal.js」「簡報加截圖/QR」「轉 PPT/講義」「離線簡報」 | +| **`html-slide-deck`** | 從大綱到交付的主流程:版型、圖片、QR、互動頁、觀眾手機即時投票、逐頁 QA、講義 PPTX/PDF、原生可編輯 PPTX | 「做一份 HTML 簡報」「reveal.js」「簡報加截圖/QR」「現場投票」「轉 PPT/講義」「離線簡報」 | | **`html-slide-effects`** | 加在其上的效果:逐步出現、Auto-Animate、3D 翻卡、GSAP 逐字、計時條、點圖放大、手機捲動 | 「加動畫」「卡片翻面」「讓簡報更生動」「有什麼效果可以加」 | ## 30 秒安裝 @@ -107,6 +108,7 @@ node $SKILL/scripts/print-pdf.mjs index.html handout.pdf # PDF 講 QA:qa-screenshots.mjs ─ 截圖、溢出、JS 錯誤、對外請求、QR 逐張解碼比對、montage.png ─ 有問題 exit 1 交付:index.html(單檔)+ 講義 PPTX + PDF(+ 原生可編輯 PPTX) 效果:html-slide-effects → 加完回頭重跑 QA +互動:手機投票 poll-slides.mjs(講者版另存)、互動地圖、頁內測驗 ```
@@ -118,6 +120,47 @@ QA:qa-screenshots.mjs ─ 截圖、溢出、JS 錯誤、對外請求、QR 逐 - `PLAN.md`+`ROLE-AUTHOR.md`/`ROLE-REVIEWER.md`:想讓兩個 agent 一個主筆一個稽核,角色由你指定。
+## 📱 現場互動:手機投票、互動地圖 + +左:投影片即時顯示全場作答比例並揭曉答案;右:觀眾手機同步顯示答案 + +左:投影片即時顯示全場作答比例,講者按 A 揭曉;右:觀眾手機同步顯示答案。2026-10 一場約 150 人的講座實際使用。 + +### 觀眾用手機即時作答 + +頁內測驗只有台上一個人點;要全場一起作答,用 [`references/live-poll/`](skills/html-slide-deck/references/live-poll/README.md)。 + +| 你要的 | 做法 | +|---|---| +| 觀眾不用裝 App、不用登入 | 掃 QR 開網頁作答,匿名、可改答案、不重複計票 | +| 投影片上即時看到全場答案 | 選擇題顯示百分比,猜數字顯示直方圖與中位數,加入頁顯示在線手機數 | +| 揭曉時台下也看得到 | 講者按 `A`,投影片標出正解與說明,答案同步推到每支手機;揭曉前手機拿不到答案 | +| 彩排完要清掉測試票 | 在該題按 `Shift+R` | +| 會場網路斷了 | 投影片顯示提示並改輪詢;`A` 照樣揭曉 | +| 人多會不會塞車 | 150 支手機壓測:150 票約 0.2–1.2 秒寫入並推到投影片 | + +後端是 Cloudflare Worker+Durable Object(免費方案即可),部署一次之後只改題目檔。 + +```html +
+
+``` +```bash +cp -R $SKILL/references/live-poll/worker my-poll && cd my-poll && npm install +npx wrangler login && npx wrangler secret put ADMIN_KEY && npx wrangler deploy # 同一把 key 也寫進 .dev.vars +node $SKILL/scripts/poll-slides.mjs index.html index-live.html --worker my-poll --url https://slide-poll.<子網域>.workers.dev +``` + +產出的 `index-live.html` 內含講者控制用的 ADMIN_KEY,**只放講者電腦**;要分享的版本用原本的 `index.html`。 + +### 互動地圖與製作技巧頁 + +- **互動地圖**:點場館顯示項目、最近據點與直線距離,按鈕切換「20 km 圈」「標出 20 km 外」;經緯度投影的 SVG,離線可用。 +- **製作技巧頁**:Q&A 前一頁放 PowerPoint/PDF/HTML 優缺點比較表與 AI 協作清單。 +- **PPTX 轉 HTML 插入互動頁**:以「原 PPTX 第幾頁之後」指定位置,插頁後自動重編頁碼。 + +程式碼與內容範例:[`references/interactive-pages.md`](skills/html-slide-deck/references/interactive-pages.md)。 + ## 🎬 快捷鍵、換場與動畫技巧 以下是作者 55 頁工作坊簡報實際用過、驗證過的做法,適用 reveal.js 5.2。點開每一項看可直接複製的程式碼。同一份內容也在 [docs/TECHNIQUES.md](docs/TECHNIQUES.md)。 @@ -472,6 +515,8 @@ show(); +> 全場用手機作答、互動地圖、製作技巧頁:見上方 [📱 現場互動](#-現場互動手機投票互動地圖)。 + ### 🧪 讓動畫不妨礙 QA、PDF 與無障礙
@@ -537,7 +582,7 @@ html.static .flip .inner, html.static .reveal .fragment, html.static .wall .subs ### 🕳️ 常見的坑
-十個踩過的雷與解法 +十二個踩過的雷與解法 | 現象 | 原因與解法 | |---|---| @@ -551,6 +596,8 @@ html.static .flip .inner, html.static .reveal .fragment, html.static .wall .subs | 外掛鍵位沒反應或行為怪 | 撞到內建鍵(`B` 黑屏、`S` 講者、`O` 總覽);換鍵 | | 插入 script 後講者視窗壞掉(`RevealNotes is not defined`) | notes.js 原始碼裡也有 ``;程式插入時找**最後一個** `` | | 3D 粒子頁卡頓 | 降 `count`、`setPixelRatio` 上限 1.5、離開封面就 `stop()` | +| 投票頁截圖被判溢出 | 隱藏的答案框用 `translateY` 往下推會撐出捲動範圍;改用 `scale` | +| 手機投票票數一直是 0 | 測試用的 voter 代號太短被格式檢查擋掉(需 8–64 字元) |
@@ -569,13 +616,16 @@ html.static .flip .inner, html.static .reveal .fragment, html.static .wall .subs skills/ html-slide-deck/ SKILL.md 主流程(給 agent 讀) - scripts/ qa-screenshots、render-slides、build-handout、print-pdf、inline-assets、 + scripts/ qa-screenshots、render-slides、build-handout、print-pdf、inline-assets、poll-slides、 resolve、montage.py、pptx-to-pdf.sh(macOS)、gen-image.sh(可換生圖工具) references/ reveal-template.html 深色版型:chip/card/grid/steps/mock/qrcard/viz/互動頁(reveal.js 5.2) offline-build-template/ 做法 B:content.js+build.js+check.mjs+PLAN.md+角色檔 pptxgenjs-helpers.mjs 原生 PPTX 的 theme 與 helper(+ native-pptx-example.mjs) pitfalls.md 踩過的雷與可複用片段 + design-principles.md 規劃與設計原則、現成工具(整理自 X 分享,SKILL.md 第 1 步引用) + interactive-pages.md 互動地圖、製作技巧頁、PPTX 轉 HTML 插入互動頁 + live-poll/ 手機即時投票:worker/(Cloudflare Worker+Durable Object)、screen/(投影片端)、load-test.mjs fixed-rules.md Keanu 的固定規則(SKILL.md 要求遵守) html-slide-effects/ SKILL.md @@ -617,7 +667,7 @@ AGENTS.md、CLAUDE.md 給在本 repo 工作的 agent Two Agent Skills (open `SKILL.md` format) for building **reveal.js HTML slide decks** with an AI coding agent, plus the scripts that make them trustworthy: -- **html-slide-deck**: outline-first workflow, dark professional template, real screenshots, QR codes, interactive pages, per-slide screenshot QA (overflow, JS errors, external requests, **QR decoded and compared with `data-url`**), export to handout PPTX (notes included), PDF, and native editable PPTX. +- **html-slide-deck**: outline-first workflow, dark professional template, real screenshots, QR codes, interactive pages, live audience polling from phones (Cloudflare Worker), per-slide screenshot QA (overflow, JS errors, external requests, **QR decoded and compared with `data-url`**), export to handout PPTX (notes included), PDF, and native editable PPTX. - **html-slide-effects**: fragments, Auto-Animate, 3D flip cards, GSAP, timer bar, lightbox, scroll view, with reduced-motion and a STATIC switch so QA screenshots and PDFs capture the final state. Works with Claude Code, OpenAI Codex CLI, Gemini CLI and Grok Build (`./install.sh` detects them). Docs are in Traditional Chinese; the scripts and templates are language-neutral. diff --git a/docs/TECHNIQUES.md b/docs/TECHNIQUES.md index 2fd24a8..0fbc206 100644 --- a/docs/TECHNIQUES.md +++ b/docs/TECHNIQUES.md @@ -7,7 +7,7 @@ 1. [播放快捷鍵](#1-播放快捷鍵) 2. [換場(Transitions)](#2-換場transitions) 3. [動畫技巧](#3-動畫技巧):逐步出現、Auto-Animate、3D 翻卡、卡片牆飛入、GSAP 逐字、Three.js 粒子 -4. [講者工具](#4-講者工具):Spotlight 聚光燈、計時條、點圖放大、頁內測驗 +4. [講者工具](#4-講者工具):Spotlight 聚光燈、計時條、點圖放大、頁內測驗、手機即時投票、互動地圖、製作技巧頁 5. [讓動畫不妨礙 QA、PDF 與無障礙](#5-讓動畫不妨礙-qapdf-與無障礙) 6. [怎麼挑:穩重、中間、驚豔](#6-怎麼挑穩重中間驚豔) 7. [常見的坑](#7-常見的坑) @@ -337,6 +337,32 @@ show(); 醫療或專業題目:題目與解釋要自己審過,參考文獻不確定就標「待查證」。 +### 4.5 觀眾手機即時投票 + +頁內測驗(4.4)只有台上一個人點;要全場一起作答,用 `skills/html-slide-deck/references/live-poll/`:觀眾掃 QR 用手機作答,投影片即時顯示百分比或直方圖,講者按 `A` 揭曉、答案同步推到手機,`Shift+R` 清空本題。後端是 Cloudflare Worker+Durable Object,150 支手機實測通過。 + +```html +
+
+``` +```bash +node $SKILL/scripts/poll-slides.mjs index.html index-live.html --worker my-poll --url https://slide-poll.<子網域>.workers.dev +``` + +- 產出的 `index-live.html` 內含講者控制用的 ADMIN_KEY,只放講者電腦。 +- 斷網照樣能播、能揭曉;自動化瀏覽器與 `?print-pdf` 不連線,QA 不受影響。 +- 部署、題目格式、API、壓測:[`live-poll/README.md`](../skills/html-slide-deck/references/live-poll/README.md)。 + +### 4.6 互動地圖 + +場館、據點、距離這類空間論點,做成可點的 SVG 地圖:點場館顯示項目、最近據點與直線距離,按鈕切換「20 km 圈」與「標出 20 km 外」。經緯度線性投影、純前端、離線可用。完整程式碼:[`interactive-pages.md`](../skills/html-slide-deck/references/interactive-pages.md#互動地圖點據點看離中繼站多遠)。 + +最容易踩的坑:據點標記蓋住旁邊的場館點,點不到。據點先畫、場館後畫,並給據點 `pointer-events:none`。 + +### 4.7 製作技巧頁 + +在 Q&A 前一頁分享「這份簡報怎麼做的」:左邊 PowerPoint/PDF/HTML 的優缺點比較表,右邊 AI 協作清單。實際內容見 [`interactive-pages.md`](../skills/html-slide-deck/references/interactive-pages.md#製作技巧頁結尾前分享這份簡報怎麼做的)。 + --- ## 5. 讓動畫不妨礙 QA、PDF 與無障礙 @@ -411,3 +437,5 @@ html.static .flip .inner, html.static .reveal .fragment, html.static .wall .subs | 外掛鍵位沒反應或行為怪 | 撞到內建鍵(`B` 黑屏、`S` 講者、`O` 總覽);換鍵 | | 插入 script 後講者視窗壞掉(`RevealNotes is not defined`) | notes.js 原始碼裡也有 ``;程式插入時找**最後一個** `` | | 3D 粒子頁卡頓 | 降 `count`、`setPixelRatio` 上限 1.5、離開封面就 `stop()` | +| 投票頁截圖被判溢出 | 隱藏的答案框用 `translateY` 往下推會撐出捲動範圍;改用 `scale` | +| 手機投票票數一直是 0 | 測試用的 voter 代號太短被格式檢查擋掉(需 8–64 字元) | diff --git a/docs/html-slides-tips-from-x-2026-10-17.md b/docs/html-slides-tips-from-x-2026-10-17.md index c237157..7407a90 100644 --- a/docs/html-slides-tips-from-x-2026-10-17.md +++ b/docs/html-slides-tips-from-x-2026-10-17.md @@ -1,6 +1,8 @@ # HTML 簡報技巧整理(來源:X 使用者分享,2026-10-17 整理) > 以下為 X 上的使用者分享,非官方文件;工具未經實測,GitHub 星數為貼文所述。 +> +> 前兩節已整理進 skill:`skills/html-slide-deck/references/design-principles.md`(安裝時一起裝,SKILL.md 第 1 步引用);修改時兩處一起改。 ## 流程與設計原則 - 先規劃受眾、結論與敘事結構,再生成頁面;依內容選用圖表、表格、時間線、轉場,輸出可離線分享的單檔 HTML。來源:https://x.com/leowu_love/status/2082124253437247863 diff --git a/docs/live-poll.png b/docs/live-poll.png new file mode 100644 index 0000000..9a99395 Binary files /dev/null and b/docs/live-poll.png differ diff --git a/skills/html-slide-deck/SKILL.md b/skills/html-slide-deck/SKILL.md index d133d0f..4f270e0 100644 --- a/skills/html-slide-deck/SKILL.md +++ b/skills/html-slide-deck/SKILL.md @@ -1,6 +1,6 @@ --- name: html-slide-deck -description: 用 reveal.js 製作或修改 HTML 簡報(單檔、可離線、深色專業版型、真實截圖、QR、互動頁),逐頁截圖 QA 並驗證 QR,再輸出講義 PPTX/PDF 或原生可編輯 PPTX。當使用者說「做一份 HTML 簡報」「reveal.js 簡報」「簡報加底圖/截圖/QR」「HTML 簡報轉 PPT/講義/PDF」「離線簡報」時使用。要加動畫特效時搭配 html-slide-effects。 +description: 用 reveal.js 製作或修改 HTML 簡報(單檔、可離線、深色專業版型、真實截圖、QR、互動頁、觀眾手機即時投票),逐頁截圖 QA 並驗證 QR,再輸出講義 PPTX/PDF 或原生可編輯 PPTX。當使用者說「做一份 HTML 簡報」「reveal.js 簡報」「簡報加底圖/截圖/QR」「簡報加互動/現場投票/觀眾用手機作答」「HTML 簡報轉 PPT/講義/PDF」「離線簡報」時使用。要加動畫特效時搭配 html-slide-effects。 --- # HTML 簡報(reveal.js)完整流程 @@ -19,7 +19,8 @@ description: 用 reveal.js 製作或修改 HTML 簡報(單檔、可離線、 ## 1. 大綱 → 確認 → 生成 -- 先輸出大綱表(頁碼|標題|內容摘要|標記 [BG]/[SHOT]/[INTERACT]/[VIZ]),**等使用者確認再寫 HTML**。 +- 規劃前讀 `references/design-principles.md`(受眾→結論→敘事結構、統一設計系統、一份來源多種輸出、互動區塊怎麼挑;附現成工具清單)。 +- 先輸出大綱表(頁碼|標題|內容摘要|標記 [BG]/[SHOT]/[INTERACT]/[POLL]/[VIZ]),**等使用者確認再寫 HTML**。 - **兩種做法,先選一種**: - **A 單檔手寫**(≤25 頁、一次交付):複製 `references/reveal-template.html`,直接寫 `
`。範本預設從 CDN 載 reveal.js 5.2;交付前跑 `node $SKILL/scripts/inline-assets.mjs in.html out.html` 內嵌 reveal.js 與圖片(執行目錄要有 `reveal.js` 套件:`npm i reveal.js@5`)。 - **B 內容/版型分離**(>25 頁、多輪改稿、要離線、要 QR 驗證):複製 `references/offline-build-template/` 成新專案,填 `PLAN.md`,`content.js` 一頁一個物件(`type` 決定版型、`notes` 為備註),`npm install && npm run all` 產出 `dist/index.html`(reveal.js、CSS、圖片、QR 全內嵌)。範本是 8 頁中性起手;50 頁完整實例在 repo `examples/workshop-2026/`。 @@ -44,6 +45,9 @@ description: 用 reveal.js 製作或修改 HTML 簡報(單檔、可離線、 ## 3. 互動 - **課堂互動平台**(多數不能 iframe):互動頁=掃碼加入 QR+場次代碼(`.cl-code` 以 localStorage 跨頁同步,範本「範例 8」含完整頁面與腳本;`.cl-code-input` 要 `stopPropagation` 否則 reveal 吃掉按鍵)+「開啟主持畫面」新分頁按鈕+學生端截圖。範本以 `example.com` 占位,換成你的平台網址。 +- **觀眾手機即時投票**(現場有穩定網路時):`references/live-poll/`。Cloudflare Worker+Durable Object 收票、WebSocket 推到投影片;選擇題顯示百分比、猜數字顯示直方圖與中位數;講者 `A` 揭曉(手機同步顯示答案)、`Shift+R` 清空本題;斷網照樣能揭曉。HTML 寫占位 `
`、`
`,再跑 `node $SKILL/scripts/poll-slides.mjs in.html out-live.html --worker --url `。部署、建帳號前先問;**講者版 HTML 內含 ADMIN_KEY,不可外流**。完整步驟與壓測見 `references/live-poll/README.md`。 +- **互動地圖**(場館、據點、距離):座標存 JSON,SVG 以經緯度線性投影畫點,點擊顯示名稱與到最近據點的距離、按鈕切換範圍圈;做法見 `references/interactive-pages.md`。 +- **製作技巧頁**(結尾前分享怎麼做的):格式比較表+AI 協作清單,內容範例見 `references/interactive-pages.md`。 - **滑桿對比**(`viz`):適合「沒有版本控制 vs 有」這類前後對比。 - 頁面裡放**真的能用的東西**(可作答的小測驗、可拉的滑桿)比截圖更有說服力;純 JS、離線可用。 diff --git a/skills/html-slide-deck/references/design-principles.md b/skills/html-slide-deck/references/design-principles.md new file mode 100644 index 0000000..fb6abf9 --- /dev/null +++ b/skills/html-slide-deck/references/design-principles.md @@ -0,0 +1,25 @@ +# 規劃與設計原則、現成工具(整理自 X 使用者分享,2026-10-17) + +> 來源是 X 上的使用者分享,非官方文件;工具未經實測,看需求再自行評估。原始整理與來源連結:repo `docs/html-slides-tips-from-x-2026-10-17.md`(修改時兩處一起改)。 + +## 規劃與設計原則(第 1 步「大綱」時套用) + +- **先想受眾、結論、敘事結構,再生成頁面**;依內容選圖表、表格、時間線、轉場,輸出可離線分享的單檔 HTML。 +- **固定畫布、自包含單檔**(本 skill 是 1280×720);大字、留白、統一設計系統;在瀏覽器預覽中改,必要時才加動畫。 +- **字型、留白、線條、配色、動效要像同一套設計**,不要拼湊模板:只用範本的 tokens 與 class。 +- **一份來源輸出多種格式**(簡報、PDF、網站),避免同一內容改三次:本 skill 以 HTML(或 content.js)為源頭,講義 PPTX/PDF 由腳本產生。 +- **互動區塊依結構挑**:點擊深挖、滑桿重算、現場投票;整份依「結論與依據/分類/比較/因果/解決方案/順序」其中一種組織。互動頁做法見 SKILL.md 第 3 節。 + +## 現成 Skill 與工具(需要時參考) + +| 名稱 | 用途 | +|---|---| +| guizang-ppt-skill | 文章或大綱轉瀏覽器 HTML 簡報,雜誌風/瑞士風,講稿備註、排練計時、獨立觀眾螢幕 | +| ppt-master | 原生可編輯 PPTX | +| frontend-slides | 高完成度 HTML 簡報 | +| ppt-director | 先導演再生成,HTML 預覽審查後轉 PPTX | +| html-to-ppt | 先寫 HTML 簡報再匯出原生 PPTX,文字、表格、圖表可編輯 | +| Slide Studio | 像 PowerPoint 一樣編輯 AI 生成的 HTML 簡報,匯出仍是乾淨 HTML、保留動畫 | +| DeckFlow | PPTX 轉 HTML 簡報,輸出分頁 HTML 與素材 | +| Quarkdown | 一份 Markdown 同時輸出簡報、PDF、網站 | +| Lieflat Charts | 貼文所述:字型、留白、線條、配色、動效一致的設計 Skill,支援 SKILL.md 的 Agent | diff --git a/skills/html-slide-deck/references/interactive-pages.md b/skills/html-slide-deck/references/interactive-pages.md new file mode 100644 index 0000000..4a18cb5 --- /dev/null +++ b/skills/html-slide-deck/references/interactive-pages.md @@ -0,0 +1,92 @@ +# 互動頁:互動地圖、製作技巧頁(2026-10 講座實例) + +手機即時投票另見 `live-poll/README.md`。以下兩種頁面都是純前端、離線可用。 + +## 互動地圖:點據點,看離中繼站多遠 + +適合「場館分散、據點涵蓋不到」這類論點:觀眾自己點,比一張靜態地圖有說服力。 + +資料(`data-map` 屬性或內嵌 JSON;距離先用 Python 以 haversine 算好): +```json +{ "hubs": [{ "name": "〈據點 A〉", "lng": 136.90, "lat": 35.17 }], + "venues": [{ "name": "〈場館〉", "tags": ["〈項目〉"], "lng": 137.10, "lat": 35.05, "hub": "〈據點 A〉", "km": 24.3 }] } +``` +```html +
+ +
點地圖上的場館
+ +
+``` +```js +// 範圍內用等距圓柱投影即可:經度乘 cos(緯度) 校正,誤差在幾十公里尺度可以忽略 +const LNG0 = 136.45, LNG1 = 138.05, LAT0 = 34.56, LAT1 = 35.48, W = 800, FAR_KM = 20; +const KX = W / (LNG1 - LNG0), KY = KX / Math.cos((LAT0 + LAT1) / 2 * Math.PI / 180), H = (LAT1 - LAT0) * KY; +const P = (lng, lat) => [(lng - LNG0) * KX, (LAT1 - lat) * KY]; +const NS = 'http://www.w3.org/2000/svg'; +const el = (tag, attrs, parent) => { const n = document.createElementNS(NS, tag); + Object.entries(attrs).forEach(([k, v]) => n.setAttribute(k, v)); parent && parent.appendChild(n); return n; }; + +function initMap(sec) { + if (sec.dataset.ready) return; sec.dataset.ready = '1'; + const data = JSON.parse(sec.dataset.map), svg = sec.querySelector('.map'), info = sec.querySelector('.info'); + svg.setAttribute('viewBox', `0 0 ${W} ${H.toFixed(0)}`); + data.hubs.forEach(h => { const [x, y] = P(h.lng, h.lat); el('circle', { class: 'ring', cx: x, cy: y, r: FAR_KM / 111.32 * KY }, svg); }); + const link = el('line', { class: 'link', opacity: 0 }, svg); + data.hubs.forEach(h => { // 據點先畫、場館點後畫,場館才在上層 + const [x, y] = P(h.lng, h.lat), g = el('g', { class: 'hub' }, svg); + el('circle', { cx: x, cy: y, r: 15 }, g); + el('text', { x: x + 22, y: y + 7 }, g).textContent = h.name; + }); + data.venues.forEach(v => { + const [x, y] = P(v.lng, v.lat); + const c = el('circle', { class: 'v' + (v.km >= FAR_KM ? ' is-far' : ''), cx: x, cy: y, r: 8 }, svg); + c.addEventListener('click', () => { + info.innerHTML = `${v.name}
${v.tags.join('、')}
最近:${v.hub} ${v.km.toFixed(1)} km 直線`; + const hub = data.hubs.find(h => h.name === v.hub), [x2, y2] = P(hub.lng, hub.lat); + Object.entries({ x1: x, y1: y, x2, y2, opacity: 1 }).forEach(([k, val]) => link.setAttribute(k, val)); + }); + }); + sec.querySelectorAll('[data-act]').forEach(b => b.addEventListener('click', () => + b.classList.toggle('on', svg.classList.toggle(b.dataset.act === 'ring' ? 'rings' : 'far')))); +} +Reveal.on('slidechanged', e => e.currentSlide.classList.contains('mapslide') && initMap(e.currentSlide)); +``` +```css +.map .ring { fill: rgba(232,132,107,.08); stroke: var(--coral); stroke-dasharray: 8 7; opacity: 0; transition: opacity .4s } +.map.rings .ring { opacity: 1 } +.map .v { fill: var(--teal); stroke: #fff; stroke-width: 2; cursor: pointer } +.map.far .v.is-far { fill: var(--coral) } .map.far .v:not(.is-far) { opacity: .35 } +.map .hub { pointer-events: none } /* 不然據點標記會蓋住旁邊的場館、點不到 */ +.map .link { stroke: var(--amber); stroke-width: 3; stroke-dasharray: 6 5 } +``` +- 海岸線:Natural Earth `ne_10m_land` 裁到範圍內,存成 `[[lng, lat], …]` 折線陣列,用同一個 `P()` 畫 `polyline`。 +- 範圍外的點(例如離主圖 100 km 的場館)不要硬塞進地圖,放在側欄文字,點一下一樣顯示資訊。 +- 「幾個場館在 N km 外」要和備註的數字同一條規則算(`>=` 還是 `>`),剛好 20.0 km 的點會讓兩邊對不上。 +- 同一份座標可以用 Pillow 輸出靜態圖給 PPTX 版;標籤擺放要檢查引線交叉與引線穿過別的標籤。 + +## 製作技巧頁:結尾前分享「這份簡報怎麼做的」 + +左邊格式比較表、右邊 AI 協作清單;放在 Q&A 前一頁。以下是講座實際使用的內容,換成你的。 + +| | PowerPoint/Keynote | PDF | HTML(reveal.js) | +|---|---|---|---| +| 修改 | 最直覺,誰都能改 | 只能回原檔改 | 改 PPT 或來源後一鍵重轉 | +| 動畫與互動 | 內建動畫;無法連網互動 | 無 | 程式動畫、地圖、手機即時投票 | +| 播放 | 需要軟體與字型 | 任何裝置 | 瀏覽器即可,單檔離線 | +| 影片/分享 | 檔案大、版本混亂 | 不能放影片 | 內嵌影片;可放網路分享 | +| 風險 | 跨電腦跑版 | 沒有講者備註 | 需測瀏覽器;投票需要網路 | + +AI 協作清單(每項一行標題+一行說明): +- **程式產生投影片**:python-pptx/pptxgenjs 逐頁產生,字級、配色、版型一致 +- **資料變圖表**:座標算距離、畫地圖;紀錄去識別後彙總 +- **PPT → HTML**:講者在 PowerPoint 定稿,程式轉成 reveal.js 單檔 +- **現場互動**:Cloudflare Worker 收手機作答,WebSocket 即時推到投影片 +- **查核**:文獻逐條核對年份與數字 +- 結語一句:「人負責內容與判斷,AI 負責把重複的工作寫成程式」 + +表格字級 ≥ 20px;1280×720 下左表約 700px、右欄約 440px 寬。 + +## 從 PPTX 轉出的 HTML 插入互動頁 + +講者在 PowerPoint 定稿、再轉 HTML 時,互動頁用「原 PPTX 第幾頁之後」描述插入位置(`after`),同一個 `after` 依清單順序排列,轉檔程式最後統一插入。插頁後投影片上印的頁碼會錯位:用 JS 在載入時依實際順序重編(找右下角、內容只有數字的文字框)。 diff --git a/skills/html-slide-deck/references/live-poll/README.md b/skills/html-slide-deck/references/live-poll/README.md new file mode 100644 index 0000000..ab52cca --- /dev/null +++ b/skills/html-slide-deck/references/live-poll/README.md @@ -0,0 +1,115 @@ +# 手機即時投票(live-poll) + +觀眾用手機掃 QR 作答,投影片即時顯示百分比或直方圖,講者按 `A` 揭曉,答案同步推到每支手機。 +後端是一個 Cloudflare Worker+Durable Object(SQLite),WebSocket 用 hibernation API,閒置不計費。 +2026-10 一場約 150 人的講座實際使用;本機與正式站都以 150 支假手機壓測過,150 票約 0.2–1.2 秒內寫入並推到投影片。 + +``` +live-poll/ + worker/ Cloudflare Worker(部署一次,之後只改 polls.json) + src/index.js 路由、Durable Object、API、WebSocket 推播 + src/phone.js 觀眾手機頁(不含答案,揭曉後才由伺服器送出) + src/polls.json 題目、選項、答案、解釋 + wrangler.jsonc + screen/ 投影片端:由 scripts/poll-slides.mjs 注入 + live-poll.js 開題、即時更新、A 揭曉、Shift+R 清空、斷線改輪詢 + live-poll.css 沿用 reveal-template.html 的色彩 tokens + load-test.mjs N 支假手機壓測 +``` + +## 什麼時候用 + +- 現場有穩定網路、想讓全場一起作答:用這個。 +- 現場網路不確定:用頁內測驗(`docs/TECHNIQUES.md` 4.4,純前端、離線可用),或課堂互動平台。 +- 這套沒有網路照樣能播:投票頁按 `A` 一樣顯示答案,只是沒有全場統計。 + +## 1. 部署(一次) + +需要 Cloudflare 帳號(免費方案即可)與 Node 18+。部署前先問使用者。 + +```bash +cp -R $SKILL/references/live-poll/worker my-poll && cd my-poll +npm install # 裝 wrangler 4 +npx wrangler login +openssl rand -hex 24 | tee /dev/stderr | npx wrangler secret put ADMIN_KEY +printf 'ADMIN_KEY=%s\n' '<上一行印出的 key>' > .dev.vars && chmod 600 .dev.vars +npx wrangler deploy # 印出 https://slide-poll.<子網域>.workers.dev +``` + +- `.dev.vars` 已在 `.gitignore`,**不要 commit**;`wrangler dev` 本機測試也讀它。 +- `wrangler.jsonc` 的 `name` 改成你的;`compatibility_date` 不能晚於本機 wrangler 支援的日期,否則 `wrangler dev` 起不來。 +- 同一個 Worker 給不同場次用:在 `wrangler.jsonc` 加 `"vars": { "ROOM_NAME": "場次名稱" }`,不同房間的票互不相干。 + +## 2. 寫題目 + +`src/polls.json`:`event` 是手機頁標題;每題一個 id。 + +| 欄位 | 選擇題 `choice` | 猜數字 `number` | +|---|---|---| +| `kicker`、`title`、`stem` | 小標、題目、題幹 | 同左 | +| `options` | 選項陣列 | — | +| `min`、`max`、`unit` | — | 允許範圍與單位 | +| `bins` | — | 直方圖分組 `[[lo, hi], …]`,最後一組 `hi` 等於 `max` 顯示「以上」 | +| `answer` | 正解的索引(0 起) | 正解數字;揭曉時另算「猜在 ±20% 內」人數 | +| `explain` | 揭曉後的說明 | 同左 | + +改完 `npx wrangler deploy`。專業或醫療題目:題目與解釋要自己審過。 + +## 3. 放進簡報 + +在 HTML 裡要放投票的位置寫占位 section,然後產生講者版: + +```html +
+
+
+``` +```bash +node $SKILL/scripts/poll-slides.mjs index.html index-live.html --worker my-poll --url https://slide-poll.<子網域>.workers.dev +``` + +- 腳本把占位換成完整投票頁(每頁右側 QR 都有 `data-url`,QA 會解碼比對),並在**最後一個** `` 前注入樣式、設定與 `live-poll.js`。需要 npm `qrcode`。 +- ADMIN_KEY 取環境變數 `ADMIN_KEY` 或 `my-poll/.dev.vars`,**會寫進 `index-live.html`**:這份只放講者電腦,不上傳、不寄出、不 commit。要分享給觀眾的版本用沒跑過這支腳本的 `index.html`。 +- 做法 B(content.js):讓 build.js 對某個 type 輸出同樣的占位 section,build 完再跑這支。 +- 從 PPTX 轉出的 HTML 也一樣:在轉檔程式插頁時插入占位 section。插頁後若投影片上印有頁碼,要重編。 + +## 4. 現場操作 + +| 動作 | 做法 | +|---|---| +| 開題 | 翻到投票頁就自動開題,手機出現題目 | +| 揭曉 | `A`:投影片標出正解與說明,手機同步顯示答案 | +| 清空本題 | `Shift+R`(彩排後逐題按一次;或用下面的 curl) | +| 下一題 | 翻頁;離開投票頁時手機回到「等下一題」 | +| 回到已揭曉的題 | 手機會再顯示一次答案,不會重開 | +| 斷網 | 投影片顯示紅字提示、每 2.5 秒輪詢;`A` 照樣揭曉 | +| 不連線播放 | 網址加 `?offline` | + +- 彩排後清空一題:`curl -X POST /api/control -H "authorization: Bearer $ADMIN_KEY" -d '{"poll":"warmup","phase":"closed","reset":true}'` +- 演講前用自己的手機在會場網路實測一輪,再逐題 `Shift+R`。 +- 手機頁匿名:用 localStorage 的隨機代號辨識同一支手機(可改答案、不重複計票),不收集個人資料。 + +## 5. 壓測 + +```bash +ADMIN_KEY=… node load-test.mjs https://slide-poll.<子網域>.workers.dev warmup 150 # Node 22+ +``` +開 N 支 WebSocket 假手機、開題、同時投票、揭曉,檢查講者畫面與每支手機都收到;最後清空那題的票。 + +## API + +| 方法 | 路徑 | 說明 | +|---|---|---| +| GET | `/` | 手機頁 | +| GET | `/ws?role=phone\|screen` | WebSocket;收 `state`(目前題目、階段、在線手機數、揭曉後的答案)與 `results` | +| GET | `/api/state` | 同 WebSocket 的 `state`(斷線輪詢用) | +| GET | `/api/results?poll=id` | 該題統計 | +| POST | `/api/vote` | `{poll, voter, value}`;voter 須 8–64 字元英數或 `-`;題目未開放回 409;同一 voter 重投覆蓋 | +| POST | `/api/control` | 需 `Authorization: Bearer ADMIN_KEY`;`{poll \| null, phase: closed\|open\|revealed, reset}` | + +## 踩過的雷 + +- 測試腳本的 voter 代號太短(`e2e-1`)會被格式檢查擋掉,看起來像「票數一直是 0」。 +- 投影片端預設在自動化瀏覽器(`navigator.webdriver`)與 `?print-pdf` 不連線,QA 截圖才不會打 API、`QA_OFFLINE=1` 才會過;自己寫 E2E 要在網址加 `?live`。 +- 未揭曉的答案框若用 `translateY` 藏在下方,QA 會判成溢出;改用 `scale`。 +- 答案只在 `revealed` 時由伺服器送出;手機頁本身與 `/api/state` 在揭曉前都不含答案。 diff --git a/skills/html-slide-deck/references/live-poll/load-test.mjs b/skills/html-slide-deck/references/live-poll/load-test.mjs new file mode 100644 index 0000000..0a02120 --- /dev/null +++ b/skills/html-slide-deck/references/live-poll/load-test.mjs @@ -0,0 +1,56 @@ +// 壓力測試:N 支假手機連 WebSocket、同時投一題,檢查講者畫面與手機都收到。Node 22+(內建 WebSocket)。 +// 用法:ADMIN_KEY=… node load-test.mjs <題目 id> [N=150] +// 跑完會清空該題的票並關題;正式場次前不要對真的場次跑(會清掉那題的票)。 +const [BASE, POLL, N = 150] = process.argv.slice(2); +const KEY = process.env.ADMIN_KEY; +if (!BASE || !POLL || !KEY) { console.error('usage: ADMIN_KEY=… node load-test.mjs [N]'); process.exit(1); } +const WSB = BASE.replace(/^http/, 'ws'); +const sleep = ms => new Promise(r => setTimeout(r, ms)); +const ctl = body => fetch(BASE + '/api/control', { method: 'POST', + headers: { 'content-type': 'application/json', authorization: 'Bearer ' + KEY }, body: JSON.stringify(body) }) + .then(r => { if (!r.ok) throw new Error('control HTTP ' + r.status); return r.json(); }); + +const screen = new WebSocket(WSB + '/ws?role=screen'); +let results = {}, state = {}; +screen.onmessage = e => { if (e.data === 'pong') return; const m = JSON.parse(e.data); if (m.type === 'results') results = m; else state = m; }; +await new Promise((ok, bad) => { screen.onopen = ok; screen.onerror = bad; }); + +const seen = Array(+N).fill(null); +const socks = await Promise.all(Array.from({ length: +N }, (_, i) => new Promise((ok, bad) => { + const ws = new WebSocket(WSB + '/ws?role=phone'); + ws.onmessage = e => { if (e.data !== 'pong') seen[i] = JSON.parse(e.data); }; + ws.onopen = () => ok(ws); ws.onerror = bad; +}))); +await sleep(1000); +const phones = state.phones; +console.log('講者畫面看到手機數:', phones, '/', N); + +const openedN = () => seen.filter(s => s && s.active === POLL && s.phase === 'open').length; +const opened = await ctl({ poll: POLL, phase: 'open', reset: true }); +const def = opened.results; +await sleep(1000); +const gotOpen = openedN(); +console.log('手機收到開題:', gotOpen, '/', N); + +const value = i => (def.median !== undefined ? 50 + (i * 37) % 900 : i % def.counts.length); +const t0 = Date.now(); +const codes = await Promise.all(Array.from({ length: +N }, (_, i) => fetch(BASE + '/api/vote', { method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ poll: POLL, voter: 'loadtest-' + String(i).padStart(4, '0'), value: value(i) }) }).then(r => r.status))); +const okVotes = codes.filter(c => c === 200).length; +console.log('投票成功:', okVotes, '/', N, '耗時', Date.now() - t0, 'ms'); +await sleep(1000); +const total = results.total; +console.log('講者畫面統計總數:', total); + +await ctl({ poll: POLL, phase: 'revealed' }); +await sleep(1000); +const gotReveal = seen.filter(s => s && s.phase === 'revealed' && s.reveal).length; +console.log('手機收到揭曉:', gotReveal, '/', N); + +await ctl({ poll: POLL, phase: 'closed', reset: true }); +await ctl({ poll: null }); +socks.forEach(s => s.close()); screen.close(); +const ok = [phones, gotOpen, okVotes, total, gotReveal].every(n => n === +N); +console.log(ok ? '✓ 通過(測試票已清空)' : '✗ 有數字對不上,見上方'); +setTimeout(() => process.exit(ok ? 0 : 1), 300); diff --git a/skills/html-slide-deck/references/live-poll/screen/live-poll.css b/skills/html-slide-deck/references/live-poll/screen/live-poll.css new file mode 100644 index 0000000..471a407 --- /dev/null +++ b/skills/html-slide-deck/references/live-poll/screen/live-poll.css @@ -0,0 +1,53 @@ +/* 手機即時投票:投影片端。沿用 reveal-template.html 的 tokens(--card --line --text --muted --amber --teal --green --coral) */ +.reveal section.lp{text-align:left} +.lp-wrap{display:grid;grid-template-columns:1fr 250px;gap:36px;align-items:start;font-size:22px;color:var(--text)} +.lp-kicker{font-size:18px;font-weight:700;color:var(--amber);letter-spacing:.04em} +.reveal .lp h2{font-size:38px;line-height:1.25;margin:4px 0 14px} +.lp-stem{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:14px 20px;line-height:1.55;margin-bottom:16px} +.lp-opts{display:flex;flex-direction:column;gap:12px} +.lp-opt{position:relative;display:grid;grid-template-columns:40px 1fr 80px;align-items:center;column-gap:14px; + padding:10px 16px;border-radius:14px;background:var(--card);border:2px solid var(--line);overflow:hidden;transition:border-color .4s,opacity .4s} +.lp-opt>i{position:relative;z-index:1;width:40px;height:40px;border-radius:50%;background:var(--amber);color:var(--bg); + font-style:normal;font-weight:800;display:grid;place-items:center} +.lp-opt-t{position:relative;z-index:1;line-height:1.35} +.lp-pct{position:relative;z-index:1;text-align:right;font-size:28px;font-variant-numeric:tabular-nums} +.lp-bar{position:absolute;inset:0;pointer-events:none} +.lp-bar span{display:block;height:100%;width:0;background:rgba(63,193,183,.22);transition:width .6s ease} +.lp-revealed .lp-opt.ok{border-color:var(--green)} +.lp-revealed .lp-opt.ok>i{background:var(--green)} +.lp-revealed .lp-opt.ok .lp-bar span{background:rgba(111,207,151,.3)} +.lp-revealed .lp-opt:not(.ok){opacity:.5} +.lp-side{display:flex;flex-direction:column;align-items:center;gap:6px;text-align:center} +.lp-side .qrcard{width:220px} +.lp-count{font-size:22px;margin-top:8px} +.lp-count b{display:block;font-size:68px;color:var(--amber);line-height:1;font-variant-numeric:tabular-nums} +.lp-phones-line{font-size:18px;color:var(--muted)} +.lp-net{font-size:15px;color:var(--coral);min-height:1.4em} +.lp-hint{font-size:15px;color:var(--dim);margin-top:6px} +.lp-revealed .lp-hint{visibility:hidden} +.lp-answer{margin-top:14px;padding:14px 20px;border-radius:14px;background:var(--card2);border-left:6px solid var(--green); + opacity:0;transform:scale(.97);transform-origin:left top;transition:opacity .5s,transform .5s} +.lp-answer-h{font-size:22px;font-weight:700;color:var(--green)} +.lp-answer-b{font-size:19px;line-height:1.5;margin-top:4px;color:var(--muted)} +.lp-revealed .lp-answer{opacity:1;transform:none} +/* 猜數字:直方圖 */ +.lp-hist{height:230px;display:flex;align-items:flex-end;gap:14px} +.lp-bin{flex:1;display:flex;flex-direction:column;align-items:center;gap:8px;height:100%} +.lp-bin-bar{flex:1;width:100%;display:flex;flex-direction:column;justify-content:flex-end;align-items:center;position:relative} +.lp-bin-bar span{display:block;width:100%;height:0;background:var(--teal);border-radius:8px 8px 0 0;transition:height .6s ease} +.lp-bin-bar b{position:absolute;bottom:6px;font-size:20px;color:var(--bg);font-variant-numeric:tabular-nums} +.lp-bin-l{font-size:17px;color:var(--muted)} +.lp-bin.ok .lp-bin-bar span{background:var(--green)} +.lp-median{font-size:22px;margin-top:10px} +.lp-median b{font-size:32px;color:var(--amber);margin-left:8px} +/* 加入頁 */ +.lp-join{display:grid;grid-template-columns:420px 1fr;gap:56px;align-items:center;font-size:24px} +.lp-join .qrcard{width:420px} +.lp-join-url{font-size:30px;font-weight:700;color:var(--amber);overflow-wrap:anywhere;margin:8px 0 18px} +.lp-join-n{font-size:28px} +.lp-join-n b{font-size:92px;color:var(--amber);margin:0 10px;font-variant-numeric:tabular-nums} +.lp-join-s{font-size:20px;color:var(--muted);line-height:1.6;margin-top:18px} +.lp-dot{display:inline-block;width:14px;height:14px;border-radius:50%;background:var(--dim);vertical-align:middle} +.lp-live .lp-dot{background:var(--green);animation:lpPulse 1.6s infinite} +@keyframes lpPulse{70%{box-shadow:0 0 0 12px rgba(111,207,151,0)}0%{box-shadow:0 0 0 0 rgba(111,207,151,.6)}} +@media (prefers-reduced-motion:reduce){.lp-bar span,.lp-bin-bar span,.lp-answer{transition:none}.lp-live .lp-dot{animation:none}} diff --git a/skills/html-slide-deck/references/live-poll/screen/live-poll.js b/skills/html-slide-deck/references/live-poll/screen/live-poll.js new file mode 100644 index 0000000..5a9ed1d --- /dev/null +++ b/skills/html-slide-deck/references/live-poll/screen/live-poll.js @@ -0,0 +1,160 @@ +/* 手機即時投票:投影片端(講者電腦)。需要 window.POLL_CFG = { url, key, polls },由 scripts/poll-slides.mjs 注入。 + 進到投票頁自動開題;離開投票頁手機回到「等下一題」。 + 講者鍵:A 揭曉、Shift+R 清空本題(彩排後用)。網路斷了照樣能按 A 顯示答案。 + 網址加 ?offline 完全不連線;自動化瀏覽器(QA 截圖)與 ?print-pdf 也不連線,跟 STATIC 開關同一套判斷,要測連線加 ?live。 */ +(() => { + const CFG = window.POLL_CFG; + const Q = location.search; + const OFFLINE = /[?&]offline/.test(Q) || (!/[?&]live/.test(Q) && (navigator.webdriver || Q.includes('print-pdf'))); + const L = 'ABCDEFG'; + const $$ = (sel, root = document) => [...root.querySelectorAll(sel)]; + const pollSec = id => document.querySelector(`section[data-poll="${id}"]`); + const results = {}; + let current = null, ws = null, wsRetry = 0, poller = null; + + /* ---------------------------------------------------------------- 網路 */ + async function api(path, opts = {}) { + if (OFFLINE) throw new Error('offline'); + const ctl = new AbortController(), timer = setTimeout(() => ctl.abort(), 5000); + try { + const r = await fetch(CFG.url + path, { ...opts, signal: ctl.signal, cache: 'no-store' }); + if (!r.ok) throw new Error('HTTP ' + r.status); + return await r.json(); + } finally { clearTimeout(timer); } + } + const control = body => api('/api/control', { + method: 'POST', headers: { 'content-type': 'application/json', authorization: 'Bearer ' + CFG.key }, + body: JSON.stringify(body), + }); + + function setNet(ok, msg) { + document.documentElement.classList.toggle('lp-live', ok); + $$('.lp-net').forEach(el => { el.textContent = ok ? '' : msg; }); + } + + function onMessage(m) { + if (m.type === 'state') $$('.lp-phones').forEach(el => { el.textContent = m.phones; }); + if (m.type === 'results') { results[m.poll] = m; render(m.poll); } + } + + function connect() { + if (OFFLINE) return setNet(false, '離線模式:按 A 仍可揭曉'); + try { ws = new WebSocket(CFG.url.replace(/^http/, 'ws') + '/ws?role=screen'); } + catch (e) { return startPolling(); } + ws.onopen = () => { wsRetry = 0; setNet(true); stopPolling(); }; + ws.onmessage = e => { if (e.data !== 'pong') onMessage(JSON.parse(e.data)); }; + ws.onclose = () => { + setNet(false, '連線中斷,重試中…(按 A 仍可揭曉)'); + startPolling(); + setTimeout(connect, Math.min(15000, 1000 * 2 ** wsRetry++)); + }; + } + setInterval(() => { if (ws && ws.readyState === 1) ws.send('ping'); }, 25000); + + function startPolling() { + if (poller || OFFLINE) return; + poller = setInterval(async () => { + try { + onMessage(await api('/api/state')); + if (current) onMessage(await api('/api/results?poll=' + current)); + } catch (e) { /* 下一輪再試;畫面已顯示斷線提示 */ } + }, 2500); + } + function stopPolling() { clearInterval(poller); poller = null; } + + /* ---------------------------------------------------------------- 投票頁 */ + function render(id) { + const sec = pollSec(id), res = results[id], def = CFG.polls[id]; + if (!sec || !res || !def) return; + sec.querySelector('.lp-total').textContent = res.total; + if (def.type === 'choice') { + $$('.lp-opt', sec).forEach((el, i) => { + const pct = res.total ? Math.round(res.counts[i] / res.total * 100) : 0; + el.querySelector('.lp-bar span').style.width = pct + '%'; + el.querySelector('.lp-pct').textContent = pct + '%'; + }); + } else { + const max = Math.max(1, ...res.counts); + $$('.lp-bin', sec).forEach((el, i) => { + el.querySelector('span').style.height = (res.counts[i] / max * 100) + '%'; + el.querySelector('b').textContent = res.counts[i] || ''; + }); + sec.querySelector('.lp-med').textContent = res.median == null ? '—' : res.median + ' ' + (def.unit || ''); + } + if (res.phase === 'revealed') showAnswer(id); + } + + function showAnswer(id) { + const sec = pollSec(id), def = CFG.polls[id], res = results[id]; + sec.classList.add('lp-revealed'); + if (def.type === 'choice') { + $$('.lp-opt', sec).forEach((el, i) => el.classList.toggle('ok', i === def.answer)); + sec.querySelector('.lp-answer-h').textContent = `答案:${L[def.answer]} ${def.options[def.answer]}`; + } else { + $$('.lp-bin', sec).forEach((el, i) => { + const [lo, hi] = def.bins[i]; + el.classList.toggle('ok', def.answer >= lo && def.answer <= hi); + }); + const lo = Math.round(def.answer * 0.8), hi = Math.round(def.answer * 1.2); + const close = res && res.total ? `;猜在 ${lo}–${hi} 之間的有 ${res.close} 人` : ''; + sec.querySelector('.lp-answer-h').textContent = `答案:${def.answer} ${def.unit || ''}${close}`; + } + sec.querySelector('.lp-answer-b').textContent = def.explain; + } + + function clearAnswer(id) { + const sec = pollSec(id); + sec.classList.remove('lp-revealed'); + $$('.ok', sec).forEach(el => el.classList.remove('ok')); + } + + async function enterPoll(id) { + current = id; + try { + const res = await api('/api/results?poll=' + id); + results[id] = res; + if (res.phase !== 'revealed') { + const r = await control({ poll: id, phase: 'open' }); + results[id] = r.results; + } else { + await control({ poll: id, phase: 'revealed' }); // 回到已揭曉的題:讓手機也顯示答案 + } + render(id); + } catch (e) { + setNet(false, '連不到投票伺服器(按 A 仍可揭曉)'); + } + } + + async function reveal(id) { + if (results[id]) results[id].phase = 'revealed'; + showAnswer(id); + try { const r = await control({ poll: id, phase: 'revealed' }); results[id] = r.results; render(id); } + catch (e) { setNet(false, '揭曉沒送到手機(網路)'); } + } + + async function resetPoll(id) { + clearAnswer(id); + try { const r = await control({ poll: id, phase: 'open', reset: true }); results[id] = r.results; render(id); } + catch (e) { setNet(false, '清空失敗(網路)'); } + } + + function onShow(sec) { + const id = sec && sec.dataset.poll; + if (id) return enterPoll(id); + if (current) { current = null; control({ poll: null }).catch(() => {}); } + } + + // capture 階段攔截,避免和 reveal.js 內建鍵衝突;只在投票頁生效 + document.addEventListener('keydown', e => { + if (!current || e.metaKey || e.ctrlKey || e.altKey) return; + if ((e.key === 'a' || e.key === 'A') && !e.shiftKey) { e.preventDefault(); reveal(current); } + if (e.key === 'R' && e.shiftKey) { e.preventDefault(); resetPoll(current); } + }, true); + + function start() { + if ($$('section[data-poll], .lp-joinslide').length) connect(); + Reveal.on('slidechanged', e => onShow(e.currentSlide)); + onShow(Reveal.getCurrentSlide()); + } + if (Reveal.isReady()) start(); else Reveal.on('ready', start); +})(); diff --git a/skills/html-slide-deck/references/live-poll/worker/.gitignore b/skills/html-slide-deck/references/live-poll/worker/.gitignore new file mode 100644 index 0000000..a934447 --- /dev/null +++ b/skills/html-slide-deck/references/live-poll/worker/.gitignore @@ -0,0 +1,3 @@ +.wrangler/ +.dev.vars +node_modules/ diff --git a/skills/html-slide-deck/references/live-poll/worker/package.json b/skills/html-slide-deck/references/live-poll/worker/package.json new file mode 100644 index 0000000..ffb4d48 --- /dev/null +++ b/skills/html-slide-deck/references/live-poll/worker/package.json @@ -0,0 +1,12 @@ +{ + "name": "slide-poll", + "private": true, + "type": "module", + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy" + }, + "devDependencies": { + "wrangler": "^4" + } +} diff --git a/skills/html-slide-deck/references/live-poll/worker/src/index.js b/skills/html-slide-deck/references/live-poll/worker/src/index.js new file mode 100644 index 0000000..1c154af --- /dev/null +++ b/skills/html-slide-deck/references/live-poll/worker/src/index.js @@ -0,0 +1,195 @@ +// 現場互動投票:手機作答、投影片即時顯示。 +// 一場演講一個 Durable Object(SQLite),WebSocket 用 hibernation API 推播。 +// 題目在 polls.json;講者控制用 ADMIN_KEY(wrangler secret put ADMIN_KEY)。 +import { DurableObject } from "cloudflare:workers"; +import POLLS from "./polls.json"; +import { phonePage } from "./phone.js"; + +const DEFAULT_ROOM = "default"; // 同一個 Worker 要給不同場次用,設 vars.ROOM_NAME +const PHASES = ["closed", "open", "revealed"]; +const MAX_BODY = 2048; +const json = (data, status = 200) => + new Response(JSON.stringify(data), { + status, + headers: { "content-type": "application/json; charset=utf-8", "access-control-allow-origin": "*", + "cache-control": "no-store" }, + }); + +export default { + async fetch(req, env) { + const url = new URL(req.url); + if (req.method === "OPTIONS") { + return new Response(null, { headers: { + "access-control-allow-origin": "*", "access-control-allow-methods": "GET,POST", + "access-control-allow-headers": "content-type,authorization" } }); + } + if (url.pathname === "/" || url.pathname === "/index.html") { + return new Response(phonePage(POLLS), { headers: { + "content-type": "text/html; charset=utf-8", "cache-control": "no-store" } }); + } + if (url.pathname === "/ws" || url.pathname.startsWith("/api/")) { + const room = env.ROOM.get(env.ROOM.idFromName(env.ROOM_NAME || DEFAULT_ROOM)); + return room.fetch(req); + } + return new Response("Not found", { status: 404 }); + }, +}; + +export class Room extends DurableObject { + constructor(ctx, env) { + super(ctx, env); + this.env = env; + this.sql = ctx.storage.sql; + this.sql.exec(`CREATE TABLE IF NOT EXISTS votes( + poll TEXT NOT NULL, voter TEXT NOT NULL, value TEXT NOT NULL, ts INTEGER NOT NULL, + PRIMARY KEY (poll, voter))`); + this.sql.exec(`CREATE TABLE IF NOT EXISTS phase(poll TEXT PRIMARY KEY, phase TEXT NOT NULL)`); + this.sql.exec(`CREATE TABLE IF NOT EXISTS kv(k TEXT PRIMARY KEY, v TEXT)`); + } + + // ---------------------------------------------------------------- 狀態 + active() { + const row = this.sql.exec(`SELECT v FROM kv WHERE k = 'active'`).toArray()[0]; + return row && POLLS.polls[row.v] ? row.v : null; + } + + phaseOf(poll) { + const row = this.sql.exec(`SELECT phase FROM phase WHERE poll = ?`, poll).toArray()[0]; + return row ? row.phase : "closed"; + } + + state() { + const active = this.active(); + const phase = active ? this.phaseOf(active) : "closed"; + const def = active && POLLS.polls[active]; + // 答案只在揭曉後才送到手機,手機頁本身不帶答案 + const reveal = phase === "revealed" ? { answer: def.answer, explain: def.explain } : null; + return { type: "state", active, phase, reveal, phones: this.ctx.getWebSockets("phone").length }; + } + + results(poll) { + const def = POLLS.polls[poll]; + const rows = this.sql.exec(`SELECT value FROM votes WHERE poll = ?`, poll).toArray(); + const out = { type: "results", poll, phase: this.phaseOf(poll), total: rows.length }; + if (def.type === "choice") { + out.counts = def.options.map(() => 0); + for (const r of rows) out.counts[Number(r.value)] += 1; + } else { + const vals = rows.map(r => Number(r.value)).sort((a, b) => a - b); + out.counts = def.bins.map(([lo, hi]) => vals.filter(v => v >= lo && v <= hi).length); + out.median = vals.length ? vals[Math.floor((vals.length - 1) / 2)] : null; + const tol = def.answer * 0.2; + out.close = vals.filter(v => Math.abs(v - def.answer) <= tol).length; + } + return out; + } + + broadcast(tag, msg) { + const s = JSON.stringify(msg); + for (const ws of this.ctx.getWebSockets(tag)) { + try { ws.send(s); } catch { /* 斷線的 socket 由 webSocketClose 收尾 */ } + } + } + + pushAll(poll) { + const st = this.state(); + this.broadcast("phone", st); + this.broadcast("screen", st); + if (poll) this.broadcast("screen", this.results(poll)); + } + + // ---------------------------------------------------------------- HTTP + async fetch(req) { + const url = new URL(req.url); + if (url.pathname === "/ws") return this.upgrade(req, url); + try { + if (url.pathname === "/api/state" && req.method === "GET") return json(this.state()); + if (url.pathname === "/api/results" && req.method === "GET") { + const poll = url.searchParams.get("poll"); + if (!POLLS.polls[poll]) return json({ error: "unknown poll" }, 404); + return json(this.results(poll)); + } + if (url.pathname === "/api/vote" && req.method === "POST") return this.vote(await this.body(req)); + if (url.pathname === "/api/control" && req.method === "POST") { + if (!this.authorized(req)) return json({ error: "unauthorized" }, 401); + return this.control(await this.body(req)); + } + return json({ error: "not found" }, 404); + } catch (err) { + console.error("room error", url.pathname, err && err.stack || err); + return json({ error: err.message || "bad request" }, err.status || 500); + } + } + + async body(req) { + const text = await req.text(); + if (text.length > MAX_BODY) throw Object.assign(new Error("body too large"), { status: 413 }); + try { return JSON.parse(text || "{}"); } + catch { throw Object.assign(new Error("invalid json"), { status: 400 }); } + } + + authorized(req) { + const key = this.env.ADMIN_KEY; + return Boolean(key) && req.headers.get("authorization") === `Bearer ${key}`; + } + + vote({ poll, voter, value }) { + const def = POLLS.polls[poll]; + if (!def) return json({ error: "unknown poll" }, 404); + if (typeof voter !== "string" || !/^[a-z0-9-]{8,64}$/i.test(voter)) return json({ error: "bad voter" }, 400); + if (this.active() !== poll || this.phaseOf(poll) !== "open") return json({ error: "closed" }, 409); + let v; + if (def.type === "choice") { + v = Number(value); + if (!Number.isInteger(v) || v < 0 || v >= def.options.length) return json({ error: "bad option" }, 400); + } else { + v = Math.round(Number(value)); + if (!Number.isFinite(v) || v < def.min || v > def.max) return json({ error: "out of range" }, 400); + } + this.sql.exec(`INSERT INTO votes(poll, voter, value, ts) VALUES (?, ?, ?, ?) + ON CONFLICT(poll, voter) DO UPDATE SET value = excluded.value, ts = excluded.ts`, + poll, voter, String(v), Date.now()); + this.broadcast("screen", this.results(poll)); + return json({ ok: true }); + } + + control({ poll, phase, reset }) { + if (poll !== null && !POLLS.polls[poll]) return json({ error: "unknown poll" }, 404); + if (phase !== undefined && !PHASES.includes(phase)) return json({ error: "bad phase" }, 400); + if (poll && reset) this.sql.exec(`DELETE FROM votes WHERE poll = ?`, poll); + this.sql.exec(`INSERT INTO kv(k, v) VALUES ('active', ?) ON CONFLICT(k) DO UPDATE SET v = excluded.v`, + poll || ""); + if (poll && phase) { + this.sql.exec(`INSERT INTO phase(poll, phase) VALUES (?, ?) + ON CONFLICT(poll) DO UPDATE SET phase = excluded.phase`, poll, phase); + } + this.pushAll(poll); + return json({ ok: true, ...this.state(), results: poll ? this.results(poll) : null }); + } + + // ---------------------------------------------------------------- WebSocket + upgrade(req, url) { + if (req.headers.get("upgrade") !== "websocket") return new Response("expected websocket", { status: 426 }); + const role = url.searchParams.get("role") === "screen" ? "screen" : "phone"; + const pair = new WebSocketPair(); + this.ctx.acceptWebSocket(pair[1], [role]); + pair[1].send(JSON.stringify(this.state())); + const active = this.active(); + if (role === "screen" && active) pair[1].send(JSON.stringify(this.results(active))); + if (role === "phone") this.broadcast("screen", this.state()); + return new Response(null, { status: 101, webSocket: pair[0] }); + } + + webSocketMessage(ws, msg) { + if (msg === "ping") ws.send("pong"); + } + + webSocketClose(ws, code) { + try { ws.close(code, "bye"); } catch { /* 已關閉 */ } + this.broadcast("screen", this.state()); + } + + webSocketError() { + this.broadcast("screen", this.state()); + } +} diff --git a/skills/html-slide-deck/references/live-poll/worker/src/phone.js b/skills/html-slide-deck/references/live-poll/worker/src/phone.js new file mode 100644 index 0000000..f6d3bd8 --- /dev/null +++ b/skills/html-slide-deck/references/live-poll/worker/src/phone.js @@ -0,0 +1,162 @@ +// 觀眾手機頁:等講者開題 → 作答(可改選)→ 揭曉後顯示答案。題目定義不含答案。 +const PUBLIC_KEYS = ["type", "kicker", "title", "stem", "options", "min", "max", "unit"]; + +function publicPolls(polls) { + return Object.fromEntries(Object.entries(polls.polls).map(([id, p]) => + [id, Object.fromEntries(PUBLIC_KEYS.filter(k => k in p).map(k => [k, p[k]]))])); +} + +const esc = s => String(s).replace(/[&<>"]/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c])); + +export function phonePage(polls) { + const defs = JSON.stringify(publicPolls(polls)).replace(/ + + + +${esc(polls.event || "現場互動")} + +
${esc(polls.event || "現場互動")}連線中…
+
連線中…
+`; +} diff --git a/skills/html-slide-deck/references/live-poll/worker/src/polls.json b/skills/html-slide-deck/references/live-poll/worker/src/polls.json new file mode 100644 index 0000000..b4a329a --- /dev/null +++ b/skills/html-slide-deck/references/live-poll/worker/src/polls.json @@ -0,0 +1,35 @@ +{ + "event": "〈講座名稱〉|現場互動", + "polls": { + "warmup": { + "type": "choice", + "kicker": "暖身", + "title": "你平常用什麼做簡報?", + "stem": "選最常用的一個。", + "options": ["PowerPoint/Keynote", "Google 簡報", "HTML(reveal.js 等)", "其他"], + "answer": 2, + "explain": "這題沒有標準答案;揭曉時會把「HTML」標成綠色,示範揭曉效果。換成你自己的題目。" + }, + "case-1": { + "type": "choice", + "kicker": "情境 1/1", + "title": "〈情境題標題〉", + "stem": "〈題幹:一段情境描述,最後問「你會怎麼做?」〉", + "options": ["〈選項 A〉", "〈選項 B〉", "〈選項 C〉"], + "answer": 1, + "explain": "〈解釋:為什麼是 B;專業題目要自己審過〉" + }, + "guess": { + "type": "number", + "kicker": "猜數字", + "title": "〈猜一個數字的題目〉", + "stem": "〈例:這次活動一共服務了多少人次?〉", + "min": 0, + "max": 5000, + "unit": "人次", + "answer": 218, + "bins": [[0, 99], [100, 199], [200, 299], [300, 499], [500, 999], [1000, 5000]], + "explain": "〈揭曉後的說明〉" + } + } +} diff --git a/skills/html-slide-deck/references/live-poll/worker/wrangler.jsonc b/skills/html-slide-deck/references/live-poll/worker/wrangler.jsonc new file mode 100644 index 0000000..e4efd5d --- /dev/null +++ b/skills/html-slide-deck/references/live-poll/worker/wrangler.jsonc @@ -0,0 +1,12 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + // 部署後網址是 https://.<你的 workers 子網域>.workers.dev + "name": "slide-poll", + "main": "src/index.js", + // 不能晚於你本機 wrangler 內建 workerd 支援的日期,否則 wrangler dev 起不來 + "compatibility_date": "2026-08-20", + "workers_dev": true, + "observability": { "enabled": true }, + "durable_objects": { "bindings": [{ "name": "ROOM", "class_name": "Room" }] }, + "migrations": [{ "tag": "v1", "new_sqlite_classes": ["Room"] }] +} diff --git a/skills/html-slide-deck/references/pitfalls.md b/skills/html-slide-deck/references/pitfalls.md index f69e537..c2f9ad5 100644 --- a/skills/html-slide-deck/references/pitfalls.md +++ b/skills/html-slide-deck/references/pitfalls.md @@ -82,3 +82,11 @@ prs.save('out.pptx') - `render-slides.mjs` 預設直接輸出 JPEG(1280×720 ×1.5),不必再轉檔。 - `?print-pdf` 預設 `pdfSeparateFragments:true`,54 頁會印成 79 頁;在 initialize 設 false。 - 用 python-pptx 抓 PPTX 裡的 QR 圖片、再用 jsqr 解碼,可還原使用者手動加的 QR 網址(`skill 目錄自帶的 node_modules` 有 jsqr/pngjs)。 + +## 互動頁與手機投票(2026-10 講座) +- 手機投票全套在 `live-poll/`(README 有部署、API、壓測與踩雷);地圖、製作技巧頁、PPTX 轉 HTML 插頁在 `interactive-pages.md`。 +- 講者版 HTML 內嵌 ADMIN_KEY:commit 前 `grep -c ` 確認 staged 內容為 0;`.dev.vars` 一律 gitignore。 +- `wrangler.jsonc` 的 `compatibility_date` 晚於本機 wrangler 內建 workerd 支援範圍時 `wrangler dev` 直接起不來,往前調即可。 +- 插頁後投影片上印的頁碼會錯位:載入時依實際順序重編,不要回頭改 PPTX。 +- 互動頁的進場動畫不要套到「未揭曉時隱藏」的元素上(例如答案框),否則動畫結束後它會被顯示出來。 +- SVG 地圖上大標記(據點)會攔截點擊:先畫、`pointer-events:none`。 diff --git a/skills/html-slide-deck/scripts/poll-slides.mjs b/skills/html-slide-deck/scripts/poll-slides.mjs new file mode 100644 index 0000000..0dd9526 --- /dev/null +++ b/skills/html-slide-deck/scripts/poll-slides.mjs @@ -0,0 +1,87 @@ +// 用法:node poll-slides.mjs --worker --url +// 把 HTML 裡的占位 section 換成手機即時投票頁,並在最後一個 前注入樣式、設定與投影片端程式。 +//
→ 加入頁(大 QR+在線手機數) +//
→ 投票頁(題目取自 /src/polls.json) +// ADMIN_KEY 依序取環境變數 ADMIN_KEY、/.dev.vars。產出的 HTML 內含這把 key,只給講者電腦用,不要外流。 +import { readFileSync, writeFileSync, existsSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { requirePkg } from './resolve.mjs'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const SCREEN = join(HERE, '..', 'references', 'live-poll', 'screen'); +const args = process.argv.slice(2); +const opt = k => { const i = args.indexOf(k); return i >= 0 ? args[i + 1] : null; }; +const [input, output] = args.filter((a, i) => !a.startsWith('--') && !['--worker', '--url'].includes(args[i - 1])); +const worker = opt('--worker'), url = (opt('--url') || '').replace(/\/$/, ''); +if (!input || !output || !worker || !/^(https:\/\/|http:\/\/(localhost|127\.0\.0\.1)[:/])/.test(url + '/')) { + console.error('usage: poll-slides.mjs --worker --url (本機測試可用 http://localhost:8787)'); process.exit(1); +} + +function adminKey() { + if (process.env.ADMIN_KEY) return process.env.ADMIN_KEY; + const f = join(worker, '.dev.vars'); + const line = existsSync(f) && readFileSync(f, 'utf8').split('\n').find(l => l.startsWith('ADMIN_KEY=')); + if (!line) { console.error(`找不到 ADMIN_KEY:設環境變數,或在 ${f} 寫 ADMIN_KEY=…`); process.exit(2); } + return line.slice('ADMIN_KEY='.length).trim(); +} + +const { event, polls } = JSON.parse(readFileSync(join(worker, 'src', 'polls.json'), 'utf8')); +const QR = requirePkg('qrcode'); +const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); +const host = url.replace(/^https?:\/\//, ''); +const L = 'ABCDEFG'; + +const qrCard = async () => { + const svg = await QR.toString(url, { type: 'svg', margin: 1, errorCorrectionLevel: 'M', color: { dark: '#0B1F2A', light: '#FFFFFF' } }); + return `
${svg.replace('${esc(host)}
`; +}; + +async function joinSlide() { + return `
現場互動

拿出手機,一起作答

+
${await qrCard()}
用手機相機掃 QR,或輸入網址
${esc(host)}
+
0 支手機已加入
+
匿名作答,不收集個人資料;揭曉前可以改答案。
+
`; +} + +async function pollSlide(id) { + const p = polls[id]; + if (!p) { console.error(`polls.json 沒有題目 ${id}`); process.exit(2); } + let body, notes; + if (p.type === 'choice') { + body = `
${p.options.map((o, i) => `
${L[i]}
${esc(o)}
` + + `
0%
`).join('')}
`; + notes = `給 30–40 秒作答,作答數穩定後按 A 揭曉。答案:${L[p.answer]} ${p.options[p.answer]}。${p.explain}`; + } else { + body = `
${p.bins.map(([lo, hi]) => `
` + + `
${hi < p.max ? lo + '–' + hi : lo + ' 以上'}
`).join('')}
` + + `
全場中位數 —
`; + notes = `請大家猜一個數字,看分布與中位數後按 A 揭曉。答案 ${p.answer} ${p.unit || ''}。${p.explain}`; + } + return `
+
${esc(p.kicker || '')}

${esc(p.title)}

${esc(p.stem)}
${body} +
+
${await qrCard()}
0 人作答
+
0 支手機在線
+
按 A 揭曉
+
`; +} + +let s = readFileSync(input, 'utf8'); +const used = []; +for (const m of [...s.matchAll(/
<\/section>|
<\/section>/g)]) { + const html = m[1] ? await pollSlide(m[1]) : await joinSlide(); + if (m[1]) used.push(m[1]); + s = s.replace(m[0], () => html); +} +if (!used.length && !s.includes('lp-joinslide')) { console.error('沒有找到占位
'); process.exit(1); } + +const cfg = JSON.stringify({ url, key: adminKey(), polls, event }).replace(/${readFileSync(join(SCREEN, 'live-poll.css'), 'utf8')}\n` + + `\n\n`; +const at = s.lastIndexOf(''); // notes.js 原始碼裡也有 ,一定要找最後一個 +if (at < 0) { console.error('找不到 '); process.exit(1); } +writeFileSync(output, s.slice(0, at) + inject + s.slice(at)); +console.log('wrote', output, '投票頁', used.length, '題:', used.join(', ')); +console.log('⚠ 產出檔內含 ADMIN_KEY:只放講者電腦,不要上傳或寄出。');