- name
- illustrated-explainer-spec
- description
- Spec for building an infinite drill-down AI illustrated explainer web app where users type a topic and click to drill into generated watercolor-style images.
- triggers
- ["implement the illustrated explainer spec","build a drill-down explainer app","create an infinite drill-down image generator","set up the illustrated explainer project","implement drill-down watercolor explainer","build AI image drill-down app","implement the flipbook explainer spec","create click-to-drill image explainer"]
# Illustrated Explainer Spec Implementation Guide
> Skill by [ara.so](https://ara.so) — Daily 2026 Skills collection.
## What This Project Is
A spec (not a library) for building a locally-run single-page web app where:
1. User types a topic → AI generates a 16:9 watercolor-style illustrated explainer page
2. User clicks anywhere on the image → AI generates a "drill-into" next page for that spot
3. This repeats infinitely, preserving painting style across all pages
4. Content-addressed caching means identical queries/clicks never re-generate
The spec is **stack-agnostic** — you choose the framework, image model API, and language. This skill shows you how to implement it end-to-end.
---
## Architecture Overview
```
Browser (thin client)
└── POST /api/page ──► Server
├── hash → check disk cache
├── composite red marker onto parent image
├── call image model (text + optional image)
└── write PNG → return page object
```
### Page Object Shape
```typescript
interface Page {
id: string; // deterministic hash
imageUrl: string; // e.g. "/generated/abc123.png"
parentId: string | null;
parentClick: { x: number; y: number } | null; // normalized 0–1
initialQuery: string | null; // only on page 1
}
```
---
## Recommended Stack (Node.js + Google Gemini)
```bash
mkdir explainer && cd explainer
npm init -y
npm install express sharp crypto @google/generative-ai cors dotenv
```
```
.env
GEMINI_API_KEY=your_key_here
CACHE_VERSION=v1
PORT=3000
```
---
## Server Implementation
### `server.js` — Full Reference Implementation
```javascript
import express from 'express';
import fs from 'fs';
import path from 'path';
import crypto from 'crypto';
import sharp from 'sharp';
import { GoogleGenerativeAI } from '@google/generative-ai';
import 'dotenv/config';
const app = express();
app.use(express.json());
app.use(express.static('public'));
app.use('/generated', express.static('public/generated'));
const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY);
const VERSION = process.env.CACHE_VERSION || 'v1';
const GENERATED_DIR = path.join('public', 'generated');
fs.mkdirSync(GENERATED_DIR, { recursive: true });
// ── Content-addressed IDs ──────────────────────────────────────────────────
function hashFirstPage(query) {
const normalized = query.trim().replace(/\s+/g, ' ').toLowerCase();
return crypto
.createHash('sha256')
.update(`first${VERSION}${normalized}`)
.digest('hex')
.slice(0, 32);
}
function hashChildPage(parentId, x, y) {
const rx = Math.round(x * 100) / 100;
const ry = Math.round(y * 100) / 100;
return crypto
.createHash('sha256')
.update(`child${VERSION}${parentId}${rx}${ry}`)
.digest('hex')
.slice(0, 32);
}
// ── Style description (single source of truth) ────────────────────────────
const STYLE_DESCRIPTION = `Painting style (must remain consistent across every page):
- Light warm paper background with generous margins
- Clean, even dark gray or black ink outlines, consistent thin line weight
- Soft watercolor washes, pale palette: ivory, pale green, pale blue, light gray, with restrained warm accents
- A large serif title printed at the top center of the image
- Calm, well-composed scene with breathing room
Strict exclusions:
- No decorative borders, seals, parchment aging, ornate fonts, or vintage texture
- No 3D render, photorealism, neon, dark themes, or modern app UI cards
- No dense paragraphs of text, watermarks, or tiny unreadable labels
- No tourist map roads, landmarks, transit, or "traveler-guide" framing`;
function firstPagePrompt(query) {
return `${STYLE_DESCRIPTION}
Subject: ${query}
Compose a single 16:9 illustrated explainer page about the subject above.
Let the scene's content (objects, layout, metaphor) be whatever best
explains the subject — cross-section, exploded view, timeline, anatomy,
flow, comparison, or scene — chosen to fit this specific topic.
Output a single PNG image, 16:9. Print the title clearly inside the image.`;
}
const CHILD_PAGE_PROMPT = `${STYLE_DESCRIPTION}
You are continuing an illustrated explainer book.
The provided image is the previous page. A red circle marks
the area the reader pointed at.
Generate the next page: a single 16:9 image that goes deeper
into whatever the red circle is on — zoom in, expand its inner
structure, or show its mechanism.
Critical: match the painting style of the provided image exactly
— same line weight, same paper tone, same pastel palette, same
title typography. The two pages must feel like consecutive spreads
in the same hand-drawn book.
Do NOT include the red circle or any cursor mark in the output.
Output a single PNG image, 16:9.`;
// ── Red marker compositing ─────────────────────────────────────────────────
async function compositeRedMarker(imagePath, nx, ny) {
const img = sharp(imagePath);
const { width, height } = await img.metadata();
const cx = Math.round(nx * width);
const cy = Math.round(ny * height);
const radius = Math.round(width * 0.04);
// Build SVG marker: outer ring + inner dot
const svg = `<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
<circle cx="${cx}" cy="${cy}" r="${radius}" fill="rgba(220,30,30,0.25)" stroke="red" stroke-width="${Math.max(3, radius * 0.15)}"/>
<circle cx="${cx}" cy="${cy}" r="${Math.round(radius * 0.3)}" fill="red" stroke="white" stroke-width="2"/>
</svg>`;
return img
.composite([{ input: Buffer.from(svg), blend: 'over' }])
.png()
.toBuffer();
}
// ── Image model call ───────────────────────────────────────────────────────
async function callImageModel(prompt, referenceImageBuffer = null) {
const model = genAI.getGenerativeModel({ model: 'gemini-2.0-flash-preview-image-generation' });
const parts = [{ text: prompt }];
if (referenceImageBuffer) {
parts.push({
inlineData: {
mimeType: 'image/png',
data: referenceImageBuffer.toString('base64'),
},
});
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 120_000);
try {
const result = await model.generateContent({
contents: [{ role: 'user', parts }],
generationConfig: { responseModalities: ['IMAGE'] },
});
clearTimeout(timeout);
const candidates = result.response.candidates ?? [];
for (const candidate of candidates) {
for (const part of candidate.content?.parts ?? []) {
if (part.inlineData?.mimeType?.startsWith('image/')) {
return Buffer.from(part.inlineData.data, 'base64');
}
}
}
throw new Error('No inline image in model response');
} catch (err) {
clearTimeout(timeout);
throw err;
}
}
// ── Generation queue (serialize requests) ─────────────────────────────────
let queue = Promise.resolve();
function enqueue(fn) {
queue = queue.then(fn, fn); // always advance queue even on error
return queue;
}
// ── Core generation logic ──────────────────────────────────────────────────
async function generatePage(id, prompt, referenceImageBuffer) {
const outPath = path.join(GENERATED_DIR, `${id}.png`);
// Cache hit
if (fs.existsSync(outPath) && fs.statSync(outPath).size > 0) {
return `/generated/${id}.png`;
}
const imageBytes = await callImageModel(prompt, referenceImageBuffer);
fs.writeFileSync(outPath, imageBytes);
return `/generated/${id}.png`;
}
// ── /api/page endpoint ────────────────────────────────────────────────────
const ID_REGEX = /^[0-9a-f]{32}$/;
app.post('/api/page', async (req, res) => {
const { query, parentId, parentClick } = req.body;
// Validate
if (query !== undefined) {
if (typeof query !== 'string' || query.trim().length < 1 || query.length > 300) {
return res.status(400).json({ error: 'query must be 1–300 chars' });
}
} else {
if (!ID_REGEX.test(parentId)) {
return res.status(400).json({ error: 'invalid parentId' });
}
const { x, y } = parentClick ?? {};
if (
typeof x !== 'number' || typeof y !== 'number' ||
!isFinite(x) || !isFinite(y) ||
x < 0 || x > 1 || y < 0 || y > 1
) {
return res.status(400).json({ error: 'x and y must be finite floats in [0, 1]' });
}
}
try {
const page = await enqueue(async () => {
if (query !== undefined) {
// First page
const trimmed = query.trim();
const id = hashFirstPage(trimmed);
const imageUrl = await generatePage(id, firstPagePrompt(trimmed), null);
return { id, imageUrl, parentId: null, parentClick: null, initialQuery: trimmed };
} else {
// Child page
const id = hashChildPage(parentId, parentClick.x, parentClick.y);
const parentPath = path.join(GENERATED_DIR, `${parentId}.png`);
if (!fs.existsSync(parentPath)) {
throw new Error('Parent image not found');
}
const markedBuffer = await compositeRedMarker(parentPath, parentClick.x, parentClick.y);
const imageUrl = await generatePage(id, CHILD_PAGE_PROMPT, markedBuffer);
return { id, imageUrl, parentId, parentClick, initialQuery: null };
}
});
res.json({ page });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Generation failed, try clicking elsewhere.' });
}
});
app.listen(process.env.PORT || 3000, () => {
console.log(`Explainer running on http://localhost:${process.env.PORT || 3000}`);
});
```
---
## Client Implementation
### `public/index.html`
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Drill-Down Explainer</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: #1a1a1a; color: #eee; display: flex; flex-direction: column; height: 100vh; }
#topbar { display: flex; align-items: center; gap: 12px; padding: 10px 16px; background: #111; flex-shrink: 0; }
#appname { font-weight: bold; font-size: 1.1rem; }
#counter { font-size: 0.85rem; color: #aaa; margin-right: auto; }
#topic-input { flex: 1; padding: 6px 10px; border-radius: 6px; border: none; font-size: 1rem; max-width: 420px; }
button { padding: 6px 14px; border-radius: 6px; border: none; cursor: pointer; font-size: 0.9rem; background: #444; color: #eee; }
button:disabled { opacity: 0.4; cursor: not-allowed; }
#generate-btn { background: #4a7eff; color: #fff; }
#canvas-area { flex: 1; position: relative; display: flex; align-items: center; justify-content: center; overflow: hidden; }
#page-img { max-width: 100%; max-height: 100%; cursor: crosshair; display: block; }
#loading-overlay { position: absolute; inset: 0; background: rgba(0,0,0,0.55); display: flex; align-items: center; justify-content: center; font-size: 1.2rem; display: none; }
#error-banner { background: #b00; color: #fff; padding: 6px 16px; font-size: 0.9rem; display: none; flex-shrink: 0; }
#thumbstrip { display: flex; gap: 8px; padding: 8px 16px; background: #111; overflow-x: auto; flex-shrink: 0; min-height: 72px; }
.thumb { width: 96px; height: 54px; object-fit: cover; border-radius: 4px; cursor: pointer; border: 2px solid transparent; flex-shrink: 0; }
.thumb.active { border-color: #4a7eff; }
.ripple { position: absolute; border-radius: 50%; background: rgba(255,80,80,0.5); transform: scale(0); animation: ripple 0.6s ease-out forwards; pointer-events: none; width: 40px; height: 40px; margin: -20px; }
@keyframes ripple { to { transform: scale(3); opacity: 0; } }
</style>
</head>
<body>
<div id="topbar">
<span id="appname">🔍 Explainer</span>
<span id="counter"></span>
<input id="topic-input" placeholder="Type a topic…" maxlength="300" />
<button id="generate-btn">Generate</button>
<button id="back-btn" disabled>← Back</button>
<button id="reset-btn" disabled>Reset</button>
</div>
Ver no GitHub