Claude
Skills
Sign in
Back

illustrated-explainer-spec

Included with Lifetime
$97 forever

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.

Web Dev

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(pa

Related in Web Dev