illustrated-explainer-spec
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.
What this skill does
# 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(paRelated in Web Dev
generating-lwc-components
IncludedLightning Web Components with PICKLES methodology and 165-point scoring. Use this skill when the user creates or edits LWC components, builds wire service patterns, or writes Jest tests for LWC. TRIGGER when: user creates/edits LWC components, touches lwc/**/*.js, .html, .css, .js-meta.xml files, or asks about wire service, SLDS, or Jest LWC tests. DO NOT TRIGGER when: Apex classes (use generating-apex), Aura components, or Visualforce.
tanstack-query
IncludedManage server state in React with TanStack Query v5. Set up queries with useQuery, mutations with useMutation, configure QueryClient caching strategies, implement optimistic updates, and handle infinite scroll with useInfiniteQuery. Use when: setting up data fetching in React projects, migrating from v4 to v5, or fixing object syntax required errors, query callbacks removed issues, cacheTime renamed to gcTime, isPending vs isLoading confusion, keepPreviousData removed problems.
document-processor-api
IncludedProcess documents with Nutrient DWS. Use when the user wants to generate PDFs from HTML or URLs, convert Office/images/PDFs, assemble or split packets, OCR scans, extract text/tables/key-value pairs, redact PII, watermark, sign, fill forms, optimize PDFs, or produce compliance outputs like PDF/A or PDF/UA. Triggers include convert to PDF, merge these PDFs, OCR this scan, extract tables, redact PII, sign this PDF, make this PDF/A, or linearize for web delivery.
nutrient-document-processing
IncludedProcess documents with Nutrient DWS. Use when the user wants to generate PDFs from HTML or URLs, convert Office/images/PDFs, assemble or split packets, OCR scans, extract text/tables/key-value pairs, redact PII, watermark, sign, fill forms, optimize PDFs, or produce compliance outputs like PDF/A or PDF/UA. Triggers include convert to PDF, merge these PDFs, OCR this scan, extract tables, redact PII, sign this PDF, make this PDF/A, or linearize for web delivery.
tanstack-query
IncludedManage server state in React with TanStack Query v5. Covers useMutationState, simplified optimistic updates, throwOnError, network mode (offline/PWA), and infiniteQueryOptions. Use when setting up data fetching, fixing v4→v5 migration errors (object syntax, gcTime, isPending, keepPreviousData), or debugging SSR/hydration issues with streaming server components.
accelint-nextjs-best-practices
IncludedNext.js performance optimization and best practices. Use when writing Next.js code (App Router or Pages Router); implementing Server Components, Server Actions, or API routes; optimizing RSC serialization, data fetching, or server-side rendering; reviewing Next.js code for performance issues; fixing authentication in Server Actions; or implementing Suspense boundaries, parallel data fetching, or request deduplication.