Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,3 +173,9 @@
2026-11-07 [note] Session session_01JyJzMXcc2unSAwN88hsew5 took over at 08:00 and moved the three relay routines; Netlify auto-publishing is unlocked again (the live site still served a build from early 5 November)

2026-11-07 [decision] Small drawings on laptops: Yusuf chose option 3 (a taller desktop stage first, then views that lay out for the stage); stage 305 to 387 px, Options in the side column, view(step, settings, stage) used by the 26 graph pages

2026-11-07 [decision] Catalog thumbnails: Yusuf chose animated SVG drawn from data over GIF (all themes, about 1.5 KB a page), left of each title, play on hover or focus, text stripped; pilot family Trees

2026-11-07 [code] Trees thumbnails: VizDef.preview names an example; src/render/preview.ts strips it to shapes in one frame; prerender writes dist/previews.json and the dev server makes it on request

2026-11-07 [code] Thumbnails for all 108 pages; posters show the fullest frame; input and output boxes always hidden; no arrowheads in thumbnails; graph and geometry pages hide side tables and matrix brackets
9 changes: 8 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,11 @@ everything on every push; Netlify builds and deploys `main` (`netlify.toml`).
- Compute every answer by running the model; never write one by hand.
- Answers are a choice, a number, or a sequence.
- Add `watch` operations so "Show me" can play the answer.
13. **Tests.**
13. **Thumbnail.** `preview: { example, level?, hide? }` picks the curated example the catalog
plays on hover, stripped of text. Pick one that shows the idea and ends full, not empty; use a
finer `level` when it has fewer than about 12 steps; `hide` side panels (tables, queues, call
stacks) by shape id prefix so the main picture fills the 72x44 box. Look at it on the catalog.
14. **Tests.**
- `src/viz/conformance.test.ts` checks every page automatically: narration (no `{token}`
or `undefined` leftovers), line ids, plain pseudocode, marks, unique shape ids, Big-O
rows, presets, examples, links, and no placeholder text.
Expand All @@ -121,6 +125,9 @@ everything on every push; Netlify builds and deploys `main` (`netlify.toml`).
- Every visualization has its own page, `/<viz-id>/`, written by `scripts/prerender.mjs` after
`vite build` (title, description, preview tags, canonical URL). The hash holds only state:
`#ex=<code>&embed=1&lesson=<section>`. Old `/#/<viz-id>?...` links redirect.
- Catalog thumbnails: `preview` in the `VizDef` names a curated example (and a step level, and
shape ids to hide); `src/render/preview.ts` strips it to bare shapes and `scripts/prerender.mjs`
writes `dist/previews.json`.
- The app loads each visualization on demand (`src/viz/registry.ts`); tests and prerender use
`src/viz/all.ts`.
- No analytics or tracking. No external fonts or CDNs.
Expand Down
21 changes: 18 additions & 3 deletions scripts/prerender.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// After `vite build`: write dist/<id>/index.html for every visualization, so each page has its
// own title, description and link preview, plus a sitemap and robots.txt. The app itself is the
// same on every page; it reads the page's path to decide what to show.
// own title, description and link preview, plus a sitemap, robots.txt and the catalog thumbnails
// (previews.json, previews/<id>.json). The app itself is the same on every page; it reads the
// page's path to decide what to show.
import fs from 'node:fs';
import path from 'node:path';
import { createServer } from 'vite';
Expand Down Expand Up @@ -44,6 +45,18 @@ try {
fs.writeFileSync(path.join(dist, def.id, 'index.html'), html);
}

// Catalog thumbnails: the steps of one example per page, stripped to bare shapes.
// previews.json holds the still pictures; previews/<id>.json the frames, fetched on first play.
const { makePreviews, posterOf } = await server.ssrLoadModule('/src/render/preview.ts');
const previews = makePreviews(vizzes);
fs.mkdirSync(path.join(dist, 'previews'), { recursive: true });
const posters = {};
for (const [id, p] of Object.entries(previews)) {
posters[id] = posterOf(p);
fs.writeFileSync(path.join(dist, 'previews', `${id}.json`), JSON.stringify(p));
}
fs.writeFileSync(path.join(dist, 'previews.json'), JSON.stringify(posters));

const urls = [`${SITE_URL}/`, ...vizzes.map((d) => `${SITE_URL}/${d.id}/`)];
fs.writeFileSync(
path.join(dist, 'sitemap.xml'),
Expand All @@ -55,7 +68,9 @@ try {
path.join(dist, 'robots.txt'),
`User-agent: *\nAllow: /\nSitemap: ${SITE_URL}/sitemap.xml\n`,
);
console.log(`prerendered ${vizzes.length} visualization pages`);
console.log(
`prerendered ${vizzes.length} visualization pages, ${Object.keys(previews).length} previews`,
);
} finally {
await server.close();
}
12 changes: 12 additions & 0 deletions src/engine/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,16 @@ export interface StageSize {
h: number;
}

