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. + ![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..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** 之间切换,在你决定用哪套主题之前,先看清它在 +表意文字和拉丁文字下分别是什么样。 + ![html-ppt 封面 · 实时预览](docs/readme/hero.gif) > 一行命令装好 **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。
-

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..5111532 --- /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..ec364ea --- /dev/null +++ b/preview/anim-stage.html @@ -0,0 +1,309 @@ + + + + + +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..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 +
+
+ + + +
+ +
+ 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 | |---|---|---|---|