docs: salvage focused stale PR contributions

- add Vite and Redis pattern skills from closed stale PRs

- add frontend-slides support assets

- port skill-comply runner fixes and LLM prompt/provider regressions

- harden agent frontmatter validation and sync catalog counts
This commit is contained in:
Affaan Mustafa
2026-05-11 05:18:18 -04:00
committed by Affaan Mustafa
parent d8f879e671
commit b39d2244cf
28 changed files with 2653 additions and 59 deletions

View File

@@ -0,0 +1,122 @@
# Animation Patterns Reference
Use this reference when generating presentations. Match animations to the intended feeling.
## Effect-to-Feeling Guide
| Feeling | Animations | Visual Cues |
|---------|-----------|-------------|
| **Dramatic / Cinematic** | Slow fade-ins (1-1.5s), large-scale transitions (0.9 to 1), parallax scrolling | Dark backgrounds, spotlight effects, full-bleed images |
| **Techy / Futuristic** | Neon glow (box-shadow), glitch/scramble text, grid reveals | Particle systems (canvas), grid patterns, monospace accents, cyan/magenta/electric blue |
| **Playful / Friendly** | Bouncy easing (spring physics), floating/bobbing | Rounded corners, pastel/bright colors, hand-drawn elements |
| **Professional / Corporate** | Subtle fast animations (200-300ms), clean slides | Navy/slate/charcoal, precise spacing, data visualization focus |
| **Calm / Minimal** | Very slow subtle motion, gentle fades | High whitespace, muted palette, serif typography, generous padding |
| **Editorial / Magazine** | Staggered text reveals, image-text interplay | Strong type hierarchy, pull quotes, grid-breaking layouts, serif headlines + sans body |
## Entrance Animations
```css
/* Fade + Slide Up (most versatile) */
.reveal {
opacity: 0;
transform: translateY(30px);
transition: opacity 0.6s var(--ease-out-expo),
transform 0.6s var(--ease-out-expo);
}
.visible .reveal {
opacity: 1;
transform: translateY(0);
}
/* Scale In */
.reveal-scale {
opacity: 0;
transform: scale(0.9);
transition: opacity 0.6s, transform 0.6s var(--ease-out-expo);
}
.visible .reveal-scale {
opacity: 1;
transform: scale(1);
}
/* Slide from Left */
.reveal-left {
opacity: 0;
transform: translateX(-50px);
transition: opacity 0.6s, transform 0.6s var(--ease-out-expo);
}
.visible .reveal-left {
opacity: 1;
transform: translateX(0);
}
/* Blur In */
.reveal-blur {
opacity: 0;
filter: blur(10px);
transition: opacity 0.8s, filter 0.8s var(--ease-out-expo);
}
.visible .reveal-blur {
opacity: 1;
filter: blur(0);
}
```
## Background Effects
```css
/* Gradient Mesh — layered radial gradients for depth */
.gradient-bg {
background:
radial-gradient(ellipse at 20% 80%, rgba(120, 0, 255, 0.3) 0%, transparent 50%),
radial-gradient(ellipse at 80% 20%, rgba(0, 255, 200, 0.2) 0%, transparent 50%),
var(--bg-primary);
}
/* Noise Texture — inline SVG for grain */
.noise-bg {
background-image: url("data:image/svg+xml,..."); /* Inline SVG noise */
}
/* Grid Pattern — subtle structural lines */
.grid-bg {
background-image:
linear-gradient(rgba(255,255,255,0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,0.03) 1px, transparent 1px);
background-size: 50px 50px;
}
```
## Interactive Effects
```javascript
/* 3D Tilt on Hover — adds depth to cards/panels */
class TiltEffect {
constructor(element) {
this.element = element;
this.element.style.transformStyle = 'preserve-3d';
this.element.style.perspective = '1000px';
this.element.addEventListener('mousemove', (e) => {
const rect = this.element.getBoundingClientRect();
const x = (e.clientX - rect.left) / rect.width - 0.5;
const y = (e.clientY - rect.top) / rect.height - 0.5;
this.element.style.transform = `rotateY(${x * 10}deg) rotateX(${-y * 10}deg)`;
});
this.element.addEventListener('mouseleave', () => {
this.element.style.transform = 'rotateY(0) rotateX(0)';
});
}
}
```
## Troubleshooting
| Problem | Fix |
|---------|-----|
| Fonts not loading | Check Fontshare/Google Fonts URL; ensure font names match in CSS |
| Animations not triggering | Verify Intersection Observer is running; check `.visible` class is being added |
| Scroll snap not working | Ensure `scroll-snap-type: y mandatory` on html; each slide needs `scroll-snap-align: start` |
| Mobile issues | Disable heavy effects at 768px breakpoint; test touch events; reduce particle count |
| Performance issues | Use `will-change` sparingly; prefer `transform`/`opacity` animations; throttle scroll handlers |

