Reference architecture for generating slide presentations. Every presentation follows this structure.
<!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>
Every presentation must include:
SlidePresentation Class — Main controller with:
Intersection Observer — For scroll-triggered animations:
.visible class when slides enter viewportOptional Enhancements (match to chosen style):
Inline Editing (only if user opted in during Phase 1 — skip entirely if they said No):
E key)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:
<div class="edit-hotzone"></div>
<button class="edit-toggle" id="editToggle" title="Edit mode (E)">✏️</button>
CSS (visibility controlled by JS classes only):
/* 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):
// 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:
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);
}
When contenteditable is active on elements inside a themed <div> or <section>, pressing Enter inserts a <div> (Chrome default) instead of a <br>, which inherits none of the element's styles and makes the new line appear unstyled. Pasting rich text injects foreign fonts and colors that override the theme.
Add these three guards to every inline-editing implementation:
// 1. Force <br> on Enter instead of <div>
document.execCommand('defaultParagraphSeparator', false, 'br');
// 2. Intercept Enter to prevent the browser default <div> insertion
document.addEventListener('keydown', e => {
if (e.key === 'Enter' && document.querySelector('[contenteditable]:focus')) {
e.preventDefault();
document.execCommand('insertLineBreak');
}
});
// 3. Strip rich-text formatting on paste — keep plain text only
document.addEventListener('paste', e => {
const active = document.activeElement;
if (!active?.isContentEditable) return;
e.preventDefault();
const text = e.clipboardData.getData('text/plain');
document.execCommand('insertText', false, text);
});
Without these fixes: after pressing Enter, new lines render with no theme font or color (issue #49); pasting from a browser or document injects foreign styles that break the theme.
If user chose "No images" in Phase 1, skip this entirely. If images were provided, process them before generating HTML.
Dependency: pip install Pillow
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.
Use direct file paths (not base64) — presentations are viewed locally:
<img src="assets/logo_round.png" alt="Logo" class="slide-image logo" />
<img
src="assets/screenshot.png"
alt="Screenshot"
class="slide-image screenshot"
/>
.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).
Comments: Every section needs clear comments explaining what it does and how to modify it.
Accessibility:
<section>, <nav>, <main>)prefers-reduced-motion support (included in viewport-base.css)Single presentations:
presentation.html # Self-contained, all CSS/JS inline
assets/ # Images only, if any
Multiple presentations in one project:
[name].html
[name]-assets/