VisualProduct
Batches story
Dot animation of batch processing and progress.
$ npx context-ds add story-batchesUsage
Use it for
The Batches empty state, above its heading. Plays only on screen; shows its resting picture with reduced motion.
Avoid
- Stories as the main content of a page
- More than one story in an empty state
Dependencies: dot-engine
Example
examples/story-batches.tsx
import { BatchesStory } from "@/components/visuals/stories/batches-story";export function Product() { return ( <div className="flex flex-col items-center px-8 py-16 text-center"> <BatchesStory className="w-72" /> <h2 className="mt-8 text-h4">Scrape pages in bulk</h2> <p className="mt-1 max-w-120 text-body-sm text-balance text-fg-muted"> Submit your first batch to scrape a list of URLs, or crawl a whole site, as one job you can check on later. </p> </div> );}Source
components/visuals/stories/batches-story.tsx
'use client';import { BATCHES_RESTING, BATCHES_STORY_GRID } from '@/components/visuals/stories/batches-story-scenes';import { DotStory, type LoadDotStory } from '@/components/visuals/dot-engine/dot-story';const load: LoadDotStory = async (stage) => { const { createBatchesStoryPlayer } = await import('@/components/visuals/stories/batches-story-player'); return createBatchesStoryPlayer(stage);};/** The Batches empty state's picture: a batch reads its sources, then counts through its URLs as its progress fills. */export function BatchesStory({ className }: { className?: string }) { return <DotStory grid={BATCHES_STORY_GRID} resting={BATCHES_RESTING} load={load} className={className} />;}components/visuals/stories/batches-story-player.ts
import { BATCH_SIZE, BATCHES_STORY_GRID, COUNTER, EMPTY_PROGRESS, FULL_PROGRESS, PROGRESS_CELLS, SOURCES, SOURCES_LABEL } from '@/components/visuals/stories/batches-story-scenes';import { DOT_FILLED } from '@/components/visuals/dot-engine/dot-morph-dots';import { createBeatStoryPlayer } from '@/components/visuals/dot-engine/dot-story-beats';import { cellIndexIn, clamp01 } from '@/components/visuals/dot-engine/dot-story-cells';import type { DotStoryPlayer, DotStoryStage } from '@/components/visuals/dot-engine/dot-story-player';/** * One batch, played as a loop: its sources are read, then they melt into the job, which counts up to its URLs while * its progress fills dot by dot. The full progress melts back into the sources. */const MELT = { kind: 'melts' as const, seconds: 1 };const RUN = { startsAt: 0.3, seconds: 3 };const cellIndex = cellIndexIn(BATCHES_STORY_GRID);// Slows only a little toward the end, so the count is still visibly counting when it lands on the total.const settle = (t: number) => 1 - (1 - t) ** 1.6;const ran = (heldSeconds: number) => settle(clamp01((heldSeconds - RUN.startsAt) / RUN.seconds));export function createBatchesStoryPlayer(stage: DotStoryStage): DotStoryPlayer { return createBeatStoryPlayer(stage, { grid: BATCHES_STORY_GRID, resting: { beat: 0, heldSeconds: 2 }, beats: [ { picture: SOURCES, arrival: MELT, holdSeconds: 2.4, write: (pen) => pen.text(SOURCES_LABEL) }, { picture: EMPTY_PROGRESS, arrival: MELT, holdSeconds: RUN.startsAt + RUN.seconds + 1.4, animate: (states, heldSeconds) => { const done = Math.round(ran(heldSeconds) * PROGRESS_CELLS.length); for (let step = 0; step < done; step++) states[cellIndex(PROGRESS_CELLS[step])] = DOT_FILLED; }, departure: FULL_PROGRESS, write: (pen, heldSeconds) => pen.text({ ...COUNTER, text: `${Math.round(ran(heldSeconds) * BATCH_SIZE).toLocaleString('en-US')} URLs` }), }, ], });}components/visuals/stories/batches-story-scenes.ts
import { DOT_ABSENT, DOT_FILLED, DOT_HOLLOW, type DotGrid } from '@/components/visuals/dot-engine/dot-morph-dots';import { pictureOf, type StoryPicture } from '@/components/visuals/dot-engine/dot-story-beats';import { cellStates, cellsOfStates, fillCells, rectangle, ringsWithSpeckle, solidWithSpeckle, stack, type DotPainter, type Region } from '@/components/visuals/dot-engine/dot-story-cells';import type { DotStoryText } from '@/components/visuals/dot-engine/dot-story-player';/** * The Batches empty state's pictures, ported from the website's Research and "Run batches at scale" cards: the * sources a batch reads, then the job counting its way through its URLs while its progress fills dot by dot. */export const BATCHES_STORY_GRID: DotGrid = { columns: 58, rows: 38, pitch: 10, dotRadius: 3.2, ringStroke: 1, haloRadius: 0 };const { columns: COLUMNS, pitch: PITCH } = BATCHES_STORY_GRID;const x = (column: number) => column * PITCH;const y = (row: number) => row * PITCH;// Doto sits a third of its size below the middle of the row it is set on.const baselineOn = (row: number, size: number) => (row + 0.5) * PITCH + size / 3;const BOOK = { columns: 40, rows: 8, spine: 4, pages: 3 };const BOOKS = [ { left: 10, top: 3, isDark: true }, { left: 7, top: 13, isDark: false }, { left: 11, top: 23, isDark: true },];// A book lying flat: a solid spine, the page edges as rings at its other end, and a title band across its cover.const book = ({ left, top, isDark }: (typeof BOOKS)[number]): DotPainter => rectangle({ left, right: left + BOOK.columns - 1, top, bottom: top + BOOK.rows - 1 }, (column, row) => { if (column < left + BOOK.spine) return DOT_FILLED; if (column >= left + BOOK.columns - BOOK.pages) return row % 2 === 0 ? DOT_HOLLOW : DOT_ABSENT; const isTitle = (row === top + 3 || row === top + 4) && column >= left + BOOK.spine + 4 && column < left + BOOK.spine + 20; if (isTitle) return isDark ? DOT_HOLLOW : DOT_FILLED; return isDark ? solidWithSpeckle(column, row) : ringsWithSpeckle(column, row); });export const SOURCES = pictureOf(BATCHES_STORY_GRID, stack(...BOOKS.map(book)));export const SOURCES_LABEL: DotStoryText = { text: 'Reading 3 sources', x: x(COLUMNS / 2), y: baselineOn(34, 18), size: 18, weight: 700, muted: true, anchor: 'middle' };const pictureOfStates = (states: Uint8Array): StoryPicture => ({ states, cells: cellsOfStates(BATCHES_STORY_GRID, states) });export const BATCH_SIZE = 25000;/** One dot per share of the batch, filled in reading order as the job runs. */const PROGRESS: Region = { left: 4, top: 20, columns: 50, rows: 8 };export const PROGRESS_CELLS = fillCells(PROGRESS, false);export const EMPTY_PROGRESS = pictureOfStates(cellStates(BATCHES_STORY_GRID, PROGRESS_CELLS));export const FULL_PROGRESS = pictureOfStates(cellStates(BATCHES_STORY_GRID, fillCells(PROGRESS, true)));export const COUNTER: DotStoryText = { text: `${BATCH_SIZE.toLocaleString('en-US')} URLs`, x: x(COLUMNS / 2), y: y(15), size: 64, weight: 800, anchor: 'middle' };/** The sources being read: what the picture shows before it plays, and instead of playing with reduced motion. */export const BATCHES_RESTING = { cells: SOURCES.cells, texts: [SOURCES_LABEL] };Reference: context-dashboard c0ddf7a src/components/batches/batches-story.tsx.