View File

@@ -0,0 +1,419 @@
# HTML Presentation Template
Reference architecture for generating slide presentations. Every presentation follows this structure.
## Base HTML Structure
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Presentation Title</title>
<!-- Fonts: use Fontshare or Google Fonts — never system fonts -->
<link rel="stylesheet" href="https://api.fontshare.com/v2/css?f[]=..." />
<style>
/* ===========================================
CSS CUSTOM PROPERTIES (THEME)
Change these to change the whole look
=========================================== */
:root {
/* Colors — from chosen style preset */
--bg-primary: #0a0f1c;
--bg-secondary: #111827;
--text-primary: #ffffff;
--text-secondary: #9ca3af;
--accent: #00ffcc;
--accent-glow: rgba(0, 255, 204, 0.3);
/* Typography — MUST use clamp() */
--font-display: "Clash Display", sans-serif;
--font-body: "Satoshi", sans-serif;
--title-size: clamp(2rem, 6vw, 5rem);
--subtitle-size: clamp(0.875rem, 2vw, 1.25rem);
--body-size: clamp(0.75rem, 1.2vw, 1rem);
/* Spacing — MUST use clamp() */
--slide-padding: clamp(1.5rem, 4vw, 4rem);
--content-gap: clamp(1rem, 2vw, 2rem);
/* Animation */
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
--duration-normal: 0.6s;
}
/* ===========================================
BASE STYLES
=========================================== */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
/* --- PASTE viewport-base.css CONTENTS HERE --- */
/* ===========================================
ANIMATIONS
Trigger via .visible class (added by JS on scroll)
=========================================== */
.reveal {
opacity: 0;
transform: translateY(30px);
transition:
opacity var(--duration-normal) var(--ease-out-expo),
transform var(--duration-normal) var(--ease-out-expo);
}
.slide.visible .reveal {
opacity: 1;
transform: translateY(0);
}
/* Stagger children for sequential reveal */
.reveal:nth-child(1) {
transition-delay: 0.1s;
}
.reveal:nth-child(2) {
transition-delay: 0.2s;
}
.reveal:nth-child(3) {
transition-delay: 0.3s;
}
.reveal:nth-child(4) {
transition-delay: 0.4s;
}
/* ... preset-specific styles ... */
</style>
</head>
<body>
<!-- Optional: Progress bar -->
<div class="progress-bar"></div>
<!-- Optional: Navigation dots -->
<nav class="nav-dots"><!-- Generated by JS --></nav>
<!-- Slides -->
<section class="slide title-slide">
<h1 class="reveal">Presentation Title</h1>
<p class="reveal">Subtitle or author</p>
</section>
<section class="slide">
<div class="slide-content">
<h2 class="reveal">Slide Title</h2>
<p class="reveal">Content...</p>
</div>
</section>
<!-- More slides... -->
<script>
/* ===========================================
SLIDE PRESENTATION CONTROLLER
=========================================== */
class SlidePresentation {
constructor() {
this.slides = document.querySelectorAll(".slide");
this.currentSlide = 0;
this.setupIntersectionObserver();
this.setupKeyboardNav();
this.setupTouchNav();
this.setupProgressBar();
this.setupNavDots();
}
setupIntersectionObserver() {
// Add .visible class when slides enter viewport
// Triggers CSS animations efficiently
}
setupKeyboardNav() {
// Arrow keys, Space, Page Up/Down
}
setupTouchNav() {
// Touch/swipe support for mobile
}
setupProgressBar() {
// Update progress bar on scroll
}
setupNavDots() {
// IMPORTANT: Always clear before building — if outerHTML was
// captured while dots were rendered, re-opening the file would
// append a duplicate set on top of the existing ones.
this.navDotsContainer.innerHTML = "";
// Generate and manage navigation dots
}
}
new SlidePresentation();
</script>
</body>
</html>
```
## Required JavaScript Features
Every presentation must include:
1. **SlidePresentation Class** — Main controller with:
- Keyboard navigation (arrows, space, page up/down)
- Touch/swipe support
- Mouse wheel navigation
- Progress bar updates
- Navigation dots
2. **Intersection Observer** — For scroll-triggered animations:
- Add `.visible` class when slides enter viewport
- Trigger CSS transitions efficiently
3. **Optional Enhancements** (match to chosen style):
- Custom cursor with trail
- Particle system background (canvas)
- Parallax effects
- 3D tilt on hover
- Magnetic buttons
- Counter animations
4. **Inline Editing** (only if user opted in during Phase 1 — skip entirely if they said No):
- Edit toggle button (hidden by default, revealed via hover hotzone or `E` key)
- Auto-save to localStorage
- Export/save file functionality
- See "Inline Editing Implementation" section below
## Inline Editing Implementation (Opt-In Only)
**If the user chose "No" for inline editing in Phase 1, do NOT generate any edit-related HTML, CSS, or JS.**
**Do NOT use CSS `~` sibling selector for hover-based show/hide.** The CSS-only approach (`edit-hotzone:hover ~ .edit-toggle`) fails because `pointer-events: none` on the toggle button breaks the hover chain: user hovers hotzone -> button becomes visible -> mouse moves toward button -> leaves hotzone -> button disappears before click.
**Required approach: JS-based hover with 400ms delay timeout.**
HTML:
```html
<div class="edit-hotzone"></div>
<button class="edit-toggle" id="editToggle" title="Edit mode (E)">Edit</button>
```
CSS (visibility controlled by JS classes only):
```css
/* Do NOT use CSS ~ sibling selector for this!
pointer-events: none breaks the hover chain.
Must use JS with delay timeout. */
.edit-hotzone {
position: fixed;
top: 0;
left: 0;
width: 80px;
height: 80px;
z-index: 10000;
cursor: pointer;
}
.edit-toggle {
opacity: 0;
pointer-events: none;
transition: opacity 0.3s ease;
z-index: 10001;
}
.edit-toggle.show,
.edit-toggle.active {
opacity: 1;
pointer-events: auto;
}
```
JS (three interaction methods):
```javascript
// 1. Click handler on the toggle button
document.getElementById("editToggle").addEventListener("click", () => {
editor.toggleEditMode();
});
// 2. Hotzone hover with 400ms grace period
const hotzone = document.querySelector(".edit-hotzone");
const editToggle = document.getElementById("editToggle");
let hideTimeout = null;
hotzone.addEventListener("mouseenter", () => {
clearTimeout(hideTimeout);
editToggle.classList.add("show");
});
hotzone.addEventListener("mouseleave", () => {
hideTimeout = setTimeout(() => {
if (!editor.isActive) editToggle.classList.remove("show");
}, 400);
});
editToggle.addEventListener("mouseenter", () => {
clearTimeout(hideTimeout);
});
editToggle.addEventListener("mouseleave", () => {
hideTimeout = setTimeout(() => {
if (!editor.isActive) editToggle.classList.remove("show");
}, 400);
});
// 3. Hotzone direct click
hotzone.addEventListener("click", () => {
editor.toggleEditMode();
});
// 4. Keyboard shortcut (E key, skip when editing text)
document.addEventListener("keydown", (e) => {
if (
(e.key === "e" || e.key === "E") &&
!e.target.getAttribute("contenteditable")
) {
editor.toggleEditMode();
}
});
```
**CRITICAL: `exportFile()` must strip edit state before capturing outerHTML.**
When the user presses Ctrl+S in edit mode, `document.documentElement.outerHTML` captures the live DOM —
including `body.edit-active`, `contenteditable="true"` on every text element, and `.active`/`.show` classes on
the toggle button and banner. Anyone opening the saved file sees dashed outlines, a checkmark button, and an
edit banner, as if permanently stuck in edit mode.
Always implement `exportFile()` like this:
```javascript
exportFile() {
// Temporarily strip edit state so the saved file opens cleanly
const editableEls = Array.from(document.querySelectorAll('[contenteditable]'));
editableEls.forEach(el => el.removeAttribute('contenteditable'));
document.body.classList.remove('edit-active');
// Also strip UI classes from toggle button and banner
const editToggle = document.getElementById('editToggle');
const editBanner = document.querySelector('.edit-banner');
editToggle?.classList.remove('active', 'show');
editBanner?.classList.remove('active', 'show');
const html = '<!DOCTYPE html>\n' + document.documentElement.outerHTML;
// Restore edit state so the user can keep editing
document.body.classList.add('edit-active');
editableEls.forEach(el => el.setAttribute('contenteditable', 'true'));
editToggle?.classList.add('active');
editBanner?.classList.add('active');
const blob = new Blob([html], { type: 'text/html' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'presentation.html';
a.click();
URL.revokeObjectURL(a.href);
}
```
## Image Pipeline (Skip If No Images)
If user chose "No images" in Phase 1, skip this entirely. If images were provided, process them before generating HTML.
**Dependency:** `pip install Pillow`
### Image Processing
```python
from PIL import Image, ImageDraw
# Circular crop (for logos on modern/clean styles)
def crop_circle(input_path, output_path):
img = Image.open(input_path).convert('RGBA')
w, h = img.size
size = min(w, h)
left, top = (w - size) // 2, (h - size) // 2
img = img.crop((left, top, left + size, top + size))
mask = Image.new('L', (size, size), 0)
ImageDraw.Draw(mask).ellipse([0, 0, size, size], fill=255)
img.putalpha(mask)
img.save(output_path, 'PNG')
# Resize (for oversized images that inflate HTML)
def resize_max(input_path, output_path, max_dim=1200):
img = Image.open(input_path)
img.thumbnail((max_dim, max_dim), Image.LANCZOS)
img.save(output_path, quality=85)
```
| Situation | Operation |
| -------------------------------- | ----------------------------- |
| Square logo on rounded aesthetic | `crop_circle()` |
| Image > 1MB | `resize_max(max_dim=1200)` |
| Wrong aspect ratio | Manual crop with `img.crop()` |
Save processed images with `_processed` suffix. Never overwrite originals.
### Image Placement
**Use direct file paths** (not base64) — presentations are viewed locally:
```html
<img src="assets/logo_round.png" alt="Logo" class="slide-image logo" />
<img
src="assets/screenshot.png"
alt="Screenshot"
class="slide-image screenshot"
/>
```
```css
.slide-image {
max-width: 100%;
max-height: min(50vh, 400px);
object-fit: contain;
border-radius: 8px;
}
.slide-image.screenshot {
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
.slide-image.logo {
max-height: min(30vh, 200px);
}
```
**Adapt border/shadow colors to match the chosen style's accent.** Never repeat the same image on multiple slides (except logos on title + closing).
**Placement patterns:** Logo centered on title slide. Screenshots in two-column layouts with text. Full-bleed images as slide backgrounds with text overlay (use sparingly).
---
## Code Quality
**Comments:** Every section needs clear comments explaining what it does and how to modify it.
**Accessibility:**
- Semantic HTML (`<section>`, `<nav>`, `<main>`)
- Keyboard navigation works fully
- ARIA labels where needed
- `prefers-reduced-motion` support (included in viewport-base.css)
## File Structure
Single presentations:
```
presentation.html # Self-contained, all CSS/JS inline
assets/ # Images only, if any
```
Multiple presentations in one project:
```
[name].html
[name]-assets/
```

View File

@@ -0,0 +1,418 @@
#!/usr/bin/env bash
# export-pdf.sh - Export an HTML presentation to PDF
#
# Usage:
# bash scripts/export-pdf.sh <path-to-html> [output.pdf]
#
# Examples:
# bash scripts/export-pdf.sh ./my-deck/index.html
# bash scripts/export-pdf.sh ./presentation.html ./presentation.pdf
#
# What this does:
# 1. Starts a local server to serve the HTML (fonts and assets need HTTP)
# 2. Uses Playwright to screenshot each slide at 1920x1080
# 3. Combines all screenshots into a single PDF
# 4. Cleans up the server and temp files
#
# The PDF preserves colors, fonts, and layout - but not animations.
# Perfect for email attachments, printing, or embedding in documents.
set -euo pipefail
# --- Colors ---
RED='\033[0;31m'
GREEN='\033[0;32m'
CYAN='\033[0;36m'
YELLOW='\033[1;33m'
BOLD='\033[1m'
NC='\033[0m'
info() { echo -e "${CYAN}INFO:${NC} $*"; }
ok() { echo -e "${GREEN}OK:${NC} $*"; }
warn() { echo -e "${YELLOW}WARNING:${NC} $*"; }
err() { echo -e "${RED}ERROR:${NC} $*" >&2; }
# --- Parse flags ---
# Default resolution: 1920x1080 (full HD, ~1-2MB per slide)
# Compact resolution: 1280x720 (HD, ~50-70% smaller files)
VIEWPORT_W=1920
VIEWPORT_H=1080
COMPACT=false
POSITIONAL=()
for arg in "$@"; do
case $arg in
--compact)
COMPACT=true
VIEWPORT_W=1280
VIEWPORT_H=720
;;
*)
POSITIONAL+=("$arg")
;;
esac
done
set -- "${POSITIONAL[@]}"
# --- Input validation ---
if [[ $# -lt 1 ]]; then
err "Usage: bash scripts/export-pdf.sh <path-to-html> [output.pdf] [--compact]"
err ""
err "Examples:"
err " bash scripts/export-pdf.sh ./my-deck/index.html"
err " bash scripts/export-pdf.sh ./presentation.html ./slides.pdf"
err " bash scripts/export-pdf.sh ./presentation.html --compact # smaller file size"
exit 1
fi
INPUT_HTML="$1"
if [[ ! -f "$INPUT_HTML" ]]; then
err "File not found: $INPUT_HTML"
exit 1
fi
# Resolve to absolute path
INPUT_HTML=$(cd "$(dirname "$INPUT_HTML")" && pwd)/$(basename "$INPUT_HTML")
# Output PDF path: use second argument or derive from input name
if [[ $# -ge 2 ]]; then
OUTPUT_PDF="$2"
else
OUTPUT_PDF="$(dirname "$INPUT_HTML")/$(basename "$INPUT_HTML" .html).pdf"
fi
# Resolve output to absolute path
OUTPUT_DIR=$(dirname "$OUTPUT_PDF")
mkdir -p "$OUTPUT_DIR"
OUTPUT_PDF="$OUTPUT_DIR/$(basename "$OUTPUT_PDF")"
echo ""
echo -e "${BOLD}========================================${NC}"
echo -e "${BOLD} Export Slides to PDF${NC}"
echo -e "${BOLD}========================================${NC}"
echo ""
# --- Step 1: Check dependencies ---
info "Checking dependencies..."
if ! command -v npx &>/dev/null; then
err "Node.js is required but not installed."
err ""
err "Install Node.js:"
err " macOS: brew install node"
err " or visit https://nodejs.org and download the installer"
exit 1
fi
ok "Node.js found"
# --- Step 2: Create the export script ---
# We use a temporary Node.js script with Playwright to:
# 1. Start a local server (so fonts load correctly)
# 2. Navigate to each slide
# 3. Screenshot each slide at 1920x1080 (16:9 landscape)
# 4. Combine into a single PDF
TEMP_DIR=$(mktemp -d)
TEMP_SCRIPT="$TEMP_DIR/export-slides.mjs"
# Figure out which directory to serve (the folder containing the HTML)
SERVE_DIR=$(dirname "$INPUT_HTML")
HTML_FILENAME=$(basename "$INPUT_HTML")
cat > "$TEMP_SCRIPT" << 'EXPORT_SCRIPT'
// export-slides.mjs - Playwright script to export HTML slides to PDF
//
// How it works:
// 1. Starts a local HTTP server (needed for fonts/assets to load)
// 2. Opens the presentation in a headless browser at 1920x1080
// 3. Counts the total number of slides
// 4. Screenshots each slide one by one
// 5. Generates a PDF with all slides as landscape pages
import { chromium } from 'playwright';
import { createServer } from 'http';
import { readFileSync, existsSync, mkdirSync, unlinkSync, writeFileSync } from 'fs';
import { join, extname, resolve } from 'path';
import { execSync } from 'child_process';
const SERVE_DIR = process.argv[2];
const HTML_FILE = process.argv[3];
const OUTPUT_PDF = process.argv[4];
const SCREENSHOT_DIR = process.argv[5];
const VP_WIDTH = parseInt(process.argv[6]) || 1920;
const VP_HEIGHT = parseInt(process.argv[7]) || 1080;
// --- Simple static file server ---
// (We need HTTP so that Google Fonts and relative assets load correctly)
const MIME_TYPES = {
'.html': 'text/html',
'.css': 'text/css',
'.js': 'application/javascript',
'.json': 'application/json',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.webp': 'image/webp',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.ttf': 'font/ttf',
'.eot': 'application/vnd.ms-fontobject',
};
const server = createServer((req, res) => {
// Decode URL-encoded characters (e.g., %20 -> space) so filenames with spaces resolve correctly
const decodedUrl = decodeURIComponent(req.url);
let filePath = join(SERVE_DIR, decodedUrl === '/' ? HTML_FILE : decodedUrl);
try {
const content = readFileSync(filePath);
const ext = extname(filePath).toLowerCase();
res.writeHead(200, { 'Content-Type': MIME_TYPES[ext] || 'application/octet-stream' });
res.end(content);
} catch {
res.writeHead(404);
res.end('Not found');
}
});
// Find a free port
const port = await new Promise((resolve) => {
server.listen(0, () => resolve(server.address().port));
});
console.log(` Local server on port ${port}`);
// --- Screenshot each slide ---
const browser = await chromium.launch();
const page = await browser.newPage({
viewport: { width: VP_WIDTH, height: VP_HEIGHT },
});
// Load the presentation
await page.goto(`http://localhost:${port}/`, { waitUntil: 'networkidle' });
// Wait for fonts to load
await page.evaluate(() => document.fonts.ready);
// Extra wait for animations to settle on the first slide
await page.waitForTimeout(1500);
// Count slides
const slideCount = await page.evaluate(() => {
return document.querySelectorAll('.slide').length;
});
console.log(` Found ${slideCount} slides`);
if (slideCount === 0) {
console.error(' ERROR: No .slide elements found in the presentation.');
console.error(' Make sure your HTML uses <div class="slide"> or <section class="slide">.');
await browser.close();
server.close();
process.exit(1);
}
// Screenshot each slide
mkdirSync(SCREENSHOT_DIR, { recursive: true });
const screenshotPaths = [];
for (let i = 0; i < slideCount; i++) {
// Navigate to slide by simulating the presentation's navigation
// Most frontend-slides presentations use a currentSlide index and show/hide
await page.evaluate((index) => {
const slides = document.querySelectorAll('.slide');
// Try multiple navigation strategies used by frontend-slides:
// Strategy 1: Direct slide manipulation (most common in generated decks)
slides.forEach((slide, idx) => {
if (idx === index) {
slide.style.display = '';
slide.style.opacity = '1';
slide.style.visibility = 'visible';
slide.style.position = 'relative';
slide.style.transform = 'none';
slide.classList.add('active');
} else {
slide.style.display = 'none';
slide.classList.remove('active');
}
});
// Strategy 2: If there's a SlidePresentation class instance, use it
if (window.presentation && typeof window.presentation.goToSlide === 'function') {
window.presentation.goToSlide(index);
}
// Strategy 3: Scroll-based (some decks use scroll snapping)
slides[index]?.scrollIntoView({ behavior: 'instant' });
}, i);
// Wait for any slide transition animations to finish
await page.waitForTimeout(300);
// Wait for intersection observer animations to trigger
await page.waitForTimeout(200);
// Force all .reveal elements on the current slide to be visible
// (animations normally trigger on scroll/intersection, but we need them visible now)
await page.evaluate((index) => {
const slides = document.querySelectorAll('.slide');
const currentSlide = slides[index];
if (currentSlide) {
currentSlide.querySelectorAll('.reveal').forEach(el => {
el.style.opacity = '1';
el.style.transform = 'none';
el.style.visibility = 'visible';
});
}
}, i);
await page.waitForTimeout(100);
const screenshotPath = join(SCREENSHOT_DIR, `slide-${String(i + 1).padStart(3, '0')}.png`);
await page.screenshot({ path: screenshotPath, fullPage: false });
screenshotPaths.push(screenshotPath);
console.log(` Captured slide ${i + 1}/${slideCount}`);
}
await browser.close();
server.close();
// --- Combine screenshots into PDF ---
// Use a second Playwright page to generate a PDF from the screenshots
console.log(' Assembling PDF...');
const browser2 = await chromium.launch();
const pdfPage = await browser2.newPage();
// Build an HTML page with all screenshots, one per page
const imagesHtml = screenshotPaths.map((p) => {
const imgData = readFileSync(p).toString('base64');
return `<div class="page"><img src="data:image/png;base64,${imgData}" /></div>`;
}).join('\n');
const pdfHtml = `<!DOCTYPE html>
<html>
<head>
<style>
* { margin: 0; padding: 0; }
@page { size: ${VP_WIDTH}px ${VP_HEIGHT}px; margin: 0; }
.page {
width: ${VP_WIDTH}px;
height: ${VP_HEIGHT}px;
page-break-after: always;
overflow: hidden;
}
.page:last-child { page-break-after: auto; }
img {
width: ${VP_WIDTH}px;
height: ${VP_HEIGHT}px;
display: block;
object-fit: contain;
}
</style>
</head>
<body>${imagesHtml}</body>
</html>`;
await pdfPage.setContent(pdfHtml, { waitUntil: 'load' });
await pdfPage.pdf({
path: OUTPUT_PDF,
width: `${VP_WIDTH}px`,
height: `${VP_HEIGHT}px`,
printBackground: true,
margin: { top: 0, right: 0, bottom: 0, left: 0 },
});
await browser2.close();
// Clean up screenshots
screenshotPaths.forEach(p => unlinkSync(p));
console.log(` OK: PDF saved to: ${OUTPUT_PDF}`);
EXPORT_SCRIPT
# --- Step 3: Install Playwright in temp directory ---
# We install Playwright locally in the temp dir so the Node script can import it.
# This avoids polluting global packages and ensures the script is self-contained.
info "Setting up Playwright (headless browser for screenshots)..."
info "This may take a moment on first run..."
echo ""
cd "$TEMP_DIR"
# Create a minimal package.json so npm install works
cat > "$TEMP_DIR/package.json" << 'PKG'
{ "name": "slide-export", "private": true, "type": "module" }
PKG
# Install Playwright into the temp directory
npm install playwright &>/dev/null || {
err "Failed to install Playwright."
err "Try running: npm install playwright"
rm -rf "$TEMP_DIR"
exit 1
}
# Ensure Chromium browser binary is downloaded
npx playwright install chromium 2>/dev/null || {
err "Failed to install Chromium browser for Playwright."
err "Try running manually: npx playwright install chromium"
rm -rf "$TEMP_DIR"
exit 1
}
ok "Playwright ready"
echo ""
# --- Step 4: Run the export ---
SCREENSHOT_DIR="$TEMP_DIR/screenshots"
info "Exporting slides to PDF..."
echo ""
# Run from the temp dir so Node can find the locally-installed playwright
if [[ "$COMPACT" == "true" ]]; then
info "Using compact mode (1280x720) for smaller file size"
fi
node "$TEMP_SCRIPT" "$SERVE_DIR" "$HTML_FILENAME" "$OUTPUT_PDF" "$SCREENSHOT_DIR" "$VIEWPORT_W" "$VIEWPORT_H" || {
err "PDF export failed."
rm -rf "$TEMP_DIR"
exit 1
}
# --- Step 5: Cleanup and success ---
rm -rf "$TEMP_DIR"
echo ""
echo -e "${BOLD}========================================${NC}"
ok "PDF exported successfully!"
echo ""
echo -e " ${BOLD}File:${NC} $OUTPUT_PDF"
echo ""
FILE_SIZE=$(du -h "$OUTPUT_PDF" | cut -f1 | xargs)
echo " Size: $FILE_SIZE"
echo ""
echo " This PDF works everywhere - email, Slack, Notion, print."
echo " Note: Animations are not preserved (it's a static export)."
echo -e "${BOLD}========================================${NC}"
echo ""
# Open the PDF automatically
if command -v open &>/dev/null; then
open "$OUTPUT_PDF"
elif command -v xdg-open &>/dev/null; then
xdg-open "$OUTPUT_PDF"
fi

View File

@@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""
Extract all content from a PowerPoint file (.pptx).
Returns a JSON structure with slides, text, and images.
Usage:
python extract-pptx.py <input.pptx> [output_dir]
Requires: pip install python-pptx
"""
import json
import os
import sys
from pptx import Presentation
def extract_pptx(file_path, output_dir="."):
"""
Extract all content from a PowerPoint file.
Returns a list of slide data dicts with text, images, and notes.
"""
prs = Presentation(file_path)
slides_data = []
# Create assets directory for extracted images
assets_dir = os.path.join(output_dir, "assets")
os.makedirs(assets_dir, exist_ok=True)
for slide_num, slide in enumerate(prs.slides):
slide_data = {
"number": slide_num + 1,
"title": "",
"content": [],
"images": [],
"notes": "",
}
for shape in slide.shapes:
# Extract text content
if shape.has_text_frame:
if shape == slide.shapes.title:
slide_data["title"] = shape.text
else:
slide_data["content"].append(
{"type": "text", "content": shape.text}
)
# Extract images
if shape.shape_type == 13: # Picture type
image = shape.image
image_bytes = image.blob
image_ext = image.ext
image_name = f"slide{slide_num + 1}_img{len(slide_data['images']) + 1}.{image_ext}"
image_path = os.path.join(assets_dir, image_name)
with open(image_path, "wb") as f:
f.write(image_bytes)
slide_data["images"].append(
{
"path": f"assets/{image_name}",
"width": shape.width,
"height": shape.height,
}
)
# Extract speaker notes
if slide.has_notes_slide:
notes_frame = slide.notes_slide.notes_text_frame
slide_data["notes"] = notes_frame.text
slides_data.append(slide_data)
return slides_data
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: python extract-pptx.py <input.pptx> [output_dir]")
sys.exit(1)
input_file = sys.argv[1]
output_dir = sys.argv[2] if len(sys.argv) > 2 else "."
slides = extract_pptx(input_file, output_dir)
# Write extracted data as JSON
output_path = os.path.join(output_dir, "extracted-slides.json")
with open(output_path, "w") as f:
json.dump(slides, f, indent=2)
print(f"Extracted {len(slides)} slides to {output_path}")
for s in slides:
img_count = len(s["images"])
print(f" Slide {s['number']}: {s['title'] or '(no title)'}{img_count} image(s)")

View File

@@ -0,0 +1,153 @@
/* ===========================================
VIEWPORT FITTING: MANDATORY BASE STYLES
Include this ENTIRE file in every presentation.
These styles ensure slides fit exactly in the viewport.
=========================================== */
/* 1. Lock html/body to viewport */
html, body {
height: 100%;
overflow-x: hidden;
}
html {
scroll-snap-type: y mandatory;
scroll-behavior: smooth;
}
/* 2. Each slide = exact viewport height */
.slide {
width: 100vw;
height: 100vh;
height: 100dvh; /* Dynamic viewport height for mobile browsers */
overflow: hidden; /* CRITICAL: Prevent ANY overflow */
scroll-snap-align: start;
display: flex;
flex-direction: column;
position: relative;
}
/* 3. Content container with flex for centering */
.slide-content {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
max-height: 100%;
overflow: hidden; /* Double-protection against overflow */
padding: var(--slide-padding);
}
/* 4. ALL typography uses clamp() for responsive scaling */
:root {
/* Titles scale from mobile to desktop */
--title-size: clamp(1.5rem, 5vw, 4rem);
--h2-size: clamp(1.25rem, 3.5vw, 2.5rem);
--h3-size: clamp(1rem, 2.5vw, 1.75rem);
/* Body text */
--body-size: clamp(0.75rem, 1.5vw, 1.125rem);
--small-size: clamp(0.65rem, 1vw, 0.875rem);
/* Spacing scales with viewport */
--slide-padding: clamp(1rem, 4vw, 4rem);
--content-gap: clamp(0.5rem, 2vw, 2rem);
--element-gap: clamp(0.25rem, 1vw, 1rem);
}
/* 5. Cards/containers use viewport-relative max sizes */
.card, .container, .content-box {
max-width: min(90vw, 1000px);
max-height: min(80vh, 700px);
}
/* 6. Lists auto-scale with viewport */
.feature-list, .bullet-list {
gap: clamp(0.4rem, 1vh, 1rem);
}
.feature-list li, .bullet-list li {
font-size: var(--body-size);
line-height: 1.4;
}
/* 7. Grids adapt to available space */
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 250px), 1fr));
gap: clamp(0.5rem, 1.5vw, 1rem);
}
/* 8. Images constrained to viewport */
img, .image-container {
max-width: 100%;
max-height: min(50vh, 400px);
object-fit: contain;
}
/* ===========================================
RESPONSIVE BREAKPOINTS
Aggressive scaling for smaller viewports
=========================================== */
/* Short viewports (< 700px height) */
@media (max-height: 700px) {
:root {
--slide-padding: clamp(0.75rem, 3vw, 2rem);
--content-gap: clamp(0.4rem, 1.5vw, 1rem);
--title-size: clamp(1.25rem, 4.5vw, 2.5rem);
--h2-size: clamp(1rem, 3vw, 1.75rem);
}
}
/* Very short viewports (< 600px height) */
@media (max-height: 600px) {
:root {
--slide-padding: clamp(0.5rem, 2.5vw, 1.5rem);
--content-gap: clamp(0.3rem, 1vw, 0.75rem);
--title-size: clamp(1.1rem, 4vw, 2rem);
--body-size: clamp(0.7rem, 1.2vw, 0.95rem);
}
/* Hide non-essential elements */
.nav-dots, .keyboard-hint, .decorative {
display: none;
}
}
/* Extremely short (landscape phones, < 500px height) */
@media (max-height: 500px) {
:root {
--slide-padding: clamp(0.4rem, 2vw, 1rem);
--title-size: clamp(1rem, 3.5vw, 1.5rem);
--h2-size: clamp(0.9rem, 2.5vw, 1.25rem);
--body-size: clamp(0.65rem, 1vw, 0.85rem);
}
}
/* Narrow viewports (< 600px width) */
@media (max-width: 600px) {
:root {
--title-size: clamp(1.25rem, 7vw, 2.5rem);
}
/* Stack grids vertically */
.grid {
grid-template-columns: 1fr;
}
}
/* ===========================================
REDUCED MOTION
Respect user preferences
=========================================== */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.2s !important;
}
html {
scroll-behavior: auto;
}
}