/** The catalog thumbnail: one curated example played as bare shapes (no text). */
export interface PreviewSpec {
/** Id of the curated example to play; default the first. */
example?: string;
/** Finest step level to include; default 1 (milestones only). */
level?: Level;
/** Shape id prefixes to leave out, besides the input and output boxes every preview drops. */
hide?: string[];
}

export interface VizDef<S, Set extends Settings, X = unknown> {
id: string;
title: string;
Expand Down Expand Up @@ -254,6 +264,8 @@ export interface VizDef<S, Set extends Settings, X = unknown> {
code?: CodeTabs;
/** Generators for seeded practice sets (the Practice tab). */
practice?: PracticeKind[];
/** The animated thumbnail next to the page's title in the catalog. */
preview?: PreviewSpec;
}

// Erased form used by generic UI code.
Expand Down
36 changes: 36 additions & 0 deletions src/render/preview.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest';
import { vizzes } from '../viz/all';
import { makePreview, MAX_FRAMES } from './preview';

describe('catalog previews', () => {
it('every page asks for a thumbnail', () => {
expect(vizzes.filter((d) => !d.preview).map((d) => d.id)).toEqual([]);
});

for (const def of vizzes) {
it(`${def.id}: bare shapes that move, inside one fixed frame`, () => {
const p = makePreview(def)!;
expect(p.frames.length).toBeGreaterThanOrEqual(4);
expect(p.frames.length).toBeLessThanOrEqual(MAX_FRAMES);
expect(p.poster).toBeGreaterThanOrEqual(0);
expect(p.poster).toBeLessThan(p.frames.length);
const b = p.bounds;
const inside = (x: number, y: number) =>
x >= b.x - 0.5 && x <= b.x + b.w + 0.5 && y >= b.y - 0.5 && y <= b.y + b.h + 0.5;
let changes = 0;
p.frames.forEach((shapes, i) => {
expect(shapes.length, `frame ${i}`).toBeGreaterThan(0);
expect(new Set(shapes.map((s) => s.id)).size, `frame ${i} ids`).toBe(shapes.length);
for (const s of shapes) {
expect(s.kind).not.toBe('text');
if (s.kind === 'arrow') expect(inside(s.x1, s.y1) && inside(s.x2, s.y2), s.id).toBe(true);
else if (s.kind === 'polygon') expect(s.points.every((q) => inside(q.x, q.y))).toBe(true);
else expect(inside(s.x, s.y), s.id).toBe(true);
}
if (i > 0 && JSON.stringify(shapes) !== JSON.stringify(p.frames[i - 1])) changes++;
});
// A thumbnail that barely changes does not show the idea.
expect(changes).toBeGreaterThanOrEqual(3);
});
}
});
121 changes: 121 additions & 0 deletions src/render/preview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { normalizeSettings } from '../engine/example';
import { Timeline } from '../engine/timeline';
import type { AnyVizDef } from '../engine/types';
import { shapesBounds, type Bounds, type Shape } from './scene';

/**
* A small looping animation for the catalog: the steps of one curated example, stripped to bare
* shapes. Text is dropped (it is unreadable at thumbnail size) and one fixed frame holds every
* step, so the camera stays still while the structure grows and moves.
*/
export interface Preview {
bounds: Bounds;
frames: Shape[][];
/** The frame shown at rest: the fullest one (the latest of equals), not an emptied end state. */
poster: number;
}

/** The most frames a preview keeps; longer examples are sampled evenly, keeping the last. */
export const MAX_FRAMES = 24;

/**
* Shape ids every preview leaves out: the boxes that hold an operation's input and output. They
* carry text, so stripped they are empty frames.
*/
export const DEFAULT_HIDE = ['arg-box', 'in-box', 'out-box', 'key-box', 'result-box'];

const round = (v: number) => Math.round(v * 10) / 10;

/** Drop text, round coordinates (the JSON is served to every visitor), keep identity and role. */
function strip(shapes: Shape[], hide: string[]): Shape[] {
const out: Shape[] = [];
for (const s of shapes) {
if (s.kind === 'text' || (s.opacity ?? 1) <= 0) continue;
if (hide.some((h) => s.id.startsWith(h))) continue;
const c = { ...s } as Record<string, unknown>;
for (const k of ['x', 'y', 'w', 'h', 'r', 'x1', 'y1', 'x2', 'y2']) {
if (typeof c[k] === 'number') c[k] = round(c[k] as number);
}
if (s.kind === 'polygon') c.points = s.points.map((q) => ({ x: round(q.x), y: round(q.y) }));
if (s.enterFrom) c.enterFrom = { x: round(s.enterFrom.x), y: round(s.enterFrom.y) };
out.push(c as unknown as Shape);
}
return out;
}

function sample<T>(items: T[], n: number): T[] {
if (items.length <= n) return items;
return Array.from(
{ length: n },
(_, i) => items[Math.round((i * (items.length - 1)) / (n - 1))]!,
);
}

/** The preview a page asks for with `preview`, or null if it asks for none. */
export function makePreview(def: AnyVizDef): Preview | null {
const spec = def.preview;
if (!spec) return null;
const ex = spec.example ? def.examples.find((e) => e.id === spec.example) : def.examples[0];
if (!ex) throw new Error(`${def.id}: preview example "${spec.example}" not found`);
const settings = normalizeSettings(def.settings, def.defaults, ex.settings);
const tl = new Timeline(def, settings, ex.seed, ex.ops);
const level = spec.level ?? 1;
const steps = [];
for (let i = 0; i < tl.length; i++) {
const step = tl.at(i).step;
if (step.level <= level) steps.push(step);
}
const frames = sample(steps, MAX_FRAMES)
.map((step) => strip(def.view(step, settings).shapes, [...DEFAULT_HIDE, ...(spec.hide ?? [])]))
// Keep the drawing on screen from the first frame: an empty start shows nothing.
.filter((shapes, i, all) => shapes.length > 0 || i === all.length - 1);
const boxes = frames.map((f) => shapesBounds(f, 0)).filter((b): b is Bounds => b !== null);
if (boxes.length === 0) throw new Error(`${def.id}: preview draws nothing`);
const x1 = Math.min(...boxes.map((b) => b.x));
const y1 = Math.min(...boxes.map((b) => b.y));
const x2 = Math.max(...boxes.map((b) => b.x + b.w));
const y2 = Math.max(...boxes.map((b) => b.y + b.h));
const pad = Math.max(x2 - x1, y2 - y1) * 0.04;
const bounds = {
x: round(x1 - pad),
y: round(y1 - pad),
w: round(x2 - x1 + 2 * pad),
h: round(y2 - y1 + 2 * pad),
};
let poster = 0;
frames.forEach((f, i) => {
if (f.length >= frames[poster]!.length) poster = i;
});
return { bounds, frames, poster };
}

/**
* What the catalog loads up front: each page's still picture. The frames come only when a
* thumbnail first plays (one file per page), so phones, which have no hover, never fetch them.
*/
export interface PreviewPoster {
bounds: Bounds;
shapes: Shape[];
}

export const posterOf = (p: Preview): PreviewPoster => ({
bounds: p.bounds,
shapes: p.frames[p.poster]!,
});

/** Previews of every page that asks for one, keyed by page id. */
export function makePreviews(defs: AnyVizDef[]): Record<string, Preview> {
const out: Record<string, Preview> = {};
for (const def of defs) {
const p = makePreview(def);
if (p) out[def.id] = p;
}
return out;
}

/** The still pictures of every page, keyed by page id: /previews.json. */
export function makePosters(defs: AnyVizDef[]): Record<string, PreviewPoster> {
const out: Record<string, PreviewPoster> = {};
for (const [id, p] of Object.entries(makePreviews(defs))) out[id] = posterOf(p);
return out;
}
34 changes: 33 additions & 1 deletion src/ui/Catalog.svelte
Original file line number Diff line number Diff line change
@@ -1,12 +1,27 @@
<script lang="ts">
import { onMount } from 'svelte';
import { SITE_NAME, TAGLINE } from '../site';
import type { PreviewPoster } from '../render/preview';
import { families, isLive, pathways, type Pathway } from '../viz/catalog';
import PreviewThumb from './PreviewThumb.svelte';

// Thumbnails are made at build time (scripts/prerender.mjs; the dev server makes them on
// request). Without them the catalog is a plain list, so a failed fetch is ignored.
let posters = $state.raw<Record<string, PreviewPoster>>({});
let playing = $state<string | null>(null);

onMount(() => {
document.title = `${SITE_NAME}: ${TAGLINE}`;
fetch('/previews.json')
.then((r) => (r.ok ? r.json() : {}))
.then((p: Record<string, PreviewPoster>) => (posters = p))
.catch(() => {});
});

const stop = (id: string) => {
if (playing === id) playing = null;
};

const total = families.reduce((n, f) => n + f.items.length, 0);
const live = families.reduce((n, f) => n + f.items.filter((i) => isLive(i.id)).length, 0);

Expand Down Expand Up @@ -81,7 +96,21 @@
{@const live = isLive(item.id)}
<li class:live>
{#if live}
<a href="/{item.id}/">{item.title}</a>
{@const poster = posters[item.id]}
<a
href="/{item.id}/"
onpointerenter={() => (playing = item.id)}
onpointerleave={() => stop(item.id)}
onfocus={() => (playing = item.id)}
onblur={() => stop(item.id)}
>
{#if poster}<PreviewThumb
id={item.id}
{poster}
playing={playing === item.id}
/>{/if}
<span>{item.title}</span>
</a>
{:else}
<span class="planned">{item.title}</span>
<span class="tag">{item.advanced ? 'advanced, planned' : 'planned'}</span>
Expand Down Expand Up @@ -204,6 +233,9 @@
}

li.live a {
display: flex;
align-items: center;
gap: 10px;
font-weight: 700;
}

Expand Down
Loading
Loading