From 6714dcfc3676dd226dbab9b0d5098edba93f4b9f Mon Sep 17 00:00:00 2001 From: Kien Nguyen Date: Wed, 15 Jul 2026 12:27:20 +0700 Subject: [PATCH 1/6] feat(preview): live theme + animation + language preview site on GitHub Pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds preview/, a control panel for reviewing every theme and animation on a real slide without installing the skill. Published to GitHub Pages via a workflow that ships the repo as-is (no build step). The slide is a live iframe of examples/demo-deck. Theme, language, slide and animation all travel over postMessage to the runtime already inside it, so nothing reloads — runtime.js already spoke most of this protocol for presenter mode, so this mostly reuses it rather than inventing a parallel one. - preview/: control panel (36-theme rail with real palette swatches, language segmented control, slide dots, effect picker), plus anim-stage.html, a rig that hosts all 47 animations. Each animation is mapped to a subject that actually shows it off — a generic text block can't demo .anim-typewriter (needs one nowrap line), .anim-stagger-list (animates children) or .anim-path-draw (needs SVG strokes). - assets/i18n.js: optional data-i18n layer. The default language stays inline, so a deck opened over file:// — where fetch() is blocked — still reads correctly; only switching away needs a server. - examples/demo-deck: marked up with data-i18n and translated to en + vi. Its Chart.js canvas now redraws on theme change (a canvas can't re-skin itself from CSS variables) and re-labels on language change. - runtime.js: adds preview-replay, and accepts data-theme-base on as well as — decks in this repo declare it on , which the previous lookup missed and only survived via the theme-link fallback. - .nojekyll: mandatory. Jekyll drops files starting with an underscore, which would silently break all 20 canvas FX via fx/_util.js. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/pages.yml | 43 ++++ .nojekyll | 0 README.md | 8 + README.zh-CN.md | 7 + SKILL.md | 46 +++- assets/i18n.js | 154 ++++++++++++ assets/runtime.js | 40 ++- examples/demo-deck/i18n/en.json | 68 +++++ examples/demo-deck/i18n/vi.json | 68 +++++ examples/demo-deck/index.html | 172 ++++++++----- index.html | 97 +++++++ preview/anim-stage.html | 294 ++++++++++++++++++++++ preview/app.css | 209 ++++++++++++++++ preview/app.js | 431 ++++++++++++++++++++++++++++++++ preview/i18n/en.json | 27 ++ preview/i18n/vi.json | 27 ++ preview/index.html | 84 +++++++ preview/themes.json | 66 +++++ 18 files changed, 1771 insertions(+), 70 deletions(-) create mode 100644 .github/workflows/pages.yml create mode 100644 .nojekyll create mode 100644 assets/i18n.js create mode 100644 examples/demo-deck/i18n/en.json create mode 100644 examples/demo-deck/i18n/vi.json create mode 100644 index.html create mode 100644 preview/anim-stage.html create mode 100644 preview/app.css create mode 100644 preview/app.js create mode 100644 preview/i18n/en.json create mode 100644 preview/i18n/vi.json create mode 100644 preview/index.html create mode 100644 preview/themes.json diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..bce0e02 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,43 @@ +name: Deploy preview site to GitHub Pages + +on: + push: + branches: [main] + workflow_dispatch: + +# Allow GITHUB_TOKEN to deploy to Pages. +permissions: + contents: read + pages: write + id-token: write + +# Let a running deploy finish; queue at most one more. +concurrency: + group: pages + cancel-in-progress: false + +jobs: + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # The site is pure static HTML/CSS/JS — no build step. The whole repo is + # published so that preview/ can reference ../assets/ and ../examples/ + # exactly as it does on a local file server. + - name: Guard against Jekyll eating underscore paths + # assets/animations/fx/_util.js would be silently dropped by Jekyll, + # which breaks all 20 canvas FX with no error. .nojekyll must exist. + run: test -f .nojekyll || { echo "::error::.nojekyll is missing"; exit 1; } + + - uses: actions/configure-pages@v5 + + - uses: actions/upload-pages-artifact@v3 + with: + path: '.' + + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/.nojekyll b/.nojekyll new file mode 100644 index 0000000..e69de29 diff --git a/README.md b/README.md index fe3b522..763e235 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,14 @@ **License:** MIT **中文文档:** [README.zh-CN.md](README.zh-CN.md) +### 🔍 [Live preview → kienbb.github.io/html-ppt-skill](https://kienbb.github.io/html-ppt-skill/preview/) + +Browse every theme and animation on a real slide, in the browser, with no +install. Pick any of the **36 themes** and the slide re-skins instantly — no +reload — because a theme is nothing but a set of `:root` variables. Switch the +same slide between **中文 / English / Tiếng Việt** to see how a theme handles +ideographic versus Latin text before you commit to it. + ![html-ppt — cover with live previews](docs/readme/hero.gif) > One command installs **36 themes × 20 canvas FX × 31 layouts × 15 full decks + presenter mode**. Every preview above is a live iframe of a real template file rendering inside the deck — no screenshots, no mock-ups. diff --git a/README.zh-CN.md b/README.zh-CN.md index a41c787..0e0f2d6 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -9,6 +9,13 @@ **协议:** MIT **English docs:** [README.md](README.md) +### 🔍 [在线预览 → kienbb.github.io/html-ppt-skill](https://kienbb.github.io/html-ppt-skill/preview/) + +不用安装,直接在浏览器里用真实幻灯片浏览全部主题和动效。**36 套主题**任意点选, +幻灯片**当场换肤、不刷新**——因为一套主题无非就是一组 `:root` 变量。同一页还能在 +**中文 / English / Tiếng Việt** 之间切换,在你决定用哪套主题之前,先看清它在 +表意文字和拉丁文字下分别是什么样。 + ![html-ppt 封面 · 实时预览](docs/readme/hero.gif) > 一行命令装好 **36 主题 × 20 Canvas FX × 31 布局 × 15 完整 deck + 演讲者模式**。 diff --git a/SKILL.md b/SKILL.md index 0250b9a..808ed57 100644 --- a/SKILL.md +++ b/SKILL.md @@ -27,6 +27,13 @@ One command, no build. Pure static HTML/CSS/JS with only CDN webfonts. - **Keyboard runtime** (`assets/runtime.js`) — arrows, T (theme), A (anim), F/O, **S (presenter mode: magnetic-card popup with CURRENT / NEXT / SCRIPT / TIMER cards)**, N (notes drawer), R (reset timer in presenter) - **FX runtime** (`assets/animations/fx-runtime.js`) — auto-inits `[data-fx]` on slide enter, cleans up on leave - **Showcase decks** for themes / layouts / animations / full-decks gallery +- **Live preview site** (`preview/`) — every theme and animation on one real + slide, switchable without a reload, in 中文 / English / Tiếng Việt. Published + at . Point users here when + they ask "which theme should I use?" instead of describing themes in prose. +- **Optional i18n layer** (`assets/i18n.js`) — mark text with `data-i18n="key"`, + keep the default language inline, and ship other languages as JSON. See + `examples/demo-deck/` for a working three-language deck. - **Headless Chrome render script** for PNG export ## When to use @@ -69,7 +76,11 @@ tasteful default and confirm. 1. **Content & audience.** What's the deck about, how many slides, who's watching (engineers / execs / 小红书读者 / 学生 / VC)? 2. **Style / theme.** Which of the 36 themes fits? If unsure, recommend 2-3 - candidates based on tone: + candidates based on tone — and link the user to the live preview so they can + judge for themselves rather than take your word for it: + . A direct link can carry + the exact theme, slide and language, e.g. + `preview/#stage=theme&theme=tokyo-night&lang=en&slide=1`. - Business / investor pitch → `pitch-deck-vc`, `corporate-clean`, `swiss-grid` - Tech sharing / engineering → `tokyo-night`, `dracula`, `catppuccin-mocha`, `terminal-green`, `blueprint` @@ -167,11 +178,20 @@ Chinese + English deck, and how to export. ``` html-ppt/ ├── SKILL.md (this file) +├── index.html (GitHub Pages landing hub) +├── .nojekyll (REQUIRED — Jekyll would drop fx/_util.js) ├── references/ (detailed catalogs, load as needed) +├── preview/ (live theme + animation + language preview site) +│ ├── index.html (control panel) +│ ├── app.js / app.css (state, URL hash, postMessage bridge) +│ ├── anim-stage.html (single-slide rig for all 47 animations) +│ ├── themes.json (36-theme catalog + swatch colors) +│ └── i18n/*.json (en / vi strings for the anim stage) ├── assets/ │ ├── base.css (tokens + primitives — do not edit per deck) │ ├── fonts.css (webfont imports) │ ├── runtime.js (keyboard + presenter + overview + theme cycle) +│ ├── i18n.js (optional data-i18n multilingual layer) │ ├── themes/*.css (36 token overrides, one per theme) │ └── animations/ │ ├── animations.css (27 named CSS entry animations) @@ -188,9 +208,30 @@ html-ppt/ ├── scripts/ │ ├── new-deck.sh (scaffold a deck from deck.html) │ └── render.sh (headless Chrome → PNG) -└── examples/demo-deck/ (complete working deck) +└── examples/demo-deck/ (complete working deck, zh + en + vi) + ├── index.html (zh-CN inline = the default language) + └── i18n/{en,vi}.json (translations, fetched on demand) ``` +## Multilingual decks + +`assets/i18n.js` is optional and off unless a deck opts in. The default language +stays as inline HTML — so a deck still reads correctly when opened straight off +the filesystem, where `fetch()` is blocked — and other languages are JSON files +loaded only when selected. + +```html + +

默认语言写在这里

