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..f04c4ed 100644
--- a/README.md
+++ b/README.md
@@ -10,6 +10,14 @@
**License:** MIT
**中文文档:** [README.zh-CN.md](README.zh-CN.md)
+### 🔍 [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
+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.
+

> 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..07f9ed4 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -9,6 +9,13 @@
**协议:** MIT
**English docs:** [README.md](README.md)
+### 🔍 [在线预览 → lewislulu.github.io/html-ppt-skill](https://lewislulu.github.io/html-ppt-skill/preview/)
+
+不用安装,直接在浏览器里用真实幻灯片浏览全部主题和动效。**36 套主题**任意点选,
+幻灯片**当场换肤、不刷新**——因为一套主题无非就是一组 `:root` 变量。同一页还能在
+**中文 / English / Tiếng Việt** 之间切换,在你决定用哪套主题之前,先看清它在
+表意文字和拉丁文字下分别是什么样。
+

> 一行命令装好 **36 主题 × 20 Canvas FX × 31 布局 × 15 完整 deck + 演讲者模式**。
diff --git a/SKILL.md b/SKILL.md
index 0250b9a..09a1e83 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/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..dafa4b3 100644
--- a/assets/animations/fx/_util.js
+++ b/assets/animations/fx/_util.js
@@ -10,16 +10,73 @@
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');
+ 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.
+ *
+ * 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/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/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 {
- 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
new file mode 100644
index 0000000..d3f4e1a
--- /dev/null
+++ b/assets/i18n.js
@@ -0,0 +1,160 @@
+/* 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.
+ *
+ * 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);
+ });
+ }
+
+ 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..3dd687a 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'; });
@@ -68,7 +91,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,24 +107,75 @@
}
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 */
+ 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
+ * 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;
+
+ /* [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)
+ * 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
+ * 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);
- } 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);
+ if (n >= 0 && n < slides.length) {
+ showSlide(n);
+ replayAnims(slides[n]);
}
- link.href = previewThemeBase + e.data.name + '.css';
- document.documentElement.setAttribute('data-theme', e.data.name);
+ } else if (e.data.type === 'preview-replay') {
+ replayAnims(document.querySelector('.slide.is-active'));
+ } else if (e.data.type === 'preview-theme' && e.data.name) {
+ applyPreviewTheme(e.data.name);
}
});
/* Signal to parent that preview iframe is ready */
@@ -248,19 +326,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) {
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。
+
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..5111532
--- /dev/null
+++ b/index.html
@@ -0,0 +1,97 @@
+
+
+
+
+
+html-ppt — HTML PPT Studio
+
+
+
+
+
+
+
html-ppt
+
+
HTML PPT Studio
+
Author professional presentations as plain static HTML. One theme file is one look, one layout file is one page type, one class is one entry animation.
+
36 themes · 31 layouts · 27 CSS animations · 20 canvas FX · 15 full decks · zero build
+
+
+
+
+
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..e2b856b
--- /dev/null
+++ b/preview/app.js
@@ -0,0 +1,480 @@
+/* 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 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;
+
+ /* 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' */
+ 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'); }
+ }
+
+ /* 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() {
+ 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 */
+
+ 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 ?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 +
+ '&' + 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;
+ /* 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') {
+ 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) {
+ 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;
+ paintStage();
+ 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 (!THEME_BY_ID.has(state.theme)) state.theme = prev.theme;
+
+ if (state.stage !== prev.stage) {
+ /* 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);
+ }
+ repaintAll();
+ writeHash();
+ }
+
+ /* ----------------------------------------------------------- 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);
+ 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;
+ 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');
+ 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';
+ 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));
+ }
+
+ /* 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 = THEME_BY_ID.get(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.
+ 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 pad = 44;
+ 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. */
+ stageShadow.style.margin =
+ Math.round(DESIGN_H * (scale - 1) / 2) + 'px ' +
+ Math.round(DESIGN_W * (scale - 1) / 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') {
+ 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') {
+ 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 (!THEME_BY_ID.has(state.theme)) state.theme = THEME_IDS[0];
+
+ buildDots();
+ buildAnimSelect();
+
+ 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)));
+
+ $('#theme-search').addEventListener('input', (e) => filterThemes(e.target.value));
+ $('#replay').addEventListener('click', replay);
+ $('#open-new').addEventListener('click', () => window.open(frameURL(), '_blank', 'noopener'));
+
+ repaintAll();
+ scrollThemeIntoView(state.theme);
+
+ mount();
+ fitStage();
+ new ResizeObserver(fitStage).observe(stageWrap);
+ 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..a7cd4ee
--- /dev/null
+++ b/preview/index.html
@@ -0,0 +1,84 @@
+
+
+
+
+
+html-ppt · Theme & Animation Preview
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Slide
+
+
+
+
+ Effect
+
+
+
+
+
+
+ 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" }
+ ]
+ }
+ ]
+}
diff --git a/references/animations.md b/references/animations.md
index 1c1295b..a227d95 100644
--- a/references/animations.md
+++ b/references/animations.md
@@ -111,8 +111,35 @@ 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).
+
+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 |
|---|---|---|---|