| 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 — Daily 2026 Skills collection.
What This Project Is
A spec (not a library) for building a locally-run single-page web app where:
- User types a topic → AI generates a 16:9 watercolor-style illustrated explainer page
- User clicks anywhere on the image → AI generates a "drill-into" next page for that spot
- This repeats infinitely, preserving painting style across all pages
- 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
interface Page {
id: string;
imageUrl: string;
parentId: string | null;
parentClick: { x: number; y: number } | null;
initialQuery: string | null;
}
Recommended Stack (Node.js + Google Gemini)
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
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 });
() {
normalized = query.().(, ).();
crypto
.()
.()
.()
.(, );
}
() {
rx = .(x * ) / ;
ry = .(y * ) / ;
crypto
.()
.()
.()
.(, );
}
= ;
() {
;
}
= ;
() {
img = (imagePath);
{ width, height } = img.();
cx = .(nx * width);
cy = .(ny * height);
radius = .(width * );
svg = ;
img
.([{ : .(svg), : }])
.()
.();
}
() {
model = genAI.({ : });
parts = [{ : prompt }];
(referenceImageBuffer) {
parts.({
: {
: ,
: referenceImageBuffer.(),
},
});
}
controller = ();
timeout = ( controller.(), );
{
result = model.({
: [{ : , parts }],
: { : [] },
});
(timeout);
candidates = result.. ?? [];
( candidate candidates) {
( part candidate.?. ?? []) {
(part.?.?.()) {
.(part.., );
}
}
}
();
} (err) {
(timeout);
err;
}
}
queue = .();
() {
queue = queue.(fn, fn);
queue;
}
() {
outPath = path.(, );
(fs.(outPath) && fs.(outPath). > ) {
;
}
imageBytes = (prompt, referenceImageBuffer);
fs.(outPath, imageBytes);
;
}
= ;
app.(, (req, res) => {
{ query, parentId, parentClick } = req.;
(query !== ) {
( query !== || query.(). < || query. > ) {
res.().({ : });
}
} {
(!.(parentId)) {
res.().({ : });
}
{ x, y } = parentClick ?? {};
(
x !== || y !== ||
!(x) || !(y) ||
x < || x > || y < || y >
) {
res.().({ : });
}
}
{
page = ( () => {
(query !== ) {
trimmed = query.();
id = (trimmed);
imageUrl = (id, (trimmed), );
{ id, imageUrl, : , : , : trimmed };
} {
id = (parentId, parentClick., parentClick.);
parentPath = path.(, );
(!fs.(parentPath)) {
();
}
markedBuffer = (parentPath, parentClick., parentClick.);
imageUrl = (id, , markedBuffer);
{ id, imageUrl, parentId, parentClick, : };
}
});
res.({ page });
} (err) {
.(err);
res.().({ : });
}
});
app.(process.. || , {
.();
});
Client Implementation
public/index.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; }
{ : ; : ; : ; : none; : ; : ; }
{ : ; : ; : none; : pointer; : ; : ; : ; }
{ : ; : not-allowed; }
{ : ; : ; }
{ : ; : relative; : flex; : center; : center; : hidden; }
{ : ; : ; : crosshair; : block; }
{ : absolute; : ; : (,,,); : flex; : center; : center; : ; : none; }
{ : ; : ; : ; : ; : none; : ; }
{ : flex; : ; : ; : ; : auto; : ; : ; }
{ : ; : ; : cover; : ; : pointer; : solid transparent; : ; }
{ : ; }
{ : absolute; : ; : (,,,); : (); : ripple ease-out forwards; : none; : ; : ; : -; }
ripple { { : (); : ; } }
🔍 Explainer
Generate
← Back
Reset
Generating the next page…
Configuration
Environment Variables
| Variable | Required | Description |
|---|
GEMINI_API_KEY | Yes | Google Gemini API key |
CACHE_VERSION | No | Bump to invalidate all caches (default: v1) |
PORT | No | Server port (default: 3000) |
package.json
{
"type": "module",
"scripts": {
"start": "node server.js",
"dev": "node --watch server.js"
}
}
Running
export GEMINI_API_KEY=your_key_here
npm start
npm run dev
Alternative: OpenAI gpt-image-1
If using OpenAI instead of Gemini, replace callImageModel:
import OpenAI from 'openai';
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
async function callImageModel(prompt, referenceImageBuffer = null) {
if (!referenceImageBuffer) {
const res = await openai.images.generate({
model: 'gpt-image-1',
prompt,
size: '1792x1024',
response_format: 'b64_json',
});
return Buffer.from(res.data[0].b64_json, 'base64');
}
const { toFile } = await import('openai');
const imageFile = await toFile(referenceImageBuffer, 'parent.png', { type: 'image/png' });
const res = await openai.images.edit({
model: ,
: imageFile,
prompt,
: ,
: ,
});
.(res.[]., );
}
Common Patterns
Invalidating the Cache
Bump CACHE_VERSION in .env:
CACHE_VERSION=v2
All new requests will compute new hashes and regenerate. Old files in public/generated/ can be deleted manually.
Inspecting Cached Files
ls public/generated/
Testing Cache Hit (no model call)
curl -X POST http://localhost:3000/api/page \
-H 'Content-Type: application/json' \
-d '{"query":"how volcanoes work"}'
curl -X POST http://localhost:3000/api/page \
-H 'Content-Type: application/json' \
-d '{"query":"how volcanoes work"}'
Testing Child Page
curl -X POST http://localhost:3000/api/page \
-H 'Content-Type: application/json' \
-d '{"parentId":"<id-from-first-page>","parentClick":{"x":0.5,"y":0.5}}'
Acceptance Checklist
From the spec §12 — verify each:
Troubleshooting
| Problem | Fix |
|---|
No inline image in model response | Model returned text only; check model name supports image output and responseModalities: ['IMAGE'] is set |
| Style drifts across pages | Ensure STYLE_DESCRIPTION is one const — never duplicated or paraphrased in prompts |
| Red marker not visible on dark images | Increase ring radius (width * 0.05) or add white stroke on outer ring |
| Second click fires before first finishes | Check serialization queue — both requests must be inside enqueue() |
| Cache miss after server restart | Verify CACHE_VERSION hasn't changed and public/generated/ is not being cleaned on start |
Parent image not found 500 error | Client sent a parentId for a page whose PNG was deleted; clear state and start over |
| Images too slow | Add a lightweight loading progress bar; generation typically takes 10–30s per page |