+ +``` + +Then `?lang=en` in the URL, or `HPXI18n.setLang('en')` at runtime. Values are +inserted as HTML, so a translation may carry `
` or ``. Any key a +dictionary omits falls back to the inline text. Listen for the `hpx:i18n` event +on `document` to redraw anything JS-painted (a Chart.js canvas won't re-label +itself — see slide 6 of `examples/demo-deck/`). + ## Rendering to PNG `scripts/render.sh` wraps headless Chrome at @@ -212,6 +253,7 @@ N quick notes drawer (bottom overlay) R reset timer (in presenter window) ?preview=N URL param — force preview-only mode (single slide, no chrome) O slide overview grid +?lang=xx URL param — pick a language (needs i18n.js) T cycle themes (reads data-themes attr) A cycle demo animation on current slide #/N in URL deep-link to slide N diff --git a/assets/i18n.js b/assets/i18n.js new file mode 100644 index 0000000..59c0ce1 --- /dev/null +++ b/assets/i18n.js @@ -0,0 +1,154 @@ +/* html-ppt :: i18n.js — optional multilingual layer for a deck. Zero dependencies. + * + * Design: the HTML itself IS the default language. Every translatable node carries + * data-i18n=""; its inline markup is snapshotted on load and used as the + * default-language dictionary. Other languages live in JSON files fetched on demand. + * + * That ordering matters: a deck stays fully readable when opened straight off the + * filesystem (file://), where fetch() is blocked by CORS. Only *switching away* + * from the default language needs a server. Nothing degrades if the fetch fails. + * + * Usage: + * + *

默认语言的内容

+ * + * + * ?lang=vi in the URL selects a language at load. + * HPXI18n.setLang('en') switches at runtime; returns a Promise. + * Listen for the 'hpx:i18n' event on document to react (e.g. redraw a chart). + * + * Values are inserted as HTML, so a translation may carry
, , . + * Only ever author these dictionaries yourself — they are not an untrusted input. + */ +(function () { + 'use strict'; + + const DEFAULT_LANG = 'zh'; + + const state = { + lang: DEFAULT_LANG, + defaultLang: DEFAULT_LANG, + dict: Object.create(null), // active language's key -> html + ready: false + }; + + /* Snapshot of the inline markup = the default language dictionary. */ + const baseline = Object.create(null); + /* Cache: lang -> dict, so flipping back and forth fetches each file once. */ + const cache = Object.create(null); + + function nodes() { + return document.querySelectorAll('[data-i18n]'); + } + + function snapshot() { + nodes().forEach((el) => { + const key = el.getAttribute('data-i18n'); + if (key && !(key in baseline)) baseline[key] = el.innerHTML; + }); + cache[state.defaultLang] = baseline; + } + + function apply(dict) { + nodes().forEach((el) => { + const key = el.getAttribute('data-i18n'); + if (!key) return; + const val = dict[key]; + /* Fall back to the baseline for any key a translation hasn't covered yet, + * so a partial dictionary shows source text rather than an empty slide. */ + const html = (typeof val === 'string') ? val : baseline[key]; + if (typeof html === 'string' && el.innerHTML !== html) el.innerHTML = html; + }); + } + + function srcFor(lang) { + const tpl = (document.body && document.body.getAttribute('data-i18n-src')) || 'i18n/{lang}.json'; + return tpl.replace('{lang}', lang); + } + + function announce() { + document.documentElement.setAttribute('lang', htmlLangFor(state.lang)); + document.documentElement.setAttribute('data-lang', state.lang); + document.dispatchEvent(new CustomEvent('hpx:i18n', { + detail: { lang: state.lang, dict: state.dict } + })); + } + + /* Map our short codes to real BCP-47 tags. `lang` drives font fallback and + * line-breaking rules in the browser, so getting it right is not cosmetic: + * CJK line-breaking differs from Latin, which is half of what this tool exists + * to let you eyeball. */ + const BCP47 = { zh: 'zh-CN', en: 'en', vi: 'vi', ja: 'ja' }; + function htmlLangFor(lang) { return BCP47[lang] || lang; } + + function setLang(lang) { + lang = String(lang || state.defaultLang).toLowerCase(); + + if (cache[lang]) { + state.lang = lang; + state.dict = cache[lang]; + apply(state.dict); + announce(); + return Promise.resolve(state.dict); + } + + return fetch(srcFor(lang), { cache: 'no-cache' }) + .then((r) => { + if (!r.ok) throw new Error('HTTP ' + r.status + ' for ' + srcFor(lang)); + return r.json(); + }) + .then((dict) => { + cache[lang] = dict; + state.lang = lang; + state.dict = dict; + apply(dict); + announce(); + return dict; + }) + .catch((err) => { + /* Loud in the console, invisible on the slide. A missing translation must + * never blank out a deck someone is presenting from. */ + console.warn('[hpx-i18n] could not load "' + lang + '", staying on "' + + state.lang + '":', err.message); + return state.dict; + }); + } + + function urlLang() { + const m = /[?&]lang=([a-z-]+)/i.exec(location.search || ''); + return m ? m[1].toLowerCase() : null; + } + + function boot() { + state.defaultLang = (document.body && document.body.getAttribute('data-i18n-default')) || DEFAULT_LANG; + state.lang = state.defaultLang; + snapshot(); + state.dict = baseline; + state.ready = true; + + const wanted = urlLang(); + if (wanted && wanted !== state.defaultLang) setLang(wanted); + else announce(); + + /* The preview site drives language from a parent window over the same + * postMessage channel runtime.js already uses for theme/slide control. */ + window.addEventListener('message', function (e) { + if (e.data && e.data.type === 'preview-lang' && e.data.lang) setLang(e.data.lang); + }); + } + + window.HPXI18n = { + setLang: setLang, + get lang() { return state.lang; }, + get dict() { return state.dict; }, + get ready() { return state.ready; }, + /* Look up a key in the active language, falling back to the default. */ + t: function (key) { + const v = state.dict[key]; + return (typeof v === 'string') ? v : baseline[key]; + } + }; + + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); + else boot(); +})(); diff --git a/assets/runtime.js b/assets/runtime.js index 535907d..e282e0c 100644 --- a/assets/runtime.js +++ b/assets/runtime.js @@ -68,7 +68,11 @@ document.body.setAttribute('data-preview', '1'); /* Auto-detect theme base path for theme switching in preview mode */ function getPreviewThemeBase() { - const base = document.documentElement.getAttribute('data-theme-base'); + /* Decks in this repo declare data-theme-base on , but a deck may + * just as reasonably put it on — accept either before falling + * back to deriving the directory from the theme itself. */ + const base = document.documentElement.getAttribute('data-theme-base') + || document.body.getAttribute('data-theme-base'); if (base) return base; const tl = document.getElementById('theme-link'); if (tl) { @@ -80,14 +84,40 @@ } const previewThemeBase = getPreviewThemeBase(); - /* Listen for postMessage from parent presenter window: - * - preview-goto: switch visible slide WITHOUT reloading - * - preview-theme: switch theme CSS link to match audience window */ + /* Re-trigger every entry animation on a slide. CSS animations only run once + * per element, so replaying means dropping the class, forcing a reflow to + * flush the style change, then putting it back. Without the reflow the + * browser coalesces remove+add into a no-op and nothing moves. */ + function replayAnims(slide) { + if (!slide) return; + const targets = [slide].concat(Array.from(slide.querySelectorAll('*'))); + targets.forEach((el) => { + const animClasses = Array.from(el.classList).filter(c => c.indexOf('anim-') === 0); + if (!animClasses.length) return; + animClasses.forEach(c => el.classList.remove(c)); + void el.offsetWidth; + animClasses.forEach(c => el.classList.add(c)); + }); + /* Canvas FX are driven by fx-runtime.js, which exposes a reinit hook. */ + if (typeof window.__hpxReinit === 'function') window.__hpxReinit(slide); + } + + /* Listen for postMessage from a parent window (presenter view, or the + * preview site under /preview): + * - preview-goto: switch visible slide WITHOUT reloading + * - preview-theme: switch theme CSS link to match audience window + * - preview-replay: re-run the current slide's entry animations + * Language switching rides the same channel but is owned by i18n.js. */ window.addEventListener('message', function(e) { if (!e.data) return; if (e.data.type === 'preview-goto') { const n = parseInt(e.data.idx, 10); - if (n >= 0 && n < slides.length) showSlide(n); + if (n >= 0 && n < slides.length) { + showSlide(n); + replayAnims(slides[n]); + } + } else if (e.data.type === 'preview-replay') { + replayAnims(document.querySelector('.slide.is-active')); } else if (e.data.type === 'preview-theme' && e.data.name) { let link = document.getElementById('theme-link'); if (!link) { diff --git a/examples/demo-deck/i18n/en.json b/examples/demo-deck/i18n/en.json new file mode 100644 index 0000000..3a75b69 --- /dev/null +++ b/examples/demo-deck/i18n/en.json @@ -0,0 +1,68 @@ +{ + "_comment": "English translation of examples/demo-deck. The zh-CN source lives inline in index.html and acts as the baseline; any key omitted here falls back to it. Values are inserted as HTML, so
// are allowed.", + + "cover.eyebrow": "Tech sharing · 2026-04-15", + "cover.kicker": "Keynote · Demo", + "cover.h1": "Make a tech talk that reads
like a magazine", + "cover.lede": "36 themes · 31 layouts · 27 animations · zero build", + "cover.pill1": "tokens", + "cover.pill2": "keyboard first", + "cover.pill3": "PNG export", + "cover.pill4": "CN · EN · VI", + "cover.notes": "Hi everyone — today I'll show you how html-ppt works. This demo is built with it: every slide is just a few lines of HTML plus a class.", + + "toc.kicker": "Agenda", + "toc.h2": "Ten minutes, three things", + "toc.c1t": "① Why", + "toc.c1d": "Every deck is the same work over again, and 99% of it can be a template.", + "toc.c2t": "② How", + "toc.c2d": "Tokens, layouts, animations — three separate layers.", + "toc.c3t": "③ Result", + "toc.c3d": "One deck, 36 themes, one keypress.", + "toc.foot": "agenda", + "toc.notes": "Why / How / Result. The three-part shape is the most reliable way to give a talk.", + + "quote.text": "A good talk is written,
not \"made\".", + "quote.by": "— everyone who has ever fought with slide software", + "quote.foot": "quote", + "quote.notes": "Pause here for a second. Let the line speak for itself.", + + "stat.kicker": "The result", + "stat.h3": "of your slide-making time comes back to you", + "stat.lede": "Averaged over 10 real decks.", + "stat.foot": "proof", + "stat.notes": "Stress the source: 10 of my own real decks, not a benchmark.", + + "tokens.kicker": "How · The core idea", + "tokens.h2": "Move \"what it looks like\" into :root", + "tokens.c1h": "Concept", + "tokens.c1p": "Every visual property — color, type, radius, shadow — becomes a semantic variable.", + "tokens.c2h": "Example", + "tokens.c2note": "— that is the entire aurora theme.", + "tokens.foot": "how", + "tokens.notes": "The key: base.css only ever names variables, never literal colors. Changing theme = swapping one set of variables.", + + "chart.kicker": "Numbers · What actually changed", + "chart.h2": "Where deck-building time goes, before and after", + "chart.l1": "Writing", + "chart.l2": "Layout", + "chart.l3": "Styling", + "chart.l4": "Export", + "chart.l5": "Motion", + "chart.before": "Before (min)", + "chart.after": "After (min)", + "chart.foot": "data", + "chart.notes": "Afterwards, writing takes the biggest share and everything else drops — which is exactly the point.", + + "cta.kicker": "Your turn", + "cta.h1": "Start your first one", + "cta.lede": "Copy a deck, drop in your content, press T until a theme clicks, and export PNGs when you're done.", + "cta.hint": "←/→ navigate · T theme · A animation · F fullscreen · O overview · S presenter", + "cta.foot": "cta", + "cta.notes": "End on one concrete action: a single command. Don't let people leave on \"I'll try it sometime\".", + + "thanks.h1": "Thanks", + "thanks.lede": "lewis · sudolewis@gmail.com · MIT 2026", + "thanks.foot": "end", + "thanks.notes": "Thank you. Time for Q&A." +} diff --git a/examples/demo-deck/i18n/vi.json b/examples/demo-deck/i18n/vi.json new file mode 100644 index 0000000..7ac0ea6 --- /dev/null +++ b/examples/demo-deck/i18n/vi.json @@ -0,0 +1,68 @@ +{ + "_comment": "Bản dịch tiếng Việt của examples/demo-deck. Nguồn zh-CN nằm inline trong index.html và đóng vai trò baseline; key nào thiếu ở đây sẽ tự động dùng lại bản gốc. Giá trị được chèn dưới dạng HTML nên
// đều hợp lệ.", + + "cover.eyebrow": "Chia sẻ kỹ thuật · 2026-04-15", + "cover.kicker": "Keynote · Demo", + "cover.h1": "Làm slide kỹ thuật đẹp
như một tạp chí", + "cover.lede": "36 theme · 31 layout · 27 hiệu ứng · không cần build", + "cover.pill1": "tokens", + "cover.pill2": "ưu tiên bàn phím", + "cover.pill3": "xuất PNG", + "cover.pill4": "CN · EN · VI", + "cover.notes": "Chào mọi người, hôm nay mình sẽ demo cách html-ppt hoạt động. Bản demo này chính là làm bằng nó — mỗi slide chỉ là vài dòng HTML cộng một class.", + + "toc.kicker": "Nội dung", + "toc.h2": "Mười phút, ba chuyện", + "toc.c1t": "① Tại sao", + "toc.c1d": "Làm PPT lần nào cũng lặp lại đúng ngần ấy việc, mà 99% trong đó có thể đưa vào template.", + "toc.c2t": "② Làm thế nào", + "toc.c2d": "Tokens, layouts, animations — tách hẳn thành ba lớp.", + "toc.c3t": "③ Kết quả", + "toc.c3d": "Cùng một deck, đổi 36 theme bằng một phím.", + "toc.foot": "nội dung", + "toc.notes": "Cấu trúc ba phần — Why / How / Result. Đây là cách kể chắc tay nhất.", + + "quote.text": "Một bài nói hay là được viết ra,
không phải được \"làm\" ra.", + "quote.by": "— mọi người từng vật lộn với phần mềm làm slide", + "quote.foot": "trích dẫn", + "quote.notes": "Dừng một nhịp ở đây. Để câu này tự nói.", + + "stat.kicker": "Kết quả", + "stat.h3": "thời gian bạn đổ vào PPT được trả lại cho bạn", + "stat.lede": "Số liệu trung bình từ 10 deck thực tế.", + "stat.foot": "bằng chứng", + "stat.notes": "Nhấn mạnh nguồn số liệu: 10 deck thật của chính mình, không phải benchmark.", + + "tokens.kicker": "How · Ý tưởng cốt lõi", + "tokens.h2": "Dồn toàn bộ \"trông như thế nào\" vào :root", + "tokens.c1h": "Khái niệm", + "tokens.c1p": "Mọi thuộc tính thị giác — màu, font, bo góc, đổ bóng — đều trở thành biến ngữ nghĩa.", + "tokens.c2h": "Ví dụ", + "tokens.c2note": "— toàn bộ theme aurora chỉ có bấy nhiêu.", + "tokens.foot": "cách làm", + "tokens.notes": "Mấu chốt: base.css chỉ gọi tên biến, không bao giờ ghi mã màu. Đổi theme = thay một bộ biến.", + + "chart.kicker": "Numbers · Thay đổi thật sự", + "chart.h2": "Thời gian làm deck phân bổ ra sao, trước và sau", + "chart.l1": "Viết nội dung", + "chart.l2": "Chọn layout", + "chart.l3": "Chỉnh style", + "chart.l4": "Xuất ảnh", + "chart.l5": "Hiệu ứng", + "chart.before": "Trước (phút)", + "chart.after": "Sau (phút)", + "chart.foot": "dữ liệu", + "chart.notes": "Sau khi dùng, phần viết nội dung chiếm tỉ trọng lớn nhất, mọi thứ khác đều giảm — đúng thứ mình muốn.", + + "cta.kicker": "Tới lượt bạn", + "cta.h1": "Bắt đầu bản đầu tiên của bạn", + "cta.lede": "Copy một deck, thay nội dung của bạn vào, bấm T tới khi gặp theme ưng ý, nói xong thì xuất PNG một phát.", + "cta.hint": "←/→ chuyển trang · T theme · A hiệu ứng · F toàn màn hình · O tổng quan · S trình bày", + "cta.foot": "cta", + "cta.notes": "Kết bằng một hành động cụ thể: đúng một câu lệnh. Đừng để mọi người ra về với câu \"để về thử xem\".", + + "thanks.h1": "Cảm ơn", + "thanks.lede": "lewis · sudolewis@gmail.com · MIT 2026", + "thanks.foot": "hết", + "thanks.notes": "Cảm ơn mọi người. Tới phần hỏi đáp." +} diff --git a/examples/demo-deck/index.html b/examples/demo-deck/index.html index bfdd76d..f907983 100644 --- a/examples/demo-deck/index.html +++ b/examples/demo-deck/index.html @@ -10,74 +10,75 @@ - +
-
Tech sharing · 2026-04-15html-ppt
-

Keynote · Demo

-

做一份像杂志一样的
技术分享稿

-

24 主题 · 30 版式 · 25 动效 · 零构建

+
Tech sharing · 2026-04-15html-ppt
+

Keynote · Demo

+

做一份像杂志一样的
技术分享稿

+

36 主题 · 31 版式 · 27 动效 · 零构建

- tokens - keyboard first - PNG export - CN + EN + tokens + keyboard first + PNG export + CN · EN · VI
-
Hi,今天我给大家演示一下 html-ppt 这套演讲系统是怎么工作的。这份 demo 本身就是用它做出来的——每一张幻灯片都只是几行 HTML + 一个 class。
+
Hi,今天我给大家演示一下 html-ppt 这套演讲系统是怎么工作的。这份 demo 本身就是用它做出来的——每一张幻灯片都只是几行 HTML + 一个 class。
-

Agenda

-

今天 10 分钟,讲三件事

+

Agenda

+

今天 10 分钟,讲三件事

-

① 为什么

每次做 PPT 都在重复劳动,而这件事 99% 可以模板化。

-

② 怎么做

tokens + layouts + animations,三层分离。

-

③ 效果

同一份 deck,一键切 24 种主题。

+

① 为什么

每次做 PPT 都在重复劳动,而这件事 99% 可以模板化。

+

② 怎么做

tokens + layouts + animations,三层分离。

+

③ 效果

同一份 deck,一键切 36 种主题。

- -
三段式结构——Why / How / Result。这是最稳的讲法。
+ +
三段式结构——Why / How / Result。这是最稳的讲法。
"
-
+
好的演讲稿是写出来的,
不是「做」出来的。
-

— 每一个被 PPT 折磨过的人

+

— 每一个被 PPT 折磨过的人

- -
这里停一秒。让这句话自己说话。
+ +
这里停一秒。让这句话自己说话。
-

The result

+

The result

0%
-

你花在 PPT 上的时间可以被省下

-

10 份真实 deck 的平均测试数据。

+

你花在 PPT 上的时间可以被省下

+

10 份真实 deck 的平均测试数据。

- -
强调:数据来源——自己真实的 10 个 deck。
+ +
强调:数据来源——自己真实的 10 个 deck。
-

How · 核心思路

-

把「看起来像什么」收进 :root

+

How · 核心思路

+

把「看起来像什么」收进 :root

-

概念

-

每一种视觉属性——颜色、字体、圆角、阴影——都变成语义变量。

+

概念

+

每一种视觉属性——颜色、字体、圆角、阴影——都变成语义变量。

  • --text-1 / --text-2 / --text-3
  • --surface / --surface-2
  • @@ -86,7 +87,7 @@

    概念

-

示例

+

示例

 /* assets/themes/aurora.css */
 :root {
@@ -97,65 +98,110 @@ 

示例

--accent-3: #c984ff; --radius: 20px; }
-

——整个 aurora 主题就这么大。

+

——整个 aurora 主题就这么大。

- -
关键是:base.css 只认变量名,不认具体色值。换主题 = 换一份变量。
+ +
关键是:base.css 只认变量名,不认具体色值。换主题 = 换一份变量。
-

Numbers · 实际效果

-

做 deck 的时间分布,使用前/使用后

+

Numbers · 实际效果

+

做 deck 的时间分布,使用前/使用后

- -
使用后,「写内容」时间占比大幅上升,其他一切下降——这正是我们想要的。
+ +
使用后,「写内容」时间占比大幅上升,其他一切下降——这正是我们想要的。
-

Your turn

-

开始做你的第一份

-

复制一份 deck,换你的内容,按 T 选一个最对味的主题,讲完还能一键导 PNG。

+

Your turn

+

开始做你的第一份

+

复制一份 deck,换你的内容,按 T 选一个最对味的主题,讲完还能一键导 PNG。

./scripts/new-deck.sh my-talk
-

←/→ 翻页 · T 主题 · A 动效 · F 全屏 · O 概览 · S 备注

+

←/→ 翻页 · T 主题 · A 动效 · F 全屏 · O 概览 · S 备注

- -
最后给一个具体的行动:一条命令。别停留在「我回去试试」。
+ +
最后给一个具体的行动:一条命令。别停留在「我回去试试」。
-

Thanks

-

lewis · sudolewis@gmail.com · MIT 2026

+

谢谢

+

lewis · sudolewis@gmail.com · MIT 2026

- -
谢谢大家。Q&A 时间。
+ +
谢谢大家。Q&A 时间。
+ diff --git a/index.html b/index.html new file mode 100644 index 0000000..2dff1d3 --- /dev/null +++ b/index.html @@ -0,0 +1,97 @@ + + + + + +html-ppt — HTML PPT Studio + + + + + + + diff --git a/preview/anim-stage.html b/preview/anim-stage.html new file mode 100644 index 0000000..90ba667 --- /dev/null +++ b/preview/anim-stage.html @@ -0,0 +1,294 @@ + + + + + +html-ppt · Animation stage + + + + + + + + +
+
+
+
+
+
+
+ + + + + diff --git a/preview/app.css b/preview/app.css new file mode 100644 index 0000000..ed60000 --- /dev/null +++ b/preview/app.css @@ -0,0 +1,209 @@ +/* html-ppt :: preview site chrome. + * + * Deliberately does NOT use assets/base.css or any theme token. The panel must + * stay visually constant while 36 themes flash past inside the iframe — if the + * chrome re-skinned along with the slide you'd have no fixed reference to judge + * against. Neutral dark grey is the closest thing to a darkroom safelight. + */ + +:root { + --ui-bg: #0d0e12; + --ui-panel: #14161c; + --ui-panel-2: #1b1e26; + --ui-line: #262a34; + --ui-line-2: #333947; + --ui-text: #e6e8ef; + --ui-text-2: #9aa1b1; + --ui-text-3: #666d7e; + --ui-accent: #5b8cff; + --ui-radius: 10px; + --rail-w: 268px; + --font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; + --mono: ui-monospace, SFMono-Regular, 'JetBrains Mono', Menlo, Consolas, monospace; +} + +* , *::before, *::after { box-sizing: border-box; } + +html, body { + margin: 0; height: 100%; + background: var(--ui-bg); color: var(--ui-text); + font-family: var(--font); font-size: 14px; line-height: 1.5; + -webkit-font-smoothing: antialiased; +} + +body { display: grid; grid-template-columns: var(--rail-w) 1fr; } + +/* ============================ LEFT RAIL ============================ */ +.rail { + background: var(--ui-panel); + border-right: 1px solid var(--ui-line); + display: flex; flex-direction: column; + min-height: 0; +} + +.rail-head { padding: 16px 14px 12px; border-bottom: 1px solid var(--ui-line); } + +.brand { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; } +.brand strong { display: block; font-size: 14px; letter-spacing: -.01em; } +.brand-sub { display: block; font-size: 11px; color: var(--ui-text-3); } +.brand-dot { + width: 10px; height: 10px; border-radius: 50%; flex: none; + background: linear-gradient(135deg, #5ef2c6, #7aa2ff 55%, #c984ff); +} + +.search { + width: 100%; padding: 7px 10px; + background: var(--ui-bg); color: var(--ui-text); + border: 1px solid var(--ui-line-2); border-radius: 8px; + font: inherit; font-size: 13px; +} +.search::placeholder { color: var(--ui-text-3); } +.search:focus { outline: none; border-color: var(--ui-accent); } + +.theme-list { flex: 1; overflow-y: auto; padding: 8px; min-height: 0; } + +.group-name { + font-size: 10px; text-transform: uppercase; letter-spacing: .14em; + color: var(--ui-text-3); padding: 12px 8px 6px; +} + +.theme-btn { + display: flex; align-items: center; gap: 10px; width: 100%; + padding: 7px 8px; margin-bottom: 2px; + background: none; border: 1px solid transparent; border-radius: 8px; + color: var(--ui-text-2); font: inherit; text-align: left; cursor: pointer; +} +.theme-btn:hover { background: var(--ui-panel-2); color: var(--ui-text); } +.theme-btn.is-active { + background: var(--ui-panel-2); color: var(--ui-text); + border-color: var(--ui-line-2); +} +.theme-btn.is-active .tb-label { font-weight: 600; } + +/* The swatch is a miniature of the theme: page background, then its three + accents as dots. Enough to predict the slide before you click it. */ +.swatch { + width: 34px; height: 24px; border-radius: 5px; flex: none; + border: 1px solid rgba(255,255,255,.16); + display: flex; align-items: center; justify-content: center; gap: 2.5px; +} +.swatch i { width: 5px; height: 5px; border-radius: 50%; display: block; } + +.tb-text { min-width: 0; flex: 1; } +.tb-label { display: block; font-size: 12.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.tb-note { display: block; font-size: 10.5px; color: var(--ui-text-3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + +.rail-foot { + padding: 9px 14px; border-top: 1px solid var(--ui-line); + display: flex; justify-content: space-between; align-items: center; + font-size: 11px; color: var(--ui-text-3); +} +.rail-foot a { color: var(--ui-text-3); text-decoration: none; } +.rail-foot a:hover { color: var(--ui-text); } + +/* ============================== MAIN ============================== */ +.main { display: grid; grid-template-rows: auto 1fr auto; min-width: 0; min-height: 0; } + +.bar { + display: flex; align-items: center; gap: 12px; + padding: 10px 14px; + border-bottom: 1px solid var(--ui-line); + background: var(--ui-panel); +} +.bar-group { display: flex; align-items: center; gap: 8px; } +.bar-group[hidden] { display: none; } +.bar-spacer { flex: 1; } +.bar-label { font-size: 10px; text-transform: uppercase; letter-spacing: .12em; color: var(--ui-text-3); } + +.seg { display: flex; background: var(--ui-bg); border: 1px solid var(--ui-line-2); border-radius: 8px; padding: 2px; } +.seg-btn { + padding: 5px 12px; border: 0; border-radius: 6px; + background: none; color: var(--ui-text-2); + font: inherit; font-size: 12.5px; cursor: pointer; +} +.seg-btn:hover { color: var(--ui-text); } +.seg-btn.is-active { background: var(--ui-panel-2); color: var(--ui-text); box-shadow: 0 1px 2px rgba(0,0,0,.4); } +.seg-sm .seg-btn { padding: 4px 10px; font-size: 12px; } + +.dots { display: flex; gap: 4px; } +.dot { + width: 24px; height: 24px; padding: 0; + border: 1px solid var(--ui-line-2); border-radius: 6px; + background: var(--ui-bg); color: var(--ui-text-3); + font: inherit; font-size: 11px; cursor: pointer; +} +.dot:hover { color: var(--ui-text); border-color: var(--ui-text-3); } +.dot.is-active { background: var(--ui-accent); border-color: var(--ui-accent); color: #fff; font-weight: 600; } + +.select { + padding: 5px 8px; min-width: 220px; + background: var(--ui-bg); color: var(--ui-text); + border: 1px solid var(--ui-line-2); border-radius: 8px; + font: inherit; font-size: 12.5px; font-family: var(--mono); cursor: pointer; +} +.select:focus { outline: none; border-color: var(--ui-accent); } + +.btn { + padding: 6px 12px; + background: var(--ui-panel-2); color: var(--ui-text); + border: 1px solid var(--ui-line-2); border-radius: 8px; + font: inherit; font-size: 12.5px; cursor: pointer; +} +.btn:hover { border-color: var(--ui-text-3); } +.btn:active { transform: translateY(1px); } +.btn-ghost { background: none; color: var(--ui-text-2); padding: 6px 9px; } + +/* ============================== STAGE ============================== */ +.stage-wrap { + position: relative; min-height: 0; overflow: hidden; + display: flex; align-items: center; justify-content: center; + padding: 22px; + /* Faint grid: gives the eye a neutral reference so a theme's own background + colour is judged against something, not against pure void. */ + background-color: var(--ui-bg); + background-image: + linear-gradient(rgba(255,255,255,.022) 1px, transparent 1px), + linear-gradient(90deg, rgba(255,255,255,.022) 1px, transparent 1px); + background-size: 32px 32px; +} + +/* The iframe is always laid out at the deck's real design resolution and then + scaled. Resizing the iframe itself would re-flow the slide at a size no + audience will ever see, which is exactly what a preview must not do. */ +.stage-shadow { + width: 1280px; height: 720px; flex: none; + transform-origin: center center; + border-radius: 8px; overflow: hidden; + box-shadow: 0 30px 80px rgba(0,0,0,.6), 0 0 0 1px var(--ui-line-2); + background: #000; +} +#stage { width: 1280px; height: 720px; border: 0; display: block; } + +/* ============================ STATUS BAR ============================ */ +.statusbar { + display: flex; align-items: center; gap: 8px; + padding: 7px 14px; + border-top: 1px solid var(--ui-line); + background: var(--ui-panel); + font-size: 11.5px; color: var(--ui-text-3); +} +.status-item b { color: var(--ui-text-2); font-family: var(--mono); font-weight: 500; } +.status-item i { font-style: normal; } +.sep { color: var(--ui-line-2); } +.keys { display: flex; align-items: center; gap: 5px; } +kbd { + display: inline-block; padding: 1px 5px; min-width: 18px; text-align: center; + background: var(--ui-bg); border: 1px solid var(--ui-line-2); + border-bottom-width: 2px; border-radius: 4px; + font-family: var(--mono); font-size: 10px; color: var(--ui-text-2); +} +.keys span { margin-right: 8px; } + +/* ============================ RESPONSIVE ============================ */ +@media (max-width: 900px) { + body { grid-template-columns: 1fr; grid-template-rows: 1fr auto; } + .rail { grid-row: 2; border-right: 0; border-top: 1px solid var(--ui-line); max-height: 42vh; } + .main { grid-row: 1; } + .bar { flex-wrap: wrap; } + .keys { display: none; } +} diff --git a/preview/app.js b/preview/app.js new file mode 100644 index 0000000..12c1b68 --- /dev/null +++ b/preview/app.js @@ -0,0 +1,431 @@ +/* html-ppt :: preview site controller. + * + * The whole point of this page is that switching a theme must not reload the + * slide. So the iframe is mounted once per stage and everything after that — + * theme, language, slide, animation — travels over postMessage to the runtime + * already living inside it. Reloading would cost a flash of unstyled deck and + * throw away the animation state you were trying to look at. + * + * The receiving ends are: + * assets/runtime.js — preview-goto / preview-theme / preview-replay + * assets/i18n.js — preview-lang + * preview/anim-stage.html — the above plus preview-anim / preview-fx + */ +(function () { + 'use strict'; + + /* Must match the ANIMS array in assets/runtime.js. */ + const CSS_ANIMS = [ + 'fade-up', 'fade-down', 'fade-left', 'fade-right', 'rise-in', 'drop-in', + 'zoom-pop', 'blur-in', 'glitch-in', 'typewriter', 'neon-glow', 'shimmer-sweep', + 'gradient-flow', 'stagger-list', 'counter-up', 'path-draw', 'parallax-tilt', + 'card-flip-3d', 'cube-rotate-3d', 'page-turn-3d', 'perspective-zoom', + 'marquee-scroll', 'kenburns', 'confetti-burst', 'spotlight', 'morph-shape', + 'ripple-reveal' + ]; + + /* Must match FX_LIST in assets/animations/fx-runtime.js (minus _util). */ + const FX_ANIMS = [ + 'particle-burst', 'confetti-cannon', 'firework', 'starfield', 'matrix-rain', + 'knowledge-graph', 'neural-net', 'constellation', 'orbit-ring', 'galaxy-swirl', + 'word-cascade', 'letter-explode', 'chain-react', 'magnetic-field', 'data-stream', + 'gradient-blob', 'sparkle-trail', 'shockwave', 'typewriter-multi', 'counter-explosion' + ]; + + const LANGS = ['zh', 'en', 'vi']; + const SLIDE_COUNT = 8; /* examples/demo-deck has 8 slides */ + const DECK = '../examples/demo-deck/index.html'; + + /* Flat [{id,label,note,...}] across all groups, in rail order — this is what + the T key and any index-based cycling walk through. */ + let THEMES = []; + + const state = { + stage: 'theme', /* 'theme' | 'anim' */ + theme: 'aurora', + lang: 'zh', + slide: 1, /* 1-based, matches ?preview=N */ + anim: 'fade-up', + kind: 'css' /* 'css' | 'fx' */ + }; + + const $ = (sel) => document.querySelector(sel); + const stageEl = $('#stage'); + let frameReady = false; + + /* ------------------------------------------------------------------ hash */ + + function readHash() { + const h = location.hash.replace(/^#/, ''); + if (!h) return; + const q = new URLSearchParams(h); + /* Assign both ways. Only ever setting 'anim' here would work on a cold load + (where the default is already 'theme') but would silently refuse to go + back to 'theme' when re-reading the hash on a live page. */ + const st = q.get('stage'); + if (st === 'anim' || st === 'theme') state.stage = st; + if (q.get('theme')) state.theme = q.get('theme'); + if (LANGS.indexOf(q.get('lang')) > -1) state.lang = q.get('lang'); + const n = parseInt(q.get('slide'), 10); + if (n >= 1 && n <= SLIDE_COUNT) state.slide = n; + if (q.get('fx')) { state.kind = 'fx'; state.anim = q.get('fx'); } + else if (q.get('anim')) { state.kind = 'css'; state.anim = q.get('anim'); } + } + + function writeHash() { + const q = new URLSearchParams(); + q.set('stage', state.stage); + q.set('theme', state.theme); + q.set('lang', state.lang); + if (state.stage === 'theme') q.set('slide', String(state.slide)); + else q.set(state.kind === 'fx' ? 'fx' : 'anim', state.anim); + /* replaceState, not location.hash — this fires many times a second while + arrowing through themes and must not stack up browser history entries. */ + history.replaceState(null, '', '#' + q.toString()); + } + + /* ------------------------------------------------------- iframe plumbing */ + + function send(msg) { + if (!frameReady || !stageEl.contentWindow) return; + stageEl.contentWindow.postMessage(msg, '*'); + } + + function frameURL() { + if (state.stage === 'theme') { + /* runtime.js reads ?preview=N (1-based) and ?lang is read by i18n.js. + Both are only bootstrap values; later changes go over postMessage. */ + return DECK + '?preview=' + state.slide + '&lang=' + state.lang; + } + const key = state.kind === 'fx' ? 'fx' : 'anim'; + return 'anim-stage.html?theme=' + state.theme + '&lang=' + state.lang + + '&' + key + '=' + state.anim; + } + + /* Remount the iframe. Only ever called when the *stage* changes — never for a + theme, language or slide change. */ + function mount() { + frameReady = false; + stageEl.src = frameURL(); + } + + stageEl.addEventListener('load', function () { + frameReady = true; + /* The URL already carried lang (and, on the anim stage, theme+effect), but + the deck stage boots on its own hard-coded theme — so push the full state + once the runtime inside is listening. */ + send({ type: 'preview-theme', name: state.theme }); + if (state.stage === 'theme') send({ type: 'preview-goto', idx: state.slide - 1 }); + }); + + /* --------------------------------------------------------------- actions */ + + function setTheme(id, opts) { + state.theme = id; + send({ type: 'preview-theme', name: id }); + paintThemeRail(); + paintStatus(); + writeHash(); + if (opts && opts.scroll) scrollThemeIntoView(id); + } + + function setLang(lang) { + state.lang = lang; + send({ type: 'preview-lang', lang: lang }); + paintLang(); + writeHash(); + } + + function setSlide(n) { + state.slide = Math.min(SLIDE_COUNT, Math.max(1, n)); + send({ type: 'preview-goto', idx: state.slide - 1 }); + paintDots(); + paintStatus(); + writeHash(); + } + + function setAnim(kind, name) { + state.kind = kind; + state.anim = name; + send({ type: kind === 'fx' ? 'preview-fx' : 'preview-anim', name: name }); + paintAnimSelect(); + paintStatus(); + writeHash(); + } + + function setStage(stage) { + if (state.stage === stage) return; + state.stage = stage; + $('#slide-group').hidden = (stage !== 'theme'); + $('#anim-group').hidden = (stage !== 'anim'); + $('#k-nav').textContent = (stage === 'theme') ? 'slide' : 'effect'; + document.querySelectorAll('.seg-btn[data-stage]').forEach((b) => + b.classList.toggle('is-active', b.dataset.stage === stage)); + writeHash(); + paintStatus(); + mount(); /* the only case that legitimately reloads */ + } + + const replay = () => send({ type: 'preview-replay' }); + + /* Pasting a shared #hash into an already-open tab (or editing it by hand) + only changes the fragment — the document is never re-created, so without + this the URL and the stage would silently drift apart. writeHash() uses + replaceState, which does not fire hashchange, so there's no feedback loop. */ + function applyHash() { + const prev = { stage: state.stage, theme: state.theme, lang: state.lang, + slide: state.slide, anim: state.anim, kind: state.kind }; + readHash(); + if (!THEMES.some((t) => t.id === state.theme)) state.theme = prev.theme; + + if (state.stage !== prev.stage) { + /* setStage does the remount + chrome swap, but it early-returns when the + stage is unchanged — so hand it the old value and let it transition. */ + const target = state.stage; + state.stage = prev.stage; + setStage(target); + } else if (state.theme !== prev.theme) { setTheme(state.theme, { scroll: true }); } + + if (state.lang !== prev.lang) setLang(state.lang); + if (state.stage === 'theme' && state.slide !== prev.slide) setSlide(state.slide); + if (state.stage === 'anim' && (state.anim !== prev.anim || state.kind !== prev.kind)) { + setAnim(state.kind, state.anim); + } + paintThemeRail(); paintStatus(); + } + + /* ----------------------------------------------------------- theme rail */ + + function buildThemeRail(groups) { + const list = $('#theme-list'); + const frag = document.createDocumentFragment(); + + groups.forEach((group) => { + const h = document.createElement('div'); + h.className = 'group-name'; + h.textContent = group.name; + h.dataset.group = group.name; + frag.appendChild(h); + + group.themes.forEach((t) => { + THEMES.push(t); + const b = document.createElement('button'); + b.className = 'theme-btn'; + b.dataset.id = t.id; + b.dataset.hay = (t.id + ' ' + t.label + ' ' + (t.note || '')).toLowerCase(); + b.setAttribute('role', 'option'); + b.innerHTML = + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + ''; + /* textContent, not innerHTML — theme labels/notes are data, and this + keeps a stray < in a future theme name from breaking the rail. */ + b.querySelector('.tb-label').textContent = t.label; + b.querySelector('.tb-note').textContent = t.note || t.id; + b.addEventListener('click', () => setTheme(t.id)); + frag.appendChild(b); + }); + }); + + list.appendChild(frag); + $('#theme-count').textContent = THEMES.length + ' themes'; + } + + function paintThemeRail() { + document.querySelectorAll('.theme-btn').forEach((b) => + b.classList.toggle('is-active', b.dataset.id === state.theme)); + } + + function scrollThemeIntoView(id) { + const b = document.querySelector('.theme-btn[data-id="' + id + '"]'); + if (b) b.scrollIntoView({ block: 'nearest' }); + } + + function filterThemes(q) { + q = q.trim().toLowerCase(); + document.querySelectorAll('.theme-btn').forEach((b) => { + b.hidden = q ? b.dataset.hay.indexOf(q) === -1 : false; + }); + /* Hide a group heading whose themes are all filtered out. */ + document.querySelectorAll('.group-name').forEach((h) => { + let n = h.nextElementSibling, any = false; + while (n && n.classList.contains('theme-btn')) { + if (!n.hidden) { any = true; break; } + n = n.nextElementSibling; + } + h.hidden = !any; + }); + } + + /* -------------------------------------------------------------- painting */ + + function buildDots() { + const wrap = $('#slide-dots'); + for (let i = 1; i <= SLIDE_COUNT; i++) { + const b = document.createElement('button'); + b.className = 'dot'; + b.textContent = String(i); + b.dataset.n = String(i); + b.addEventListener('click', () => setSlide(i)); + wrap.appendChild(b); + } + } + + function paintDots() { + document.querySelectorAll('.dot').forEach((d) => + d.classList.toggle('is-active', +d.dataset.n === state.slide)); + } + + function buildAnimSelect() { + const sel = $('#anim-select'); + const mk = (label, items, kind) => { + const g = document.createElement('optgroup'); + g.label = label; + items.forEach((name) => { + const o = document.createElement('option'); + o.value = kind + ':' + name; + o.textContent = name; + g.appendChild(o); + }); + return g; + }; + sel.appendChild(mk('CSS · data-anim (' + CSS_ANIMS.length + ')', CSS_ANIMS, 'css')); + sel.appendChild(mk('Canvas FX · data-fx (' + FX_ANIMS.length + ')', FX_ANIMS, 'fx')); + sel.addEventListener('change', () => { + const [kind, name] = sel.value.split(':'); + setAnim(kind, name); + }); + } + + function paintAnimSelect() { $('#anim-select').value = state.kind + ':' + state.anim; } + + function paintLang() { + document.querySelectorAll('.seg-btn[data-lang]').forEach((b) => + b.classList.toggle('is-active', b.dataset.lang === state.lang)); + } + + function paintStatus() { + const t = THEMES.find((x) => x.id === state.theme); + $('#s-theme').textContent = state.theme; + $('#s-note').textContent = t ? '— ' + t.label : ''; + $('#s-ctx').textContent = (state.stage === 'theme') + ? 'slide ' + state.slide + ' / ' + SLIDE_COUNT + : (state.kind === 'fx' ? 'data-fx="' : 'data-anim="') + state.anim + '"'; + } + + /* --------------------------------------------------------------- scaling */ + + /* The deck is authored at 1280×720 and must be *shown* at 1280×720, then + scaled as a whole — so what you review is the real layout, not a reflow. */ + function fitStage() { + const wrap = $('#stage-wrap'); + const pad = 44; + const w = wrap.clientWidth - pad; + const h = wrap.clientHeight - pad; + const scale = Math.max(0.1, Math.min(w / 1280, h / 720)); + $('#stage-shadow').style.transform = 'scale(' + scale + ')'; + /* Reserve the *scaled* footprint so flex centring has honest numbers to + work with; transform alone doesn't affect layout size. */ + $('#stage-shadow').style.margin = + Math.round((720 * scale - 720) / 2) + 'px ' + + Math.round((1280 * scale - 1280) / 2) + 'px'; + } + + /* ------------------------------------------------------------- keyboard */ + + function cycle(arr, cur, dir) { + const i = arr.indexOf(cur); + return arr[(i + dir + arr.length) % arr.length]; + } + + function onKey(e) { + const tag = (e.target.tagName || '').toLowerCase(); + if (tag === 'input' || tag === 'select' || tag === 'textarea') return; + if (e.metaKey || e.ctrlKey || e.altKey) return; + + const k = e.key.toLowerCase(); + + if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') { + const dir = e.key === 'ArrowRight' ? 1 : -1; + if (state.stage === 'theme') setSlide(state.slide + dir); + else { + const all = CSS_ANIMS.map(n => 'css:' + n).concat(FX_ANIMS.map(n => 'fx:' + n)); + const [kind, name] = cycle(all, state.kind + ':' + state.anim, dir).split(':'); + setAnim(kind, name); + } + e.preventDefault(); + } else if (k === 't') { + const ids = THEMES.map((t) => t.id); + setTheme(cycle(ids, state.theme, e.shiftKey ? -1 : 1), { scroll: true }); + } else if (k === 'l') { + setLang(cycle(LANGS, state.lang, e.shiftKey ? -1 : 1)); + } else if (k === 'r') { + replay(); + } + } + + /* ------------------------------------------------------------------ boot */ + + fetch('themes.json') + .then((r) => { + if (!r.ok) throw new Error('HTTP ' + r.status); + return r.json(); + }) + .then((data) => { + readHash(); + buildThemeRail(data.groups); + + /* A stale or hand-edited hash shouldn't leave the rail with nothing lit. */ + if (!THEMES.some((t) => t.id === state.theme)) state.theme = THEMES[0].id; + + buildDots(); + buildAnimSelect(); + + $('#slide-group').hidden = (state.stage !== 'theme'); + $('#anim-group').hidden = (state.stage !== 'anim'); + $('#k-nav').textContent = (state.stage === 'theme') ? 'slide' : 'effect'; + document.querySelectorAll('.seg-btn[data-stage]').forEach((b) => { + b.classList.toggle('is-active', b.dataset.stage === state.stage); + b.addEventListener('click', () => setStage(b.dataset.stage)); + }); + document.querySelectorAll('.seg-btn[data-lang]').forEach((b) => + b.addEventListener('click', () => setLang(b.dataset.lang))); + + $('#theme-search').addEventListener('input', (e) => filterThemes(e.target.value)); + $('#replay').addEventListener('click', replay); + $('#open-new').addEventListener('click', () => window.open(frameURL(), '_blank', 'noopener')); + + paintThemeRail(); + paintDots(); + paintAnimSelect(); + paintLang(); + paintStatus(); + scrollThemeIntoView(state.theme); + + mount(); + fitStage(); + new ResizeObserver(fitStage).observe($('#stage-wrap')); + addEventListener('keydown', onKey); + addEventListener('hashchange', applyHash); + }) + .catch((err) => { + /* Nearly always the file:// case: fetch() of a local JSON is blocked, so + the rail can never populate. Say so plainly instead of showing an + empty panel that looks like a hang. */ + document.body.innerHTML = + '
' + + '

Could not load themes.json

' + + '

' + String(err.message) + '

' + + '

This page needs to be served over HTTP — ' + + 'opening it straight from the filesystem blocks both the theme catalog and the ' + + 'translation files. Try python3 -m http.server ' + + 'from the repository root, then open ' + + 'http://localhost:8000/preview/.

'; + }); +})(); diff --git a/preview/i18n/en.json b/preview/i18n/en.json new file mode 100644 index 0000000..e836047 --- /dev/null +++ b/preview/i18n/en.json @@ -0,0 +1,27 @@ +{ + "_comment": "English strings for preview/anim-stage.html. The zh-CN baseline lives in the ZH map inside that file; any key omitted here falls back to it.", + + "stage.kicker": "Demo", + "stage.h1": "Say the idea clearly", + "stage.lede": "One class, one motion. All of it driven by tokens.", + "stage.type": "typing this line out…", + "stage.hover": "hover the card", + "stage.thanks": "Thanks", + "stage.pill1": "tokens", + "stage.pill2": "keyboard first", + "stage.pill3": "PNG export", + "stage.pill4": "zero build", + + "stage.card1t": "① Structure", + "stage.card1d": "Work out what you're saying before you worry about how it looks.", + "stage.card2t": "② Layouts", + "stage.card2d": "31 layouts, covering nearly every case you'll hit.", + "stage.card3t": "③ Themes", + "stage.card3d": "36 themes, switched by swapping one variable file.", + "stage.card4t": "④ Motion", + "stage.card4d": "27 CSS animations plus 20 canvas effects.", + "stage.card5t": "⑤ Script", + "stage.card5d": "Speaker notes live in .notes — press S to open them.", + "stage.card6t": "⑥ Export", + "stage.card6d": "One command renders every slide to PNG." +} diff --git a/preview/i18n/vi.json b/preview/i18n/vi.json new file mode 100644 index 0000000..0126200 --- /dev/null +++ b/preview/i18n/vi.json @@ -0,0 +1,27 @@ +{ + "_comment": "Chuỗi tiếng Việt cho preview/anim-stage.html. Baseline zh-CN nằm trong map ZH ngay trong file đó; key nào thiếu ở đây sẽ tự động dùng lại baseline.", + + "stage.kicker": "Demo", + "stage.h1": "Nói ý tưởng cho rõ", + "stage.lede": "Một class, một chuyển động. Tất cả đều do tokens điều khiển.", + "stage.type": "đang gõ dòng này…", + "stage.hover": "rê chuột lên thẻ", + "stage.thanks": "Cảm ơn", + "stage.pill1": "tokens", + "stage.pill2": "ưu tiên bàn phím", + "stage.pill3": "xuất PNG", + "stage.pill4": "không cần build", + + "stage.card1t": "① Cấu trúc", + "stage.card1d": "Nghĩ cho xong mình định nói gì, rồi hẵng lo tới chuyện đẹp.", + "stage.card2t": "② Layout", + "stage.card2d": "31 layout, phủ gần hết các tình huống bạn sẽ gặp.", + "stage.card3t": "③ Theme", + "stage.card3d": "36 theme, đổi bằng cách thay đúng một file biến.", + "stage.card4t": "④ Hiệu ứng", + "stage.card4d": "27 hiệu ứng CSS cộng 20 hiệu ứng canvas.", + "stage.card5t": "⑤ Kịch bản", + "stage.card5d": "Lời dẫn nằm trong .notes — bấm S để mở ra.", + "stage.card6t": "⑥ Xuất file", + "stage.card6d": "Một câu lệnh xuất toàn bộ slide ra PNG." +} diff --git a/preview/index.html b/preview/index.html new file mode 100644 index 0000000..38c3445 --- /dev/null +++ b/preview/index.html @@ -0,0 +1,84 @@ + + + + + +html-ppt · Theme & Animation Preview + + + + + + + +
+
+
+ + +
+ +
+ Slide +
+
+ + + +
+ +
+ Language +
+ + + +
+
+ + + +
+ +
+
+ +
+
+ +
+ aurora + · + slide 1 / 8 + + + slide + T theme + L language + R replay + +
+
+ + + + diff --git a/preview/themes.json b/preview/themes.json new file mode 100644 index 0000000..da7bf99 --- /dev/null +++ b/preview/themes.json @@ -0,0 +1,66 @@ +{ + "_comment": "Catalog for the preview site. Colors are copied verbatim from assets/themes/.css so the swatches show the real palette. If you add a theme, add it here too.", + "groups": [ + { + "name": "Minimal & Editorial", + "themes": [ + { "id": "minimal-white", "label": "Minimal White", "note": "极简白 · clean restraint", "bg": "#ffffff", "text": "#0c0d10", "accent": "#111216", "accent2": "#3b3f4a", "accent3": "#6b6f7a" }, + { "id": "sharp-mono", "label": "Sharp Mono", "note": "黑白高对比", "bg": "#ffffff", "text": "#000000", "accent": "#000000", "accent2": "#000000", "accent3": "#ff2200" }, + { "id": "editorial-serif", "label": "Editorial Serif", "note": "杂志衬线 · high editorial", "bg": "#faf7f2", "text": "#1b1410", "accent": "#8a2a1c", "accent2": "#c97a4a", "accent3": "#1b1410" }, + { "id": "academic-paper", "label": "Academic Paper", "note": "学术白皮书", "bg": "#fdfcf8", "text": "#0a0a0a", "accent": "#1a3a7a", "accent2": "#0a0a0a", "accent3": "#8a1a1a" }, + { "id": "japanese-minimal","label": "Japanese Minimal","note": "和风极简 · 朱红", "bg": "#fafaf5", "text": "#1a1a18", "accent": "#d93a2a", "accent2": "#1a1a18", "accent3": "#c9a961" }, + { "id": "swiss-grid", "label": "Swiss Grid", "note": "国际主义网格", "bg": "#ffffff", "text": "#111111", "accent": "#d6001c", "accent2": "#111111", "accent3": "#888888" }, + { "id": "midcentury", "label": "Mid-Century", "note": "Mid-Century Modern", "bg": "#f3ead8", "text": "#201810", "accent": "#d4902a", "accent2": "#2a7a7f", "accent3": "#c7502a" }, + { "id": "magazine-bold", "label": "Magazine Bold", "note": "大字杂志", "bg": "#f5efe2", "text": "#0a0a0a", "accent": "#ea5a1a", "accent2": "#0a0a0a", "accent3": "#c42a10" } + ] + }, + { + "name": "Business & Technical", + "themes": [ + { "id": "corporate-clean", "label": "Corporate Clean", "note": "商务正式", "bg": "#ffffff", "text": "#0a2540", "accent": "#0a2540", "accent2": "#1d4ed8", "accent3": "#64748b" }, + { "id": "pitch-deck-vc", "label": "Pitch Deck VC", "note": "YC / VC 路演", "bg": "#ffffff", "text": "#0b0d12", "accent": "#0070f3", "accent2": "#7928ca", "accent3": "#ff4ecb" }, + { "id": "engineering-whiteprint", "label": "Eng. Whiteprint", "note": "工程白图", "bg": "#ffffff", "text": "#0a1e46", "accent": "#0a1e46", "accent2": "#1e5ac4", "accent3": "#c42a10" }, + { "id": "blueprint", "label": "Blueprint", "note": "蓝图", "bg": "#0b3a6f", "text": "#e8f3ff", "accent": "#ffffff", "accent2": "#aee1ff", "accent3": "#ffd27a" }, + { "id": "news-broadcast", "label": "News Broadcast", "note": "新闻播报", "bg": "#ffffff", "text": "#0a0a0a", "accent": "#e11d2d", "accent2": "#0a0a0a", "accent3": "#ffd100" } + ] + }, + { + "name": "Developer", + "themes": [ + { "id": "tokyo-night", "label": "Tokyo Night", "note": "技术分享默认好看", "bg": "#1a1b26", "text": "#c0caf5", "accent": "#7aa2f7", "accent2": "#bb9af7", "accent3": "#7dcfff" }, + { "id": "catppuccin-mocha", "label": "Catppuccin Mocha", "note": "暗色柔和", "bg": "#1e1e2e", "text": "#cdd6f4", "accent": "#cba6f7", "accent2": "#89b4fa", "accent3": "#f5c2e7" }, + { "id": "catppuccin-latte", "label": "Catppuccin Latte", "note": "亮色柔和", "bg": "#eff1f5", "text": "#4c4f69", "accent": "#8839ef", "accent2": "#1e66f5", "accent3": "#ea76cb" }, + { "id": "dracula", "label": "Dracula", "note": "经典暗色", "bg": "#282a36", "text": "#f8f8f2", "accent": "#bd93f9", "accent2": "#ff79c6", "accent3": "#8be9fd" }, + { "id": "nord", "label": "Nord", "note": "北欧冷色", "bg": "#2e3440", "text": "#eceff4", "accent": "#88c0d0", "accent2": "#81a1c1", "accent3": "#b48ead" }, + { "id": "gruvbox-dark", "label": "Gruvbox Dark", "note": "复古暖调暗色", "bg": "#282828", "text": "#ebdbb2", "accent": "#fabd2f", "accent2": "#fe8019", "accent3": "#b8bb26" }, + { "id": "rose-pine", "label": "Rosé Pine", "note": "低饱和暗色", "bg": "#191724", "text": "#e0def4", "accent": "#ebbcba", "accent2": "#c4a7e7", "accent3": "#9ccfd8" }, + { "id": "solarized-light", "label": "Solarized Light", "note": "护眼亮色", "bg": "#fdf6e3", "text": "#073642", "accent": "#268bd2", "accent2": "#2aa198", "accent3": "#d33682" }, + { "id": "terminal-green", "label": "Terminal Green", "note": "终端绿", "bg": "#030a04", "text": "#8cff9a", "accent": "#00ff88", "accent2": "#67ffd0", "accent3": "#b6ff6b" } + ] + }, + { + "name": "Soft & Social", + "themes": [ + { "id": "xiaohongshu-white", "label": "小红书白底", "note": "小红书图文", "bg": "#fffdfb", "text": "#1a1210", "accent": "#ff2742", "accent2": "#ff7a90", "accent3": "#ffb38a" }, + { "id": "soft-pastel", "label": "Soft Pastel", "note": "马卡龙", "bg": "#fdf7fb", "text": "#3a1f33", "accent": "#f49bb8", "accent2": "#b5d5f0", "accent3": "#f7d08a" }, + { "id": "arctic-cool", "label": "Arctic Cool", "note": "冷色调", "bg": "#f2f6fb", "text": "#0e1f33", "accent": "#1e6fb0", "accent2": "#17b1b1", "accent3": "#6f8aa6" }, + { "id": "sunset-warm", "label": "Sunset Warm", "note": "暖色调", "bg": "#fff7ef", "text": "#2a160a", "accent": "#e36a2d", "accent2": "#f2a341", "accent3": "#d94860" }, + { "id": "rainbow-gradient", "label": "Rainbow Grad.", "note": "彩虹渐变", "bg": "#ffffff", "text": "#0c0d10", "accent": "#ff4d8b", "accent2": "#7a5cff", "accent3": "#36b6ff" }, + { "id": "memphis-pop", "label": "Memphis Pop", "note": "孟菲斯波普", "bg": "#fef6e8", "text": "#111111", "accent": "#ff3d8b", "accent2": "#37c2d7", "accent3": "#ffcc00" } + ] + }, + { + "name": "Bold & Experimental", + "themes": [ + { "id": "aurora", "label": "Aurora", "note": "极光 · demo 默认", "bg": "#06091c", "text": "#e8f0ff", "accent": "#5ef2c6", "accent2": "#7aa2ff", "accent3": "#c984ff" }, + { "id": "cyberpunk-neon", "label": "Cyberpunk Neon","note": "霓虹", "bg": "#000000", "text": "#f5f7ff", "accent": "#ff2bd6", "accent2": "#00f0ff", "accent3": "#f9f871" }, + { "id": "vaporwave", "label": "Vaporwave", "note": "蒸汽波", "bg": "#1a0938", "text": "#fdf0ff", "accent": "#ff6ec7", "accent2": "#00f5ff", "accent3": "#ffd166" }, + { "id": "y2k-chrome", "label": "Y2K Chrome", "note": "千禧镜面", "bg": "#dfe4ec", "text": "#1a1f2e", "accent": "#8a5cff", "accent2": "#3ccfd8", "accent3": "#ff84c4" }, + { "id": "neo-brutalism", "label": "Neo-Brutalism", "note": "新粗野", "bg": "#fffef0", "text": "#000000", "accent": "#ffd400", "accent2": "#ff5ca8", "accent3": "#3a7cff" }, + { "id": "retro-tv", "label": "Retro TV", "note": "CRT 扫描线", "bg": "#f5ecd7", "text": "#2a1a08", "accent": "#e67e14", "accent2": "#c73a1f", "accent3": "#f2b544" }, + { "id": "glassmorphism", "label": "Glassmorphism", "note": "毛玻璃", "bg": "#0b1024", "text": "#f2f4ff", "accent": "#7dd3fc", "accent2": "#c084fc", "accent3": "#f0abfc" }, + { "id": "bauhaus", "label": "Bauhaus", "note": "几何原色", "bg": "#f4efe3", "text": "#111111", "accent": "#e03c27", "accent2": "#f4c430", "accent3": "#1d4eaf" } + ] + } + ] +} From f362b8625413d0978075d9d249b3371bbb742491 Mon Sep 17 00:00:00 2001 From: Kien Nguyen Date: Wed, 15 Jul 2026 13:05:58 +0700 Subject: [PATCH 2/6] fix(runtime): run .counter count-up in preview mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slide 4 of demo-deck has no anim-* class at all — its only motion is the .counter element counting 0 -> 92. That count is JS-driven, and the code doing it sat inside go(), which is on the normal navigation path, well past the early return that preview mode takes. So in preview mode the counter never ran and the slide sat on a permanent "0%". This predates the preview site: presenter mode (S) renders its CURRENT / NEXT cards through the same ?preview=N path, so the presenter has always been shown "0%" for that slide while the audience saw it count up. Hoists the count-up into a shared animateCounters(slide) used by both paths rather than copying it, and calls it from preview mode on load, on preview-goto and on preview-replay. Verified it climbs through intermediate values (0/37/62/78/87/91/92) rather than snapping, in preview mode, on replay, on revisit, and unchanged in normal mode. Co-Authored-By: Claude Opus 4.8 --- assets/runtime.js | 40 +++++++++++++++++++++++++++------------- 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/assets/runtime.js b/assets/runtime.js index e282e0c..c9eb1a8 100644 --- a/assets/runtime.js +++ b/assets/runtime.js @@ -46,6 +46,28 @@ const previewOnlyIdx = getPreviewIdx(); const isPreviewMode = previewOnlyIdx >= 0 && previewOnlyIdx < slides.length; + /* Count a .counter element from 0 up to its data-to. Unlike everything in + * animations.css this cannot be expressed as a class, so it has to be run + * by hand whenever a slide is shown — including in preview mode, which + * returns long before the normal navigation path below. Shared so the two + * callers can't drift apart. */ + function animateCounters(slide) { + if (!slide) return; + slide.querySelectorAll('.counter').forEach((el) => { + const target = parseFloat(el.getAttribute('data-to') || el.textContent); + if (isNaN(target)) return; + const dur = parseInt(el.getAttribute('data-dur') || '1200', 10); + const start = performance.now(); + function tick(now) { + const t = Math.min(1, (now - start) / dur); + const v = target * (1 - Math.pow(1 - t, 3)); /* ease-out cubic */ + el.textContent = (target % 1 === 0) ? Math.round(v) : v.toFixed(1); + if (t < 1) requestAnimationFrame(tick); + } + requestAnimationFrame(tick); + }); + } + /* ===== Preview-only mode: show one slide, hide everything else ===== */ if (isPreviewMode) { function showSlide(i) { @@ -61,6 +83,7 @@ }); } showSlide(previewOnlyIdx); + animateCounters(slides[previewOnlyIdx]); /* Hide chrome that the presenter shouldn't see in preview */ const hideSel = '.progress-bar, .notes-overlay, .overview, .notes, aside.notes, .speaker-notes'; document.querySelectorAll(hideSel).forEach(el => { el.style.display = 'none'; }); @@ -100,6 +123,9 @@ }); /* Canvas FX are driven by fx-runtime.js, which exposes a reinit hook. */ if (typeof window.__hpxReinit === 'function') window.__hpxReinit(slide); + /* Not every animation is a class: a .counter slide (demo-deck slide 4) + * has no anim-* at all, and replaying it means re-running the count. */ + animateCounters(slide); } /* Listen for postMessage from a parent window (presenter view, or the @@ -278,19 +304,7 @@ }); // counter-up - slides[n].querySelectorAll('.counter').forEach(el => { - const target = parseFloat(el.getAttribute('data-to')||el.textContent); - const dur = parseInt(el.getAttribute('data-dur')||'1200',10); - const start = performance.now(); - const from = 0; - function tick(now){ - const t = Math.min(1,(now-start)/dur); - const v = from + (target-from)*(1-Math.pow(1-t,3)); - el.textContent = (target % 1 === 0) ? Math.round(v) : v.toFixed(1); - if (t<1) requestAnimationFrame(tick); - } - requestAnimationFrame(tick); - }); + animateCounters(slides[n]); // Broadcast to other window (audience ↔ presenter) if (!fromRemote && bc) { From 60b0bbfff1cbd133ffbc8d6785a18a046a1b15ee Mon Sep 17 00:00:00 2001 From: Kien Nguyen Date: Wed, 15 Jul 2026 13:26:17 +0700 Subject: [PATCH 3/6] fix(fx): make canvas effects actually follow the theme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three bugs, one symptom: canvas FX did not look like the theme they ran on. 1. Dead tokens. _util.js built its palette from --ok and --danger, which are defined in exactly zero files — every theme names them --good and --bad. So two of five palette slots silently fell back to a hard-coded green and red, in all 11 modules that use U.palette. 2. The palette was the wrong idea. Rather than repoint those names, drop the status triad entirely: --good/--warn/--bad carry meaning (charts, diffs, pros/cons) and mean nothing in a particle burst, while actively fighting restrained themes — minimal-white's accents are three greys, so borrowing its --bad painted red dots over a deck whose whole point is greyscale. The pool is now the theme's decorative triad, --accent/-2/-3, which is what themes design to sit together (most build --grad from exactly these). --accent-3 had never been used by any FX. 3. Theme swaps never re-initialised anything. A canvas samples its colors once at init and cannot re-skin itself the way a CSS rule does, and nothing was watching — so pressing T on any slide with a [data-fx] left the effect painting the previous theme's palette forever. fx-runtime now rebuilds active effects on the theme 's load event, which is the moment the new variables become readable. Supporting changes: - fx-runtime is now the single owner of every [data-fx] lifecycle (__hpxStop / __hpxInit exported); preview/anim-stage.html no longer calls HPX[name] itself. Two creators on one host left an untracked second canvas whose stale colors were what actually showed. - stopFxIn snapshots the host's children at init so it can remove module-injected nodes without touching deck-author markup. Several modules append DOM and never remove it in stop(), which re-init would otherwise stack up. - orbit-ring: index the palette with pal[i % pal.length] like every sibling module instead of hard-coding pal[0..4], and stop hard-coding a violet glow and a #fff core dot that was invisible on every light theme. - U.alpha() to re-alpha a theme color for glows/trails. Verified by pixel-sampling the canvas across themes: cyberpunk-neon paints #ff2bd6/#00f0ff/#f9f871, terminal-green #00ff88/#67ffd0/#b6ff6b, tokyo-night #7aa2f7/#bb9af7/#7dcfff, minimal-white nothing saturated. All 20 FX single instance, no leaks, no errors; confirmed on templates/animation-showcase.html, not only the preview site. Co-Authored-By: Claude Opus 4.8 --- assets/animations/fx-runtime.js | 46 +++++++++++++++++++++++--- assets/animations/fx/_util.js | 52 ++++++++++++++++++++++++++++-- assets/animations/fx/orbit-ring.js | 26 +++++++++------ preview/anim-stage.html | 28 ++++++---------- references/animations.md | 15 +++++++-- 5 files changed, 129 insertions(+), 38 deletions(-) diff --git a/assets/animations/fx-runtime.js b/assets/animations/fx-runtime.js index 2c5f3c9..50fee6d 100644 --- a/assets/animations/fx-runtime.js +++ b/assets/animations/fx-runtime.js @@ -46,9 +46,16 @@ const name = el.getAttribute('data-fx'); const fn = window.HPX[name]; if (typeof fn !== 'function') return; + /* Snapshot the host's existing children before the module runs. Modules + * append their own nodes (a canvas, a number overlay, a text wrapper) and + * several never remove them in stop(); on a re-init that debris would + * stack up. The host may equally hold the deck author's own markup, which + * must survive. Recording what was there first is the only way to tell + * the two apart. */ + const own = new Set(Array.from(el.childNodes)); try { const handle = fn(el, {}) || { stop(){} }; - window.__hpxActive.set(el, handle); + window.__hpxActive.set(el, { handle: handle, own: own }); } catch(e){ console.warn('[hpx-fx]', name, e); } }); } @@ -56,9 +63,16 @@ function stopFxIn(root){ const els = root.querySelectorAll('[data-fx]'); els.forEach((el) => { - const h = window.__hpxActive.get(el); - if (h && typeof h.stop === 'function'){ - try{ h.stop(); }catch(e){} + const rec = window.__hpxActive.get(el); + if (!rec) return; + if (rec.handle && typeof rec.handle.stop === 'function'){ + try{ rec.handle.stop(); }catch(e){} + } + /* Remove only what the module added, never what was already there. */ + if (rec.own){ + Array.from(el.childNodes).forEach((n) => { + if (!rec.own.has(n)) el.removeChild(n); + }); } window.__hpxActive.delete(el); }); @@ -68,9 +82,33 @@ stopFxIn(root); initFxIn(root); } + /* fx-runtime is the single owner of every [data-fx] lifecycle. Anything that + * wants to swap an effect should change the attribute and call these, rather + * than invoking HPX[name] itself — two creators on one host means two + * canvases, of which only the last is tracked and stoppable. */ window.__hpxReinit = reinitFxIn; + window.__hpxStop = stopFxIn; + window.__hpxInit = initFxIn; + + /* Canvas FX read their colors out of CSS variables exactly once, at init — + * a canvas cannot re-skin itself the way a CSS rule does. So on a theme swap + * every running effect keeps painting the previous theme's palette, which is + * only ever noticed by whoever presses T on a slide with a [data-fx]. + * + * A fires 'load' every time its href changes, and that is precisely + * the moment the new variables become readable — reinitialising any earlier + * would just re-sample the outgoing theme. */ + function watchThemeSwaps(){ + const link = document.getElementById('theme-link'); + if (!link) return; + link.addEventListener('load', () => { + const active = document.querySelector('.slide.is-active') || document.querySelector('.slide'); + if (active) reinitFxIn(active); + }); + } function boot(){ + watchThemeSwaps(); ready.then(() => { const active = document.querySelector('.slide.is-active') || document.querySelector('.slide'); if (active) initFxIn(active); diff --git a/assets/animations/fx/_util.js b/assets/animations/fx/_util.js index 8abd11f..3cebbe7 100644 --- a/assets/animations/fx/_util.js +++ b/assets/animations/fx/_util.js @@ -10,16 +10,62 @@ U.accent = (el, fb) => U.css(el, '--accent', fb || '#7c5cff'); U.accent2 = (el, fb) => U.css(el, '--accent-2', fb || '#22d3ee'); + U.accent3 = (el, fb) => U.css(el, '--accent-3', fb || '#f0abfc'); U.text = (el, fb) => U.css(el, '--text-1', fb || '#eaeaf2'); + /* Decorative color pool for particles, nodes, sparks and rings. + * + * These are the theme's three accents, and nothing else. Two reasons: + * + * 1. They are what a theme actually designs as its signature triad — most + * themes build --grad out of exactly these three, so they are known to sit + * together. + * 2. The obvious-looking alternative, --good/--warn/--bad, is a *semantic* + * status triad meant for charts, diffs and pros/cons. Green/amber/red mean + * something. Firing them out of a confetti cannon means nothing, and on a + * restrained theme it actively fights the design: minimal-white's accents + * are three greys, so borrowing its --bad painted red dots across a deck + * whose entire point is greyscale. + * + * Callers must not assume a length — index with pal[i % pal.length]. This + * list has been 5 long and is now 3; treat it as "the theme's decorative + * colors", however many that turns out to be. + */ U.palette = (el) => [ U.accent(el, '#7c5cff'), U.accent2(el, '#22d3ee'), - U.css(el, '--ok', '#22c55e'), - U.css(el, '--warn', '#f59e0b'), - U.css(el, '--danger', '#ef4444'), + U.accent3(el, '#f0abfc'), ]; + /* Re-alpha a CSS color so an fx can draw a translucent glow/trail in a theme + * color instead of a hard-coded one. Handles the two forms theme tokens + * actually use — hex (#rgb / #rrggbb, with or without an alpha pair) and + * rgb()/rgba(). Anything else is returned untouched: better to draw the color + * at full strength than to throw inside an animation frame. */ + U.alpha = (color, a) => { + if (typeof color !== 'string') return color; + const c = color.trim(); + + let m = /^#([0-9a-f]{3,8})$/i.exec(c); + if (m) { + let h = m[1]; + if (h.length === 3 || h.length === 4) h = h.split('').map(x => x + x).join(''); + if (h.length !== 6 && h.length !== 8) return c; + const r = parseInt(h.slice(0, 2), 16); + const g = parseInt(h.slice(2, 4), 16); + const b = parseInt(h.slice(4, 6), 16); + return `rgba(${r},${g},${b},${a})`; + } + + m = /^rgba?\(([^)]+)\)$/i.exec(c); + if (m) { + const p = m[1].split(/[,\s/]+/).filter(Boolean); + if (p.length >= 3) return `rgba(${p[0]},${p[1]},${p[2]},${a})`; + } + + return c; + }; + U.canvas = (el) => { if (getComputedStyle(el).position === 'static') el.style.position = 'relative'; const c = document.createElement('canvas'); diff --git a/assets/animations/fx/orbit-ring.js b/assets/animations/fx/orbit-ring.js index af5eef6..23de42e 100644 --- a/assets/animations/fx/orbit-ring.js +++ b/assets/animations/fx/orbit-ring.js @@ -4,23 +4,27 @@ const U = window.HPX._u; const k = U.canvas(el), ctx = k.ctx; const pal = U.palette(el); + const tx = U.text(el, '#ffffff'); + /* Index with modulo like every other fx module: the palette is "the theme's + * decorative colors", and hard-coding pal[4] silently painted undefined the + * moment that list stopped being exactly five long. */ const rings = [ - {r:40, n:3, sp:1.2, c:pal[0]}, - {r:75, n:5, sp:0.8, c:pal[1]}, - {r:110, n:8, sp:-0.6, c:pal[2]}, - {r:145, n:12, sp:0.4, c:pal[3]}, - {r:180, n:16, sp:-0.3, c:pal[4]} - ]; + {r:40, n:3, sp:1.2}, + {r:75, n:5, sp:0.8}, + {r:110, n:8, sp:-0.6}, + {r:145, n:12, sp:0.4}, + {r:180, n:16, sp:-0.3} + ].map((R, i) => Object.assign(R, { c: pal[i % pal.length] })); const stop = U.loop((t) => { ctx.clearRect(0,0,k.w,k.h); const cx=k.w/2, cy=k.h/2; - // radial glow + // radial glow — tinted by the theme's accent, not a fixed violet const g = ctx.createRadialGradient(cx,cy,0,cx,cy,210); - g.addColorStop(0,'rgba(124,92,255,0.25)'); + g.addColorStop(0, U.alpha(pal[0], 0.25)); g.addColorStop(1,'rgba(0,0,0,0)'); ctx.fillStyle = g; ctx.fillRect(0,0,k.w,k.h); for (const R of rings){ - ctx.strokeStyle = 'rgba(200,200,230,0.2)'; ctx.lineWidth=1; + ctx.strokeStyle = U.alpha(tx, 0.18); ctx.lineWidth=1; ctx.beginPath(); ctx.arc(cx,cy,R.r,0,Math.PI*2); ctx.stroke(); for (let i=0;i setTimeout(r, 50)).then(() => waitForFx(name, (tries || 0) + 1)); } - function clearFx() { - if (typeof window.__hpxReinit === 'function') { - /* stop() the running handle via the runtime's own bookkeeping */ - const h = window.__hpxActive && window.__hpxActive.get(fxHost); - if (h && typeof h.stop === 'function') { try { h.stop(); } catch (e) {} } - if (window.__hpxActive) window.__hpxActive.delete(fxHost); - } - /* Several FX modules append their own DOM (a number overlay, a text wrapper) - * and don't remove it in stop(). Wiping the host is the only way to be sure - * one effect doesn't leave debris behind for the next. */ - fxHost.innerHTML = ''; - fxHost.removeAttribute('data-fx'); - } + const slideEl = document.querySelector('.slide'); + /* Swap the effect by changing the attribute and letting fx-runtime do the + * work. This page must NOT call HPX[name] itself: fx-runtime already owns + * every [data-fx] host — it inits them on slide activation and re-inits them + * on a theme swap — so a second creator here would leave an untracked canvas + * behind on every change, and the stale one paints the previous theme. */ function setFx(name) { - clearFx(); - if (!name) return; + if (window.__hpxStop) window.__hpxStop(slideEl); /* attribute still set, so it's found */ + if (!name) { fxHost.removeAttribute('data-fx'); return; } fxHost.setAttribute('data-fx', name); waitForFx(name).then((ok) => { if (!ok) { console.warn('[anim-stage] fx module never loaded:', name); return; } if (fxHost.getAttribute('data-fx') !== name) return; /* superseded while waiting */ - try { - const handle = window.HPX[name](fxHost, {}) || { stop() {} }; - window.__hpxActive.set(fxHost, handle); - } catch (e) { console.warn('[anim-stage] fx failed:', name, e); } + if (window.__hpxInit) window.__hpxInit(slideEl); }); } diff --git a/references/animations.md b/references/animations.md index 1c1295b..b228c34 100644 --- a/references/animations.md +++ b/references/animations.md @@ -111,8 +111,19 @@ Then drop one of these into any slide: ``` The container just needs a size — the FX auto-sizes a canvas to fit with -`ResizeObserver` + DPR correction. Colors read your theme (`--accent`, -`--accent-2`, `--ok`, `--warn`, `--danger`). +`ResizeObserver` + DPR correction. + +Colors come from your theme's decorative accents — `--accent`, `--accent-2`, +`--accent-3` — and nothing else. The status tokens `--good` / `--warn` / `--bad` +are deliberately *not* in the pool: they carry meaning (charts, diffs, pros and +cons), and firing green/amber/red out of a confetti cannon both means nothing +and fights restrained themes. On `minimal-white`, whose accents are three greys, +the FX come out greyscale — that is correct, not broken. + +Effects re-initialise automatically when the theme changes (a canvas samples its +colors once at init, so unlike CSS it cannot re-skin itself). If you write your +own FX, read the pool with `U.palette(el)` and index it as `pal[i % pal.length]` +— never assume a fixed length. | name | effect | use case | trigger | |---|---|---|---| From a987c31f97292a820035792dea9bb3da369040f6 Mon Sep 17 00:00:00 2001 From: Kien Nguyen Date: Thu, 16 Jul 2026 09:51:26 +0700 Subject: [PATCH 4/6] perf(preview): batch the replay reflow, drop duplicated paint paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit replayAnims() forced a synchronous layout per animated element — remove class, read offsetWidth, re-add, per node — after walking every element on the slide to find them. A single forced layout already flushes the whole document, so the reflow is now taken once, between stripping all the classes and restoring them, and [class*="anim-"] pre-filters the walk (the exact prefix test still decides, since that selector also matches e.g. "no-anim-x"). preview/app.js: - Extract paintStage() / repaintAll(); which controls belong to a stage was spelled out three times, in setStage, applyHash and boot. - applyHash no longer shoves state back to its previous value just to get past setStage's early-return; it repaints and remounts from the state readHash has already produced. - Cache the two stage nodes: a ResizeObserver fires continuously while a window is dragged and fitStage was re-querying both on every frame. Design resolution is now DESIGN_W/DESIGN_H rather than 1280/720 inline four times. - THEME_IDS / THEME_BY_ID built once in buildThemeRail. Holding T rebuilt a 36-element id array per repeat and paintStatus linear-scanned for the label. Verified: T walks all 36 themes and wraps exactly once with rail, status bar and iframe in sync; stagger-list still replays (opacity 1 -> 0 -> 1) and keeps its class; slide 4's counter still rewinds and recounts; all four hash transitions (boot, ->anim, ->theme, ->theme again) and both stage tabs still resolve. Co-Authored-By: Claude Opus 4.8 --- assets/runtime.js | 33 +++++++++----- preview/app.js | 110 ++++++++++++++++++++++++++-------------------- 2 files changed, 85 insertions(+), 58 deletions(-) diff --git a/assets/runtime.js b/assets/runtime.js index c9eb1a8..2b797d2 100644 --- a/assets/runtime.js +++ b/assets/runtime.js @@ -107,20 +107,31 @@ } const previewThemeBase = getPreviewThemeBase(); - /* Re-trigger every entry animation on a slide. CSS animations only run once - * per element, so replaying means dropping the class, forcing a reflow to - * flush the style change, then putting it back. Without the reflow the - * browser coalesces remove+add into a no-op and nothing moves. */ + /* Re-trigger every entry animation on a slide. A CSS animation only runs + * once per element, so replaying means dropping the class, forcing a + * reflow to flush the style change, then putting it back — without the + * reflow the browser coalesces remove+add into a no-op and nothing moves. + * + * The reflow is taken ONCE, after every class is stripped and before any + * is restored. Reflowing per element instead would thrash layout once per + * animated node for no benefit: a single forced layout already flushes + * the whole document. */ function replayAnims(slide) { if (!slide) return; - const targets = [slide].concat(Array.from(slide.querySelectorAll('*'))); - targets.forEach((el) => { - const animClasses = Array.from(el.classList).filter(c => c.indexOf('anim-') === 0); - if (!animClasses.length) return; - animClasses.forEach(c => el.classList.remove(c)); - void el.offsetWidth; - animClasses.forEach(c => el.classList.add(c)); + + /* [class*="anim-"] is a cheap pre-filter that skips the vast majority of + * a slide's DOM; the exact prefix test below is what actually decides, + * since the selector also matches e.g. "no-anim-x". */ + const work = []; + [slide].concat(Array.from(slide.querySelectorAll('[class*="anim-"]'))).forEach((el) => { + const cls = Array.from(el.classList).filter(c => c.indexOf('anim-') === 0); + if (cls.length) work.push({ el: el, cls: cls }); }); + + work.forEach(w => w.cls.forEach(c => w.el.classList.remove(c))); + if (work.length) void slide.offsetWidth; + work.forEach(w => w.cls.forEach(c => w.el.classList.add(c))); + /* Canvas FX are driven by fx-runtime.js, which exposes a reinit hook. */ if (typeof window.__hpxReinit === 'function') window.__hpxReinit(slide); /* Not every animation is a class: a .counter slide (demo-deck slide 4) diff --git a/preview/app.js b/preview/app.js index 12c1b68..9148983 100644 --- a/preview/app.js +++ b/preview/app.js @@ -36,9 +36,16 @@ const SLIDE_COUNT = 8; /* examples/demo-deck has 8 slides */ const DECK = '../examples/demo-deck/index.html'; - /* Flat [{id,label,note,...}] across all groups, in rail order — this is what - the T key and any index-based cycling walk through. */ - let THEMES = []; + /* The deck's design resolution. Must match .stage-shadow / #stage in app.css. */ + const DESIGN_W = 1280; + const DESIGN_H = 720; + + /* Flat, in rail order — this is the order the T key cycles through. Built once + in buildThemeRail(); the id list and lookup map exist so the keyboard path + neither rebuilds an array nor linear-scans on every repeat. */ + const THEMES = []; + const THEME_IDS = []; + const THEME_BY_ID = new Map(); const state = { stage: 'theme', /* 'theme' | 'anim' */ @@ -156,11 +163,7 @@ function setStage(stage) { if (state.stage === stage) return; state.stage = stage; - $('#slide-group').hidden = (stage !== 'theme'); - $('#anim-group').hidden = (stage !== 'anim'); - $('#k-nav').textContent = (stage === 'theme') ? 'slide' : 'effect'; - document.querySelectorAll('.seg-btn[data-stage]').forEach((b) => - b.classList.toggle('is-active', b.dataset.stage === stage)); + paintStage(); writeHash(); paintStatus(); mount(); /* the only case that legitimately reloads */ @@ -176,22 +179,23 @@ const prev = { stage: state.stage, theme: state.theme, lang: state.lang, slide: state.slide, anim: state.anim, kind: state.kind }; readHash(); - if (!THEMES.some((t) => t.id === state.theme)) state.theme = prev.theme; + if (!THEME_BY_ID.has(state.theme)) state.theme = prev.theme; if (state.stage !== prev.stage) { - /* setStage does the remount + chrome swap, but it early-returns when the - stage is unchanged — so hand it the old value and let it transition. */ - const target = state.stage; - state.stage = prev.stage; - setStage(target); - } else if (state.theme !== prev.theme) { setTheme(state.theme, { scroll: true }); } - - if (state.lang !== prev.lang) setLang(state.lang); - if (state.stage === 'theme' && state.slide !== prev.slide) setSlide(state.slide); - if (state.stage === 'anim' && (state.anim !== prev.anim || state.kind !== prev.kind)) { - setAnim(state.kind, state.anim); + /* readHash has already moved state, so remount straight from it — + frameURL() carries theme/lang/effect and the load handler pushes the + rest. Routing through setStage() here would mean shoving state back to + its old value first just to get past its early-return. */ + paintStage(); + mount(); + } else { + if (state.theme !== prev.theme) setTheme(state.theme, { scroll: true }); + if (state.lang !== prev.lang) setLang(state.lang); + if (state.slide !== prev.slide) setSlide(state.slide); + if (state.anim !== prev.anim || state.kind !== prev.kind) setAnim(state.kind, state.anim); } - paintThemeRail(); paintStatus(); + repaintAll(); + writeHash(); } /* ----------------------------------------------------------- theme rail */ @@ -209,6 +213,8 @@ group.themes.forEach((t) => { THEMES.push(t); + THEME_IDS.push(t.id); + THEME_BY_ID.set(t.id, t); const b = document.createElement('button'); b.className = 'theme-btn'; b.dataset.id = t.id; @@ -310,8 +316,23 @@ b.classList.toggle('is-active', b.dataset.lang === state.lang)); } + /* Which controls belong to the current stage. Shared by the tab buttons, the + hash handler and boot — all three used to spell this out separately. */ + function paintStage() { + $('#slide-group').hidden = (state.stage !== 'theme'); + $('#anim-group').hidden = (state.stage !== 'anim'); + $('#k-nav').textContent = (state.stage === 'theme') ? 'slide' : 'effect'; + document.querySelectorAll('.seg-btn[data-stage]').forEach((b) => + b.classList.toggle('is-active', b.dataset.stage === state.stage)); + } + + function repaintAll() { + paintStage(); paintThemeRail(); paintDots(); + paintAnimSelect(); paintLang(); paintStatus(); + } + function paintStatus() { - const t = THEMES.find((x) => x.id === state.theme); + const t = THEME_BY_ID.get(state.theme); $('#s-theme').textContent = state.theme; $('#s-note').textContent = t ? '— ' + t.label : ''; $('#s-ctx').textContent = (state.stage === 'theme') @@ -322,19 +343,24 @@ /* --------------------------------------------------------------- scaling */ /* The deck is authored at 1280×720 and must be *shown* at 1280×720, then - scaled as a whole — so what you review is the real layout, not a reflow. */ + scaled as a whole — so what you review is the real layout, not a reflow. + Refs are resolved once: a ResizeObserver fires continuously while a window + is being dragged, and this is the only thing on that path. */ + const stageWrap = $('#stage-wrap'); + const stageShadow = $('#stage-shadow'); + function fitStage() { - const wrap = $('#stage-wrap'); const pad = 44; - const w = wrap.clientWidth - pad; - const h = wrap.clientHeight - pad; - const scale = Math.max(0.1, Math.min(w / 1280, h / 720)); - $('#stage-shadow').style.transform = 'scale(' + scale + ')'; + const scale = Math.max(0.1, Math.min( + (stageWrap.clientWidth - pad) / DESIGN_W, + (stageWrap.clientHeight - pad) / DESIGN_H + )); + stageShadow.style.transform = 'scale(' + scale + ')'; /* Reserve the *scaled* footprint so flex centring has honest numbers to work with; transform alone doesn't affect layout size. */ - $('#stage-shadow').style.margin = - Math.round((720 * scale - 720) / 2) + 'px ' + - Math.round((1280 * scale - 1280) / 2) + 'px'; + stageShadow.style.margin = + Math.round(DESIGN_H * (scale - 1) / 2) + 'px ' + + Math.round(DESIGN_W * (scale - 1) / 2) + 'px'; } /* ------------------------------------------------------------- keyboard */ @@ -361,8 +387,7 @@ } e.preventDefault(); } else if (k === 't') { - const ids = THEMES.map((t) => t.id); - setTheme(cycle(ids, state.theme, e.shiftKey ? -1 : 1), { scroll: true }); + setTheme(cycle(THEME_IDS, state.theme, e.shiftKey ? -1 : 1), { scroll: true }); } else if (k === 'l') { setLang(cycle(LANGS, state.lang, e.shiftKey ? -1 : 1)); } else if (k === 'r') { @@ -382,18 +407,13 @@ buildThemeRail(data.groups); /* A stale or hand-edited hash shouldn't leave the rail with nothing lit. */ - if (!THEMES.some((t) => t.id === state.theme)) state.theme = THEMES[0].id; + if (!THEME_BY_ID.has(state.theme)) state.theme = THEME_IDS[0]; buildDots(); buildAnimSelect(); - $('#slide-group').hidden = (state.stage !== 'theme'); - $('#anim-group').hidden = (state.stage !== 'anim'); - $('#k-nav').textContent = (state.stage === 'theme') ? 'slide' : 'effect'; - document.querySelectorAll('.seg-btn[data-stage]').forEach((b) => { - b.classList.toggle('is-active', b.dataset.stage === state.stage); - b.addEventListener('click', () => setStage(b.dataset.stage)); - }); + document.querySelectorAll('.seg-btn[data-stage]').forEach((b) => + b.addEventListener('click', () => setStage(b.dataset.stage))); document.querySelectorAll('.seg-btn[data-lang]').forEach((b) => b.addEventListener('click', () => setLang(b.dataset.lang))); @@ -401,16 +421,12 @@ $('#replay').addEventListener('click', replay); $('#open-new').addEventListener('click', () => window.open(frameURL(), '_blank', 'noopener')); - paintThemeRail(); - paintDots(); - paintAnimSelect(); - paintLang(); - paintStatus(); + repaintAll(); scrollThemeIntoView(state.theme); mount(); fitStage(); - new ResizeObserver(fitStage).observe($('#stage-wrap')); + new ResizeObserver(fitStage).observe(stageWrap); addEventListener('keydown', onKey); addEventListener('hashchange', applyHash); }) From e2706c9d15782cdeb2bde61fd02dbc5e66fb6c23 Mon Sep 17 00:00:00 2001 From: Kien Nguyen Date: Thu, 16 Jul 2026 10:02:43 +0700 Subject: [PATCH 5/6] fix(fx): wash trails with the theme background, not hard-coded black MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to 60b0bbf, which added the theme-following helpers and then applied them to exactly one of twenty modules. The claim in that commit message — "canvas effects actually follow the theme" — was not true when it landed. Nine modules build motion trails by painting a translucent rect over the canvas each frame instead of clearing it, and every one of them hard-coded that rect to black (or a near-black tint). On a dark deck this is invisible and looks deliberate. On minimal-white it fogs a white slide to *solid black* in about a second, headline and all — a far worse theme violation than the violet glow that did get fixed. I surveyed these last time, saw rgba(0,0,0,.25), and waved it through as "a legitimate trail overlay" without once looking at it on a light theme. - U.fade(el, a) washes with the theme's --bg; U.bg(el) exposes the token. Applied to starfield, firework, galaxy-swirl, sparkle-trail, magnetic-field, matrix-rain, shockwave, data-stream, gradient-blob. - Hard-coded accents replaced where the theme variable was already in scope and simply unused: knowledge-graph, neural-net, chain-react, shockwave, counter-explosion. No literal colors remain in any module except matrix-rain's greens, which are the reference itself and are now commented as deliberate. Also fixed, all found by review of the preview site itself: - "Open in new tab" always opened the deck on its own hard-coded aurora: theme travelled only over postMessage. runtime.js preview mode now reads ?theme= (charset-restricted — it is concatenated into a stylesheet path), so a deck preview URL is self-contained and shareable. - Switching language restarted any running canvas effect, because render() was passed straight to addEventListener and the Event object landed in its new `force` parameter — an Event is truthy, so the guard never held. Wrapped the listener. Language switches now leave the effect running; replay and theme swaps still restart it, as they must. - writeHash ran replaceState on every keypress. Firefox rate-limits the History API to ~200 calls/10s and key autorepeat reaches that in seconds, after which the URL silently stops matching the screen. Coalesced to one write per frame: 40 keypresses now produce 1 call. - SLIDE_COUNT was hard-coded to 8; the count is now read from the deck on load, so a 9th slide can't end up with no dot and unreachable by arrow. - anim-stage's counter is a knowing twin of runtime's animateCounters (the rig deliberately doesn't load runtime.js — a second owner on one slide is the bug class this whole series has been about). Aligned to runtime's contract and documented rather than left silently drifted at 1400ms. - Documented why i18n.js accepts postMessage without an origin check. Verified by pixel-sampling a canvas corner across all 9 washers on a light and a dark theme: the wash converges on #ffffff under minimal-white and #06091c under aurora; zero light themes fog to black, zero dark themes wash to white. All 47 effects mount with one handle and one canvas, no leaks, no JS errors; all 8 slides reachable; dots match the deck's real slide count. Co-Authored-By: Claude Opus 4.8 --- assets/animations/fx/_util.js | 11 ++++ assets/animations/fx/chain-react.js | 7 ++- assets/animations/fx/counter-explosion.js | 4 +- assets/animations/fx/data-stream.js | 2 +- assets/animations/fx/firework.js | 2 +- assets/animations/fx/galaxy-swirl.js | 2 +- assets/animations/fx/gradient-blob.js | 2 +- assets/animations/fx/knowledge-graph.js | 2 +- assets/animations/fx/magnetic-field.js | 2 +- assets/animations/fx/matrix-rain.js | 7 ++- assets/animations/fx/neural-net.js | 3 +- assets/animations/fx/shockwave.js | 9 +-- assets/animations/fx/sparkle-trail.js | 2 +- assets/animations/fx/starfield.js | 2 +- assets/i18n.js | 8 ++- assets/runtime.js | 29 +++++++--- preview/anim-stage.html | 49 +++++++++++++---- preview/app.js | 67 +++++++++++++++++------ references/animations.md | 22 +++++++- 19 files changed, 172 insertions(+), 60 deletions(-) diff --git a/assets/animations/fx/_util.js b/assets/animations/fx/_util.js index 3cebbe7..dafa4b3 100644 --- a/assets/animations/fx/_util.js +++ b/assets/animations/fx/_util.js @@ -12,6 +12,17 @@ U.accent2 = (el, fb) => U.css(el, '--accent-2', fb || '#22d3ee'); U.accent3 = (el, fb) => U.css(el, '--accent-3', fb || '#f0abfc'); U.text = (el, fb) => U.css(el, '--text-1', fb || '#eaeaf2'); + U.bg = (el, fb) => U.css(el, '--bg', fb || '#0b0c10'); + + /* The per-frame wash that turns moving particles into trails: instead of + * clearing the canvas, paint the page's own background over it at low alpha + * so older frames sink into the slide. + * + * It has to be the *theme's* background. A hard-coded black wash is invisible + * on a dark deck and therefore looks correct — but on minimal-white it fogs a + * white slide to solid black within a second or two, taking the headline with + * it. Same bug, opposite sign. */ + U.fade = (el, a) => U.alpha(U.bg(el, '#0b0c10'), a); /* Decorative color pool for particles, nodes, sparks and rings. * diff --git a/assets/animations/fx/chain-react.js b/assets/animations/fx/chain-react.js index 00a03d9..8331ee6 100644 --- a/assets/animations/fx/chain-react.js +++ b/assets/animations/fx/chain-react.js @@ -4,6 +4,7 @@ const U = window.HPX._u; const k = U.canvas(el), ctx = k.ctx; const ac = U.accent(el,'#7c5cff'), ac2 = U.accent2(el,'#22d3ee'); + const tx = U.text(el,'#e7e7ef'); const N = 8; const stop = U.loop((t) => { ctx.clearRect(0,0,k.w,k.h); @@ -20,18 +21,18 @@ const r = 18 + pulse*18; // glow const g = ctx.createRadialGradient(x,cy,0,x,cy,r*2); - g.addColorStop(0, `rgba(124,92,255,${0.4*pulse})`); + g.addColorStop(0, U.alpha(ac, 0.4*pulse)); g.addColorStop(1, 'rgba(0,0,0,0)'); ctx.fillStyle = g; ctx.fillRect(x-r*2, cy-r*2, r*4, r*4); // circle ctx.fillStyle = pulse>0.1 ? ac2 : ac; ctx.beginPath(); ctx.arc(x,cy,r,0,Math.PI*2); ctx.fill(); - ctx.strokeStyle='rgba(255,255,255,0.4)'; ctx.lineWidth=2; + ctx.strokeStyle=U.alpha(tx, 0.4); ctx.lineWidth=2; ctx.stroke(); // connectors if (i { if (k.h!==lh){ init(); lh=k.h; } - ctx.fillStyle = 'rgba(5,8,14,0.35)'; + ctx.fillStyle = U.fade(el, 0.35); ctx.fillRect(0,0,k.w,k.h); ctx.font = '13px ui-monospace,Menlo,monospace'; for (let i=0;i { - ctx.fillStyle = 'rgba(0,0,0,0.18)'; + ctx.fillStyle = U.fade(el, 0.18); ctx.fillRect(0,0,k.w,k.h); if (t - last > 0.7) { launch(); last = t; } const dt = 1/60; diff --git a/assets/animations/fx/galaxy-swirl.js b/assets/animations/fx/galaxy-swirl.js index f1df9e5..e937d7e 100644 --- a/assets/animations/fx/galaxy-swirl.js +++ b/assets/animations/fx/galaxy-swirl.js @@ -15,7 +15,7 @@ s: U.rand(0.8, 2.2) }; }); const stop = U.loop((t) => { - ctx.fillStyle = 'rgba(0,0,0,0.15)'; + ctx.fillStyle = U.fade(el, 0.15); ctx.fillRect(0,0,k.w,k.h); const cx=k.w/2, cy=k.h/2; for (const p of parts){ diff --git a/assets/animations/fx/gradient-blob.js b/assets/animations/fx/gradient-blob.js index 8887252..5956d2b 100644 --- a/assets/animations/fx/gradient-blob.js +++ b/assets/animations/fx/gradient-blob.js @@ -16,7 +16,7 @@ return m.map(x=>parseInt(x,16)); }; const stop = U.loop((t) => { - ctx.fillStyle = 'rgba(10,12,22,0.2)'; + ctx.fillStyle = U.fade(el, 0.2); ctx.fillRect(0,0,k.w,k.h); ctx.globalCompositeOperation = 'lighter'; for (const b of blobs){ diff --git a/assets/animations/fx/knowledge-graph.js b/assets/animations/fx/knowledge-graph.js index 2a184b1..224d7a6 100644 --- a/assets/animations/fx/knowledge-graph.js +++ b/assets/animations/fx/knowledge-graph.js @@ -51,7 +51,7 @@ n.x += n.vx; n.y += n.vy; } ctx.clearRect(0,0,k.w,k.h); - ctx.strokeStyle = 'rgba(180,180,220,0.25)'; ctx.lineWidth=1; + ctx.strokeStyle = U.alpha(tx, 0.25); ctx.lineWidth=1; for (const [i,j] of edges){ const a=nodes[i], b=nodes[j]; ctx.beginPath(); ctx.moveTo(a.x,a.y); ctx.lineTo(b.x,b.y); ctx.stroke(); diff --git a/assets/animations/fx/magnetic-field.js b/assets/animations/fx/magnetic-field.js index da2aa3f..9c83488 100644 --- a/assets/animations/fx/magnetic-field.js +++ b/assets/animations/fx/magnetic-field.js @@ -14,7 +14,7 @@ trail: [] })); const stop = U.loop((t) => { - ctx.fillStyle = 'rgba(0,0,0,0.08)'; + ctx.fillStyle = U.fade(el, 0.08); ctx.fillRect(0,0,k.w,k.h); for (const p of parts){ const x = ((t*80 + p.phase*50) % (k.w+100)) - 50; diff --git a/assets/animations/fx/matrix-rain.js b/assets/animations/fx/matrix-rain.js index c7bb93e..512e9b9 100644 --- a/assets/animations/fx/matrix-rain.js +++ b/assets/animations/fx/matrix-rain.js @@ -14,12 +14,17 @@ let lw = k.w, lh = k.h; const stop = U.loop(() => { if (k.w!==lw || k.h!==lh){ init(); lw=k.w; lh=k.h; } - ctx.fillStyle = 'rgba(0,0,0,0.08)'; + ctx.fillStyle = U.fade(el, 0.08); ctx.fillRect(0,0,k.w,k.h); ctx.font = fs+'px monospace'; for (let i=0;i { @@ -29,7 +30,7 @@ const stop = U.loop((t) => { if (k.w!==lw||k.h!==lh){ layout(); lw=k.w; lh=k.h; } ctx.clearRect(0,0,k.w,k.h); - ctx.strokeStyle = 'rgba(160,160,200,0.22)'; ctx.lineWidth=1; + ctx.strokeStyle = U.alpha(tx, 0.22); ctx.lineWidth=1; for (const [i,j] of edges){ const a=nodes[i], b=nodes[j]; ctx.beginPath(); ctx.moveTo(a.x,a.y); ctx.lineTo(b.x,b.y); ctx.stroke(); diff --git a/assets/animations/fx/shockwave.js b/assets/animations/fx/shockwave.js index 436a5cf..75528c0 100644 --- a/assets/animations/fx/shockwave.js +++ b/assets/animations/fx/shockwave.js @@ -4,10 +4,11 @@ const U = window.HPX._u; const k = U.canvas(el), ctx = k.ctx; const ac = U.accent(el,'#7c5cff'), ac2 = U.accent2(el,'#22d3ee'); + const tx = U.text(el,'#e7e7ef'); let waves = []; let last = -1; const stop = U.loop((t) => { - ctx.fillStyle = 'rgba(0,0,0,0.12)'; + ctx.fillStyle = U.fade(el, 0.12); ctx.fillRect(0,0,k.w,k.h); if (t - last > 0.6){ last = t; waves.push({t:0}); } const cx=k.w/2, cy=k.h/2; @@ -21,7 +22,7 @@ ctx.globalAlpha = alpha; ctx.lineWidth = 3 + (1-w.t)*3; ctx.beginPath(); ctx.arc(cx,cy,r,0,Math.PI*2); ctx.stroke(); - ctx.strokeStyle = '#fff'; + ctx.strokeStyle = tx; ctx.lineWidth = 1; ctx.globalAlpha = alpha*0.4; ctx.beginPath(); ctx.arc(cx,cy,r*0.92,0,Math.PI*2); ctx.stroke(); @@ -29,8 +30,8 @@ ctx.globalAlpha = 1; // core const g = ctx.createRadialGradient(cx,cy,0,cx,cy,40); - g.addColorStop(0,'rgba(255,255,255,0.9)'); - g.addColorStop(1,'rgba(124,92,255,0)'); + g.addColorStop(0, U.alpha(tx, 0.9)); + g.addColorStop(1, U.alpha(ac, 0)); ctx.fillStyle = g; ctx.beginPath(); ctx.arc(cx,cy,40,0,Math.PI*2); ctx.fill(); }); diff --git a/assets/animations/fx/sparkle-trail.js b/assets/animations/fx/sparkle-trail.js index 3509ffe..85c6035 100644 --- a/assets/animations/fx/sparkle-trail.js +++ b/assets/animations/fx/sparkle-trail.js @@ -25,7 +25,7 @@ el.addEventListener('pointermove', onMove); el.addEventListener('pointerenter', onAny); const stop = U.loop(() => { - ctx.fillStyle = 'rgba(0,0,0,0.15)'; + ctx.fillStyle = U.fade(el, 0.15); ctx.fillRect(0,0,k.w,k.h); if (auto){ autoT += 0.04; diff --git a/assets/animations/fx/starfield.js b/assets/animations/fx/starfield.js index 168835d..2310f5d 100644 --- a/assets/animations/fx/starfield.js +++ b/assets/animations/fx/starfield.js @@ -9,7 +9,7 @@ x: U.rand(-1,1), y: U.rand(-1,1), z: Math.random() })); const stop = U.loop(() => { - ctx.fillStyle = 'rgba(0,0,0,0.25)'; + ctx.fillStyle = U.fade(el, 0.25); ctx.fillRect(0,0,k.w,k.h); const cx = k.w/2, cy = k.h/2; for (const s of stars){ diff --git a/assets/i18n.js b/assets/i18n.js index 59c0ce1..d3f4e1a 100644 --- a/assets/i18n.js +++ b/assets/i18n.js @@ -131,7 +131,13 @@ else announce(); /* The preview site drives language from a parent window over the same - * postMessage channel runtime.js already uses for theme/slide control. */ + * postMessage channel runtime.js already uses for theme/slide control. + * + * No e.origin check, deliberately: the only authority this grants a sender + * is "pick one of the deck's own translation files", the deck is a static + * page with no session or private data, and decks are opened from disk + * (origin "null") as often as from a server, which an origin allowlist + * would break. Do add one if you ever put privileged state behind this. */ window.addEventListener('message', function (e) { if (e.data && e.data.type === 'preview-lang' && e.data.lang) setLang(e.data.lang); }); diff --git a/assets/runtime.js b/assets/runtime.js index 2b797d2..3dd687a 100644 --- a/assets/runtime.js +++ b/assets/runtime.js @@ -107,6 +107,25 @@ } const previewThemeBase = getPreviewThemeBase(); + function applyPreviewTheme(name) { + let link = document.getElementById('theme-link'); + if (!link) { + link = document.createElement('link'); + link.rel = 'stylesheet'; + link.id = 'theme-link'; + document.head.appendChild(link); + } + link.href = previewThemeBase + name + '.css'; + document.documentElement.setAttribute('data-theme', name); + } + + /* ?theme= makes a preview URL self-contained, so it can be opened or + * shared on its own rather than only ever being driven by postMessage + * from a parent that already knows the theme. Restricted to a plain + * theme-name charset: this value is concatenated into a stylesheet path. */ + const urlTheme = /[?&]theme=([A-Za-z0-9_-]+)/.exec(location.search || ''); + if (urlTheme) applyPreviewTheme(urlTheme[1]); + /* Re-trigger every entry animation on a slide. A CSS animation only runs * once per element, so replaying means dropping the class, forcing a * reflow to flush the style change, then putting it back — without the @@ -156,15 +175,7 @@ } else if (e.data.type === 'preview-replay') { replayAnims(document.querySelector('.slide.is-active')); } else if (e.data.type === 'preview-theme' && e.data.name) { - let link = document.getElementById('theme-link'); - if (!link) { - link = document.createElement('link'); - link.rel = 'stylesheet'; - link.id = 'theme-link'; - document.head.appendChild(link); - } - link.href = previewThemeBase + e.data.name + '.css'; - document.documentElement.setAttribute('data-theme', e.data.name); + applyPreviewTheme(e.data.name); } }); /* Signal to parent that preview iframe is ready */ diff --git a/preview/anim-stage.html b/preview/anim-stage.html index c82a57d..ec364ea 100644 --- a/preview/anim-stage.html +++ b/preview/anim-stage.html @@ -189,17 +189,25 @@ T(k) + '
').join(''); } - /* --- counter-up is JS-driven; animations.css only ships the marker class. --- */ + /* counter-up is JS-driven; animations.css only ships the .counter marker. + * + * This is a deliberate ~10-line twin of animateCounters() in runtime.js, kept + * because this rig does not load runtime.js at all (no navigation, no + * keyboard, no chrome to own) and pulling it in just for this would put a + * second owner on the one slide — the exact shape of bug that made canvas FX + * paint stale themes. It mirrors runtime's contract on purpose: same 1200ms + * default, same data-dur override, same ease-out cubic. If that contract + * changes, change it here too. */ function runCounter() { const el = document.getElementById('ctr'); if (!el) return; - const to = parseInt(el.getAttribute('data-to'), 10) || 0; - const dur = 1400; + const to = parseFloat(el.getAttribute('data-to')) || 0; + const dur = parseInt(el.getAttribute('data-dur') || '1200', 10); const t0 = performance.now(); (function tick(t) { const p = Math.min(1, (t - t0) / dur); - /* ease-out cubic — fast start, soft landing */ - el.textContent = Math.round(to * (1 - Math.pow(1 - p, 3))); + const v = to * (1 - Math.pow(1 - p, 3)); /* ease-out cubic */ + el.textContent = (to % 1 === 0) ? Math.round(v) : v.toFixed(1); if (p < 1) requestAnimationFrame(tick); })(t0); } @@ -221,8 +229,18 @@ * work. This page must NOT call HPX[name] itself: fx-runtime already owns * every [data-fx] host — it inits them on slide activation and re-inits them * on a theme swap — so a second creator here would leave an untracked canvas - * behind on every change, and the stale one paints the previous theme. */ - function setFx(name) { + * behind on every change, and the stale one paints the previous theme. + * + * Tearing an effect down and reseeding it from frame zero is only correct + * when the effect actually changed. render() also runs on a language switch + * (and twice on a cold load, since i18n announces at DOMContentLoaded), and + * restarting a running firework because someone pressed L is just a glitch — + * no fx module reads i18n. `force` is for an explicit replay. */ + let mountedFx = null; + + function setFx(name, force) { + if (name === mountedFx && !force) return; + mountedFx = name; if (window.__hpxStop) window.__hpxStop(slideEl); /* attribute still set, so it's found */ if (!name) { fxHost.removeAttribute('data-fx'); return; } fxHost.setAttribute('data-fx', name); @@ -238,12 +256,15 @@ const stage = document.getElementById('stage'); const label = document.getElementById('label'); - function render() { + /* `force` restarts a canvas effect even if it is already the mounted one — + only an explicit replay wants that. A CSS animation needs no such flag: + rebuilding the subject markup below hands it fresh elements either way. */ + function render(force) { if (state.fx) { /* In FX mode the canvas IS the subject; keep a neutral caption over it. */ stage.innerHTML = '

' + T('stage.h1') + '

'; label.textContent = 'data-fx="' + state.fx + '"'; - setFx(state.fx); + setFx(state.fx, force); return; } setFx(null); @@ -271,11 +292,15 @@ if (d.type === 'preview-theme' && d.name) setTheme(d.name); else if (d.type === 'preview-anim') { state.fx = null; state.anim = d.name; render(); } else if (d.type === 'preview-fx') { state.fx = d.name; render(); } - else if (d.type === 'preview-replay') render(); + else if (d.type === 'preview-replay') render(true); }); - /* A language change replaces every string on the stage, so just rebuild. */ - document.addEventListener('hpx:i18n', render); + /* A language change replaces every string on the stage, so just rebuild. + Wrapped, not passed by reference: as a listener, render would receive the + Event as its `force` argument, and an Event is truthy — which would restart + the running canvas effect on every language switch, the exact thing `force` + exists to prevent. */ + document.addEventListener('hpx:i18n', () => render()); render(); try { parent.postMessage({ type: 'preview-ready' }, '*'); } catch (e) {} diff --git a/preview/app.js b/preview/app.js index 9148983..e2b856b 100644 --- a/preview/app.js +++ b/preview/app.js @@ -33,9 +33,13 @@ ]; const LANGS = ['zh', 'en', 'vi']; - const SLIDE_COUNT = 8; /* examples/demo-deck has 8 slides */ const DECK = '../examples/demo-deck/index.html'; + /* Starting guess only — the real count is read out of the deck on first load, + so adding a slide to demo-deck doesn't silently leave it with no dot and + unreachable by arrow. */ + let SLIDE_COUNT = 8; + /* The deck's design resolution. Must match .stage-shadow / #stage in app.css. */ const DESIGN_W = 1280; const DESIGN_H = 720; @@ -79,16 +83,25 @@ else if (q.get('anim')) { state.kind = 'css'; state.anim = q.get('anim'); } } + /* replaceState rather than location.hash, so holding a key doesn't stack up a + history entry per repeat — and coalesced to one write per frame, because + Firefox rate-limits the History API (~200 calls / 10s) and key autorepeat + blows through that in seconds, after which it starts dropping writes and + the URL silently stops matching the screen. */ + let hashPending = 0; + function writeHash() { - const q = new URLSearchParams(); - q.set('stage', state.stage); - q.set('theme', state.theme); - q.set('lang', state.lang); - if (state.stage === 'theme') q.set('slide', String(state.slide)); - else q.set(state.kind === 'fx' ? 'fx' : 'anim', state.anim); - /* replaceState, not location.hash — this fires many times a second while - arrowing through themes and must not stack up browser history entries. */ - history.replaceState(null, '', '#' + q.toString()); + if (hashPending) return; + hashPending = requestAnimationFrame(() => { + hashPending = 0; + const q = new URLSearchParams(); + q.set('stage', state.stage); + q.set('theme', state.theme); + q.set('lang', state.lang); + if (state.stage === 'theme') q.set('slide', String(state.slide)); + else q.set(state.kind === 'fx' ? 'fx' : 'anim', state.anim); + history.replaceState(null, '', '#' + q.toString()); + }); } /* ------------------------------------------------------- iframe plumbing */ @@ -100,9 +113,12 @@ function frameURL() { if (state.stage === 'theme') { - /* runtime.js reads ?preview=N (1-based) and ?lang is read by i18n.js. - Both are only bootstrap values; later changes go over postMessage. */ - return DECK + '?preview=' + state.slide + '&lang=' + state.lang; + /* runtime.js reads ?preview=N (1-based) and ?theme=; ?lang is read by + i18n.js. All three are bootstrap values only — later changes go over + postMessage — but carrying them means "open in a new tab" hands over + exactly what you were looking at instead of the deck's own default. */ + return DECK + '?preview=' + state.slide + '&theme=' + state.theme + + '&lang=' + state.lang; } const key = state.kind === 'fx' ? 'fx' : 'anim'; return 'anim-stage.html?theme=' + state.theme + '&lang=' + state.lang + @@ -118,13 +134,29 @@ stageEl.addEventListener('load', function () { frameReady = true; - /* The URL already carried lang (and, on the anim stage, theme+effect), but - the deck stage boots on its own hard-coded theme — so push the full state - once the runtime inside is listening. */ + /* frameURL() already carries theme/lang/effect, so the iframe boots correct + on its own; re-sending theme here costs nothing and keeps the deck stage + right even if a deck ignores ?theme=. */ send({ type: 'preview-theme', name: state.theme }); - if (state.stage === 'theme') send({ type: 'preview-goto', idx: state.slide - 1 }); + if (state.stage === 'theme') { + syncSlideCount(); + send({ type: 'preview-goto', idx: state.slide - 1 }); + } }); + /* Ask the deck how many slides it actually has. Same-origin, so this is just + a DOM read. */ + function syncSlideCount() { + let n = 0; + try { n = stageEl.contentDocument.querySelectorAll('.deck .slide').length; } catch (e) { return; } + if (!n || n === SLIDE_COUNT) return; + SLIDE_COUNT = n; + buildDots(); + if (state.slide > SLIDE_COUNT) state.slide = SLIDE_COUNT; + paintDots(); + paintStatus(); + } + /* --------------------------------------------------------------- actions */ function setTheme(id, opts) { @@ -273,6 +305,7 @@ function buildDots() { const wrap = $('#slide-dots'); + wrap.textContent = ''; /* rebuilt whenever the deck reports a new count */ for (let i = 1; i <= SLIDE_COUNT; i++) { const b = document.createElement('button'); b.className = 'dot'; diff --git a/references/animations.md b/references/animations.md index b228c34..a227d95 100644 --- a/references/animations.md +++ b/references/animations.md @@ -121,9 +121,25 @@ and fights restrained themes. On `minimal-white`, whose accents are three greys, the FX come out greyscale — that is correct, not broken. Effects re-initialise automatically when the theme changes (a canvas samples its -colors once at init, so unlike CSS it cannot re-skin itself). If you write your -own FX, read the pool with `U.palette(el)` and index it as `pal[i % pal.length]` -— never assume a fixed length. +colors once at init, so unlike CSS it cannot re-skin itself). + +Writing your own FX — the helpers on `window.HPX._u`: + +| helper | gives you | +|---|---| +| `U.palette(el)` | the decorative pool. Index it `pal[i % pal.length]` — never assume a length | +| `U.accent/accent2/accent3(el)` | one accent each | +| `U.text(el)` / `U.bg(el)` | `--text-1` / `--bg` | +| `U.alpha(color, a)` | re-alpha any theme color, for glows and strokes | +| `U.fade(el, a)` | the per-frame trail wash — **use this, never `rgba(0,0,0,a)`** | + +That last one matters. Trails are made by painting over the canvas each frame +instead of clearing it, and washing with hard-coded black looks right on a dark +deck while fogging `minimal-white` to solid black within a second. `U.fade` uses +the theme's own `--bg`. + +The one deliberate exception is `matrix-rain`, whose green is the reference +rather than a palette choice. | name | effect | use case | trigger | |---|---|---|---| From bf5a7beecf7af85776cc74db084e0b9bad2bc3a0 Mon Sep 17 00:00:00 2001 From: Kien Nguyen Date: Thu, 16 Jul 2026 10:03:09 +0700 Subject: [PATCH 6/6] docs: point the preview links at this repository MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The preview site was developed on a fork, so the README/SKILL links and the GitHub links on the landing and preview pages named that fork. On this branch they name this repository instead — the URL they describe only exists once this is merged and Pages is switched on here. Co-Authored-By: Claude Opus 4.8 --- README.md | 2 +- README.zh-CN.md | 2 +- SKILL.md | 4 ++-- index.html | 2 +- preview/index.html | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 763e235..f04c4ed 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ **License:** MIT **中文文档:** [README.zh-CN.md](README.zh-CN.md) -### 🔍 [Live preview → kienbb.github.io/html-ppt-skill](https://kienbb.github.io/html-ppt-skill/preview/) +### 🔍 [Live preview → lewislulu.github.io/html-ppt-skill](https://lewislulu.github.io/html-ppt-skill/preview/) Browse every theme and animation on a real slide, in the browser, with no install. Pick any of the **36 themes** and the slide re-skins instantly — no diff --git a/README.zh-CN.md b/README.zh-CN.md index 0e0f2d6..07f9ed4 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -9,7 +9,7 @@ **协议:** MIT **English docs:** [README.md](README.md) -### 🔍 [在线预览 → kienbb.github.io/html-ppt-skill](https://kienbb.github.io/html-ppt-skill/preview/) +### 🔍 [在线预览 → lewislulu.github.io/html-ppt-skill](https://lewislulu.github.io/html-ppt-skill/preview/) 不用安装,直接在浏览器里用真实幻灯片浏览全部主题和动效。**36 套主题**任意点选, 幻灯片**当场换肤、不刷新**——因为一套主题无非就是一组 `:root` 变量。同一页还能在 diff --git a/SKILL.md b/SKILL.md index 808ed57..09a1e83 100644 --- a/SKILL.md +++ b/SKILL.md @@ -29,7 +29,7 @@ One command, no build. Pure static HTML/CSS/JS with only CDN webfonts. - **Showcase decks** for themes / layouts / animations / full-decks gallery - **Live preview site** (`preview/`) — every theme and animation on one real slide, switchable without a reload, in 中文 / English / Tiếng Việt. Published - at . Point users here when + at . Point users here when they ask "which theme should I use?" instead of describing themes in prose. - **Optional i18n layer** (`assets/i18n.js`) — mark text with `data-i18n="key"`, keep the default language inline, and ship other languages as JSON. See @@ -78,7 +78,7 @@ tasteful default and confirm. 2. **Style / theme.** Which of the 36 themes fits? If unsure, recommend 2-3 candidates based on tone — and link the user to the live preview so they can judge for themselves rather than take your word for it: - . A direct link can carry + . A direct link can carry the exact theme, slide and language, e.g. `preview/#stage=theme&theme=tokyo-night&lang=en&slide=1`. - Business / investor pitch → `pitch-deck-vc`, `corporate-clean`, `swiss-grid` diff --git a/index.html b/index.html index 2dff1d3..5111532 100644 --- a/index.html +++ b/index.html @@ -87,7 +87,7 @@

Demo deck

- GitHub + GitHub MIT Skill for Claude Code — npx skills add
diff --git a/preview/index.html b/preview/index.html index 38c3445..a7cd4ee 100644 --- a/preview/index.html +++ b/preview/index.html @@ -23,7 +23,7 @@