From keynot
Generates self-contained HTML slide presentations with keyboard navigation, swipe support, fullscreen, animations, embedded images, and brand-accurate styling from guides.
npx claudepluginhub shawnzam/keynot --plugin keynotThis skill uses the workspace's default tool permissions.
A skill for building browser-based slide presentations as single, self-contained HTML files. Keyboard navigation, swipe, fullscreen, animated reveals, embedded images, brand-accurate design. No external dependencies required at runtime.
Generates self-contained HTML slide decks for product presentations. Reads pm-context.md, extracts brand colors from websites, adds keyboard navigation and print-friendly layouts.
Generates single-file HTML slide decks from briefs, notes, or content. Supports 13 layouts, 8 style presets, PDF export. Auto-triggers on 'create slide deck' or similar requests.
Creates brand-aware presentations using frontend-slides or reveal.js. Extracts colors from design systems, scans assets, confirms setup, then generates slides.
Share bugs, ideas, or general feedback.
A skill for building browser-based slide presentations as single, self-contained HTML files. Keyboard navigation, swipe, fullscreen, animated reveals, embedded images, brand-accurate design. No external dependencies required at runtime.
Prefer HTML over PPTX unless:
.pptxIf the user provides a brand PDF, URL, or describes their brand, extract these before writing any code. Be explicit and precise.
Colors
Typography
Layout Language
Logo and Mark Rules
Once extracted, record the brand parameters in a compact block at the top of the deck's <style> so they're easy to audit and swap:
Primary Colors:
Primary: #HEX (dominant — backgrounds, headings)
Accent: #HEX (rules, CTAs, status — used sparingly)
Cream: #HEX (light slide backgrounds)
Usage Rule: One color dominates. Accent appears sparingly —
rules, call-to-action bars, status indicators.
Never use the accent color as a full background.
Typography:
Serif: <Brand serif> → <closest Google Font>
Sans-serif: <Brand sans-serif> → <closest Google Font>
Layout Language:
- Which colors own which regions (dark panels vs. light panels)
- Any spine bars, rules, or dividers the brand uses
- Layout motifs (split panels, stat columns, card grids)
- Tone: editorial / warm / minimal / dense
Logo Rule: For institutional or trademarked brands, do NOT
reproduce the logo. Use a text-based wordmark instead:
<div class="wordmark">
<div class="w-line-1">Organization</div>
<div class="w-line-2">Subtitle or parent entity</div>
</div>
Every deck is a single HTML file. All assets (fonts via CDN, images via base64) are embedded.
<!DOCTYPE html>
<html lang="en">
<head>
<!-- Google Fonts import -->
<!-- All CSS in <style> tag -->
</head>
<body>
<div class="deck" id="deck">
<div class="slide" id="s1">...</div>
<div class="slide" id="s2">...</div>
<!-- more slides -->
</div>
<!-- Navigation -->
<div class="nav">
<button id="prev">←</button>
<div class="dots" id="dots"></div>
<button id="next">→</button>
<span class="counter" id="counter">1 / N</span>
<button id="fsBtn" onclick="toggleFS()">⛶</button>
</div>
<!-- Portrait/mobile rotate hint (see Portrait / Mobile Handling below) -->
<div class="rotate-overlay" aria-hidden="true">
<svg class="rotate-icon" width="56" height="56" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="1.4"
stroke-linecap="round" stroke-linejoin="round">
<rect x="5" y="2" width="14" height="20" rx="2.5" />
<line x1="12" y1="18.5" x2="12.01" y2="18.5" />
</svg>
<h2>Rotate your device</h2>
<p>This deck is designed for landscape viewing. Turn your phone sideways to continue.</p>
</div>
<script><!-- All JS inline --></script>
</body>
</html>
:root {
/* Always use CSS variables for brand colors — swap these per brand */
--primary: #1a1a2e; /* dominant: dark panels, headings */
--accent: #e94560; /* sparingly: rules, CTAs, status */
--white: #ffffff;
--cream: #f7f4ee; /* light slide backgrounds */
--serif: 'Cormorant Garamond', Georgia, serif;
--sans: 'DM Sans', system-ui, sans-serif;
}
/* Deck shell */
html, body { width: 100%; height: 100%; overflow: hidden; background: #000; }
.deck { width: 100vw; height: 100vh; position: relative; }
/* Slides: opacity transition, stacked absolutely */
.slide {
position: absolute; inset: 0;
opacity: 0; pointer-events: none;
transition: opacity .55s cubic-bezier(.4,0,.2,1);
}
.slide.active { opacity: 1; pointer-events: all; }
/* Staggered reveal animation on active slide children */
.slide.active .reveal { animation: fadeUp .6s both; }
.slide.active .reveal:nth-child(2) { animation-delay: .1s; }
.slide.active .reveal:nth-child(3) { animation-delay: .2s; }
/* ...continue for as many children as needed */
@keyframes fadeUp {
from { opacity: 0; transform: translateY(22px); }
to { opacity: 1; transform: translateY(0); }
}
.nav {
position: fixed; bottom: 28px; left: 50%;
transform: translateX(-50%);
z-index: 999;
display: flex; align-items: center; gap: 14px;
background: rgba(26,26,46,.75); /* tint from --primary */
backdrop-filter: blur(10px);
padding: 9px 20px; border-radius: 40px;
border: 1px solid rgba(255,255,255,.14);
opacity: 1;
transition: opacity .4s ease; /* required for auto-hide */
}
.nav-hidden { opacity: 0; pointer-events: none; } /* auto-hide state */
.nav button {
background: none; border: none; cursor: pointer;
color: rgba(255,255,255,.65); font-size: 16px;
width: 30px; height: 30px;
display: flex; align-items: center; justify-content: center;
border-radius: 50%;
transition: color .2s, background .2s;
}
.nav button:hover { color: #fff; background: rgba(255,255,255,.12); }
.dot {
width: 7px; height: 7px; border-radius: 50%;
background: rgba(255,255,255,.3); cursor: pointer;
transition: background .2s, transform .2s;
}
.dot.active { background: var(--accent); transform: scale(1.35); }
// Fullscreen
function toggleFS() {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().catch(() => {});
document.getElementById('fsBtn').innerHTML = '✕';
} else {
document.exitFullscreen();
document.getElementById('fsBtn').innerHTML = '⛶';
}
}
document.addEventListener('fullscreenchange', () => {
if (!document.fullscreenElement)
document.getElementById('fsBtn').innerHTML = '⛶';
});
// Deep-link & session persistence helpers
function parseHash() {
const m = location.hash.match(/^#slide-(\d+)$/);
if (!m) return -1;
const idx = parseInt(m[1], 10) - 1; // hash is 1-based, array is 0-based
return (idx >= 0 && idx < document.querySelectorAll('.slide').length) ? idx : -1;
}
function initialSlide() {
const fromHash = parseHash();
if (fromHash >= 0) return fromHash;
const stored = sessionStorage.getItem('keynot-slide');
if (stored !== null) {
const idx = parseInt(stored, 10);
if (idx >= 0 && idx < document.querySelectorAll('.slide').length) return idx;
}
return 0;
}
function persistSlide(idx) {
sessionStorage.setItem('keynot-slide', idx);
history.replaceState(null, '', '#slide-' + (idx + 1));
}
// Navigation
const slides = document.querySelectorAll('.slide'); // REQUIRED — do not omit
const dotsEl = document.getElementById('dots');
const counter = document.getElementById('counter');
let cur = initialSlide();
// Remove default 'active' class from slide 1 if restoring a different slide
slides.forEach(s => s.classList.remove('active'));
slides.forEach((_, i) => {
const d = document.createElement('div');
d.className = 'dot';
d.addEventListener('click', () => go(i));
dotsEl.appendChild(d);
});
// Activate the initial slide
slides[cur].classList.add('active');
dotsEl.children[cur].classList.add('active');
counter.textContent = `${cur + 1} / ${slides.length}`;
persistSlide(cur);
function go(n) {
slides[cur].classList.remove('active');
dotsEl.children[cur].classList.remove('active');
cur = (n + slides.length) % slides.length;
slides[cur].classList.add('active');
dotsEl.children[cur].classList.add('active');
counter.textContent = `${cur + 1} / ${slides.length}`;
persistSlide(cur);
}
// Deep-link: respond to manual hash changes or pasted URLs
window.addEventListener('hashchange', () => {
const idx = parseHash();
if (idx >= 0 && idx !== cur) go(idx);
});
document.getElementById('next').addEventListener('click', () => go(cur + 1));
document.getElementById('prev').addEventListener('click', () => go(cur - 1));
document.addEventListener('keydown', e => {
if (e.key === 'ArrowRight' || e.key === 'ArrowDown' || e.key === ' ') go(cur + 1);
if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') go(cur - 1);
if (e.key === 'f' || e.key === 'F') toggleFS();
});
// Auto-hide nav after 1.8s of inactivity; reappear on any interaction
const nav = document.querySelector('.nav');
let hideTimer;
function showNav() {
nav.classList.remove('nav-hidden');
clearTimeout(hideTimer);
hideTimer = setTimeout(() => nav.classList.add('nav-hidden'), 1800);
}
document.addEventListener('mousemove', showNav);
document.addEventListener('touchstart', showNav, { passive: true });
document.addEventListener('keydown', showNav);
showNav(); // start timer on load
// Swipe
let tx = 0;
document.addEventListener('touchstart', e => { tx = e.touches[0].clientX; }, { passive: true });
document.addEventListener('touchend', e => {
const dx = e.changedTouches[0].clientX - tx;
if (Math.abs(dx) > 50) go(dx < 0 ? cur + 1 : cur - 1);
}, { passive: true });
Slide decks are a landscape medium. Rather than reflowing split-panel layouts into unreadable mobile stacks, show a "rotate your device" overlay when the viewport is portrait + narrow. This covers iPhones held vertically without doubling the CSS for every layout pattern. Landscape phone viewing keeps working because clamp() font sizing already scales down.
Add this CSS to every deck, near the bottom of the <style> block so it overrides nothing:
/* Rotate-to-landscape overlay — only visible on portrait phones */
.rotate-overlay {
display: none;
position: fixed; inset: 0;
z-index: 9999;
background: var(--primary);
color: var(--white);
flex-direction: column;
align-items: center; justify-content: center;
text-align: center; padding: 40px;
font-family: var(--sans);
}
.rotate-overlay .rotate-icon {
margin-bottom: 24px;
color: var(--accent);
animation: rotate-hint 2.4s ease-in-out infinite;
}
.rotate-overlay h2 {
font-family: var(--serif);
font-size: 30px; font-weight: 400;
margin-bottom: 12px; letter-spacing: -.01em;
}
.rotate-overlay p {
font-size: 14px; line-height: 1.6;
opacity: .72; max-width: 280px;
}
@keyframes rotate-hint {
0%, 40%, 100% { transform: rotate(0deg); }
60%, 80% { transform: rotate(90deg); }
}
@media (orientation: portrait) and (max-width: 900px) {
.rotate-overlay { display: flex; }
}
The overlay HTML is included in the Shell Structure above. Drop both in and the deck handles portrait phones gracefully without any per-slide changes.
Why not full mobile responsiveness? Split panels, stat columns, and photo backgrounds all rely on horizontal real estate to communicate. A reflowed mobile version is effectively a different deck — and a worse one. PowerPoint and Google Slides also fail on portrait mobile; the audience already accepts this for the format.
Users should be able to hit Cmd+P → "Save as PDF" and get a clean one-slide-per-page PDF. The screen deck stacks slides absolutely and hides inactive ones via opacity: 0, so a naive print captures only the active slide. The fix: a @media print block that un-stacks every slide, kills animations, hides nav/overlays, and forces landscape page size.
Add this near the bottom of the <style> block:
/* Print / PDF export — Cmd+P → Save as PDF gives one slide per page */
@media print {
@page {
size: 1600px 1000px; /* native deck aspect ratio; user can fit-to-page */
margin: 0;
}
html, body {
width: 1600px; height: auto;
overflow: visible !important;
background: #fff;
}
.deck {
width: 1600px; height: auto;
position: static !important;
}
.slide {
position: relative !important;
inset: auto !important;
width: 1600px; height: 1000px;
opacity: 1 !important;
pointer-events: auto !important;
page-break-after: always;
break-after: page;
transform: none !important;
}
/* Kill all animations and transitions so content is in final state */
*, *::before, *::after {
animation: none !important;
animation-duration: 0s !important;
animation-delay: 0s !important;
transition: none !important;
}
.slide .reveal {
opacity: 1 !important;
transform: none !important;
}
/* Hide interactive chrome */
.nav, .rotate-overlay { display: none !important; }
/* Preserve backgrounds and colors in print output */
* {
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}
}
Usage: user opens the deck in a browser, hits Cmd+P (or Ctrl+P), picks "Save as PDF" as the destination, and optionally toggles "Background graphics" on if the print dialog offers it. The default paper size in the dialog doesn't matter much — the @page rule sets the content frame, and browsers scale to fit. For pixel-perfect output, users can pick "Custom" paper size matching 1600 × 1000 in the dialog.
Gotchas:
@page size: <pixels> less reliably than Chrome/Safari. For critical Firefox users, swap to size: 10in 6.25in (same ratio, physical units).Use these as building blocks. Mix and match per slide.
Dark panel left (brand primary color), light panel right (white or cream). Diagonal slice or hard edge between them.
<div class="slide" id="sN" style="display:flex;">
<div class="left-panel"><!-- headline, statement, quote --></div>
<div class="right-panel"><!-- list, stats, details --></div>
</div>
.left-panel {
width: 50%; background: var(--primary);
display: flex; flex-direction: column; justify-content: center;
padding: 72px 64px; position: relative; overflow: hidden;
}
.right-panel {
flex: 1;
display: flex; flex-direction: column; justify-content: center;
padding: 72px 56px 72px 64px; background: var(--cream);
}
Right-side column of large serif numbers/words with small uppercase labels. Works over photo backgrounds.
<div class="stats-col">
<div class="stat">
<div class="stat-n">13+</div>
<div class="stat-l">Years at institution</div>
</div>
<!-- divider between stats: border-top: 1px solid rgba(255,255,255,.09) -->
</div>
Left colored bar accent, title + description. One bar color per value for visual differentiation.
<div class="value-card">
<div class="value-bar" style="background:#2e7d32"></div>
<div>
<div class="value-title">Principle Name</div>
<div class="value-text">Description of the principle.</div>
</div>
</div>
Large ghost number, vertical rule, then body content. Good for 3-step processes.
<div class="approach">
<div class="approach-num">01</div>
<div class="approach-body">
<div class="approach-title">Step Title</div>
<div class="approach-text">Detail text.</div>
<div class="approach-tags"><span>Tag 1</span><span>Tag 2</span></div>
</div>
</div>
Name + status badge + description. Status badge variants: live (green), in-progress (amber), planned (neutral).
<div class="tool-card">
<div class="tool-body">
<div class="tool-name">Tool Name</div>
<span class="status status-live">Live</span>
<div class="tool-desc">What it does and who it's for.</div>
</div>
</div>
Embed image as base64, apply gradient overlay so text stays readable.
<div class="photo-panel" style="
position:absolute; top:0; right:0; bottom:0; width:42%;
background: url('data:image/png;base64,...') center/cover no-repeat;
z-index:1;
">
<div style="position:absolute;inset:0;
background:linear-gradient(to right, var(--primary) 0%, rgba(0,0,0,.55) 40%, rgba(0,0,0,.1) 100%);
"></div>
</div>
To embed an image as base64:
import base64
data = 'data:image/png;base64,' + base64.b64encode(open('image.png','rb').read()).decode()
A strong 5-slide deck structure for an institutional introduction:
| Slide | Purpose | Layout |
|---|---|---|
| 1 | Who you are + elevator pitch | Full bleed dark + photo + stat column |
| 2 | Values / principles | Split panel + value cards |
| 3 | Approach / framework | Light background + approach rows |
| 4 | Tools / enablement | Dark background + two columns |
| 5 | Close + discussion opener | Split panel + photo background |
Adapt freely. The key is each slide has ONE job and ONE dominant visual idea.
/* Display heading — large, light weight, letter-spaced */
.display {
font-family: var(--serif);
font-size: clamp(52px, 7vw, 88px);
font-weight: 300; line-height: .95;
letter-spacing: -.01em;
}
/* Section heading */
.heading {
font-family: var(--serif);
font-size: clamp(32px, 4vw, 50px);
font-weight: 400; line-height: 1.1;
}
/* Eyebrow label */
.label {
font-family: var(--sans);
font-size: 10px; font-weight: 600;
letter-spacing: .2em; text-transform: uppercase;
}
/* Body */
.body {
font-family: var(--sans);
font-size: 13px; font-weight: 300; line-height: 1.65;
}
/* Use clamp() for all font sizes to scale with viewport */
This will corrupt arithmetic operators. Example of what goes wrong:
# DANGEROUS — corrupts JS
content = content.replace(' - ', ', ')
# cur - 1 becomes cur, 1 (prev button breaks)
# clientX - tx becomes clientX, tx (swipe breaks)
Safe approach: Use targeted str_replace with unique, specific strings. Always view the JS section after any automated replacement to verify arithmetic operators are intact.
const slides before using itIf inserting a new function before the navigation block, check that const slides = document.querySelectorAll('.slide') is still present and comes before slides.forEach(...).
str_replace requires unique stringsBefore using str_replace, grep for the target string to confirm it appears exactly once. If it appears multiple times, add more context lines to make it unique.
A single high-res PNG can add 150-200KB to the file. This is fine for a presentation deck. Use cover sizing and center center positioning. Always add a gradient overlay div for text legibility.
Avoid leading emoji in list items and card titles — they read as AI-generated and date quickly. If the user asks to remove emoji, use Python unicode replacement rather than sed:
import re
content = re.sub(r'[\U00002600-\U0001FAFF]+', '', content)
content = content.replace('\ufe0f', '') # variation selector
When making changes after initial build:
view tool with line rangestr_replace for surgical edits — never rewrite the whole file for small changescur, dx, +, - operatorsWhen the user says things like "slide 3: change X" — make ONLY that change. Resist the urge to improve adjacent content.
This skill is compatible with Claude API (Codex) usage. Key considerations:
--primary, --accent, etc.) make theming programmatic — pass brand colors as parameters and substitute into the templateWhen the user explicitly asks for a .pptx file, generate a Python script that uses python-pptx to build the deck programmatically. The user runs it with:
uv run --with python-pptx <script>.py
No system-wide installs required — uv handles the dependency inline.
.pptx or "PowerPoint"Same content extraction and brand logic as HTML — extract colors, fonts, layout intent from the prompt. Then emit a self-contained .py script instead of an .html file.
from pptx import Presentation
from pptx.util import Inches, Pt
from pptx.dml.color import RGBColor
from pptx.enum.text import PP_ALIGN, MSO_ANCHOR
from pptx.enum.shapes import MSO_SHAPE
# Brand colors mapped from CSS variables
PRIMARY = RGBColor(0x1A, 0x1A, 0x2E)
ACCENT = RGBColor(0xE9, 0x45, 0x60)
prs = Presentation()
prs.slide_width = Inches(16)
prs.slide_height = Inches(10)
blank_layout = prs.slide_layouts[6] # blank — full control
def set_slide_bg(slide, color):
fill = slide.background.fill
fill.solid()
fill.fore_color.rgb = color
def add_text_box(slide, left, top, width, height, text,
font_size=18, bold=False, italic=False,
color=RGBColor(0xFF,0xFF,0xFF), font_name="Arial"):
txBox = slide.shapes.add_textbox(left, top, width, height)
tf = txBox.text_frame
tf.word_wrap = True
p = tf.paragraphs[0]
p.text = text
p.font.size = Pt(font_size)
p.font.bold = bold
p.font.italic = italic
p.font.color.rgb = color
p.font.name = font_name
# Build slides...
slide1 = prs.slides.add_slide(blank_layout)
set_slide_bg(slide1, PRIMARY)
add_text_box(slide1, Inches(0.8), Inches(3), Inches(10), Inches(3),
"Title Here", font_size=72, font_name="Georgia")
prs.save("output.pptx")
print("Saved: output.pptx")
| HTML Pattern | PPTX Equivalent |
|---|---|
| Split panel (dark left / light right) | Rectangle shape as left bg + text boxes on each side |
| Stat columns | Large-font text boxes with small label text boxes below |
| Value cards with colored bars | Thin rectangle shapes as accent bars + adjacent text boxes |
| Approach rows (numbered steps) | Large number text box + title/body text boxes offset right |
| Full-bleed background | set_slide_bg() with brand color |
Warn the user: PPTX output is an approximation of the HTML deck, not a pixel-perfect replica. python-pptx does not support CSS, gradients, clamp() scaling, or web fonts. The generated deck will:
Always tell the user: "The .pptx is a solid starting point but may need minor adjustments in PowerPoint — check alignment and font rendering before presenting."
| HTML (Google Font) | PPTX fallback |
|---|---|
| Cormorant Garamond | Georgia |
| DM Sans | Arial |
| Playfair Display | Georgia |
| Inter | Arial |
| Any serif | Georgia or Times New Roman |
| Any sans-serif | Arial or Calibri |
Every deck fills these slots. Populate from the brand guide, then theme programmatically via CSS variables.
--primary Dominant backgrounds and dark panels
--accent Rules, spine bars, CTAs, status dots, hover states
--cream Light slide backgrounds (warmer than pure white)
--stone Dividers, subtle borders (one tint above cream)
--ink Body text on light backgrounds (softer than black)
--muted Labels, metadata, secondary text
--go Positive / live / green status
--warn Review / caution / amber status
--alt Fifth accent for value/pillar differentiation
Fill with real hex codes at the top of <style> and reference everywhere via var(--name).