Collect storyDesign system
VisualSite

Collect story

Dot animation of job progress, collected results, and a completion webhook.

npx context-ds add story-collect
On the site

Usage

Use it for

Batch or dataset jobs on a site card. Plays only on screen; shows the full ring with reduced motion.

Avoid

  • Stories as the main content of a page

Dependencies: dot-engine

Example

examples/story-collect.tsx
import { Card } from "@/components/ui/card";import { CollectStory } from "@/components/visuals/stories/collect-story";export function Site() {  return (    <Card variant="ring" className="min-h-[22rem] w-full max-w-xl items-center justify-center">      <div className="relative w-full max-w-[26rem]">        <CollectStory />      </div>    </Card>  );}

Source

components/visuals/stories/collect-story.tsx
'use client';import { COLLECT_RESTING, COLLECT_STORY_GRID } from '@/components/visuals/stories/collect-story-scenes';import { DotStory, type LoadDotStory } from '@/components/visuals/dot-engine/dot-story';const loadCollectStory: LoadDotStory = async (stage) => {	const { createCollectStoryPlayer } = await import('@/components/visuals/stories/collect-story-player');	return createCollectStoryPlayer(stage);};/** * The "Collect the dataset" card's picture: a ring fills as the job runs, becomes the results a page at a time, * then the paper plane of the completion webhook. With reduced motion it shows the full ring. */export function CollectStory({ className }: { className?: string }) {	return <DotStory grid={COLLECT_STORY_GRID} resting={COLLECT_RESTING} load={loadCollectStory} className={className} />;}
components/visuals/stories/collect-story-player.ts
import { CAPTIONS, COLLECT_STORY_GRID, PAGES_SHOWN, PROGRESS_DONE, PROGRESS_EMPTY, RESULTS_FIRST_PAGE, RESULTS_LAST_PAGE, WEBHOOK_PLANE, paintProgress, paintResults } from '@/components/visuals/stories/collect-story-scenes';import { captionedBeat } from '@/components/visuals/dot-engine/story-caption';import { createBeatStoryPlayer } from '@/components/visuals/dot-engine/dot-story-beats';import { clamp01 } from '@/components/visuals/dot-engine/dot-story-cells';import type { DotStoryPlayer, DotStoryStage } from '@/components/visuals/dot-engine/dot-story-player';/** * One job's collection, played as a loop: a ring fills as the job runs to completion, melts into the results, * turned a page at a time, and those melt into the paper plane of the webhook that says the job is done. */const RUNNING = { startsAt: 0.3, seconds: 2 };const PAGING = { startsAt: 0.9, everySeconds: 0.8 };const doneAt = (heldSeconds: number) => clamp01((heldSeconds - RUNNING.startsAt) / RUNNING.seconds);const pageAt = (heldSeconds: number) => (heldSeconds < PAGING.startsAt ? 0 : Math.min(PAGES_SHOWN - 1, Math.floor((heldSeconds - PAGING.startsAt) / PAGING.everySeconds) + 1));export function createCollectStoryPlayer(stage: DotStoryStage): DotStoryPlayer {	const [statusLabel, percent, status] = CAPTIONS.progress;	const [resultsLabel, page, pageDetail] = CAPTIONS.results;	return createBeatStoryPlayer(stage, {		grid: COLLECT_STORY_GRID,		resting: { beat: 0, heldSeconds: RUNNING.startsAt + RUNNING.seconds + 0.6 },		beats: [			{				...captionedBeat({ picture: PROGRESS_EMPTY, departure: PROGRESS_DONE, caption: CAPTIONS.progress, holdSeconds: RUNNING.startsAt + RUNNING.seconds + 1.2, animate: (states, heldSeconds) => void paintProgress(states, doneAt(heldSeconds)) }),				write(pen, heldSeconds) {					const done = doneAt(heldSeconds);					pen.text(statusLabel);					pen.text({ ...percent, text: `${Math.round(done * 100)}%` });					pen.text({ ...status, text: done < 1 ? 'running' : 'completed' });				},			},			{				...captionedBeat({ picture: RESULTS_FIRST_PAGE, departure: RESULTS_LAST_PAGE, caption: CAPTIONS.results, holdSeconds: PAGING.startsAt + (PAGES_SHOWN - 1) * PAGING.everySeconds + 0.8, animate: (states, heldSeconds) => void paintResults(states, pageAt(heldSeconds)) }),				write(pen, heldSeconds) {					pen.text(resultsLabel);					pen.text({ ...page, text: `page ${pageAt(heldSeconds) + 1}` });					pen.text(pageDetail);				},			},			captionedBeat({ picture: WEBHOOK_PLANE, caption: CAPTIONS.webhook, holdSeconds: 2.4, isTyped: true }),		],	});}
components/visuals/stories/collect-story-scenes.ts
import { storyCaption } from '@/components/visuals/dot-engine/story-caption';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 { cellsOfStates, paintShape, polygon, roundedBox, solidWithSpeckle, type DotBox, type DotShape } from '@/components/visuals/dot-engine/dot-story-cells';/** * The "Collect the dataset" card's pictures, in the brand dot grid: a ring that fills as the job runs, the results * a page at a time, and the paper plane of the webhook sent when the job is done. */// The ring pattern behind the picture already keeps clear of it, so the dots need no halo of their own.export const COLLECT_STORY_GRID: DotGrid = { columns: 58, rows: 38, pitch: 10, dotRadius: 3.2, ringStroke: 1, haloRadius: 0 };const { columns: COLUMNS, rows: ROWS } = COLLECT_STORY_GRID;const pictureOfStates = (states: Uint8Array): StoryPicture => ({ states, cells: cellsOfStates(COLLECT_STORY_GRID, states) });const blank = () => new Uint8Array(COLUMNS * ROWS);const between = (value: number, from: number, to: number) => value >= from && value <= to;const RING = { center: { column: 16, row: 19 }, outer: 13.4, inner: 7.6 };const isRing: DotShape = (column, row) => between(Math.hypot(column - RING.center.column, row - RING.center.row), RING.inner, RING.outer);/** The progress ring with `done` of it filled, clockwise from the top. */export function paintProgress(states: Uint8Array, done: number) {	return paintShape(COLLECT_STORY_GRID, states, isRing, (column, row) => {		const round = (Math.atan2(column - RING.center.column, RING.center.row - row) / (Math.PI * 2) + 1) % 1;		return round < done ? DOT_FILLED : DOT_HOLLOW;	});}export const PROGRESS_EMPTY = pictureOfStates(paintProgress(blank(), 0));export const PROGRESS_DONE = pictureOfStates(paintProgress(blank(), 1));const TABLE: DotBox = { left: 4, right: 28, top: 3, bottom: 34 };const TABLE_PARTS = { header: [5, 6], rows: [10, 13, 16, 19, 22, 25], key: { left: 7, right: 9 }, value: { left: 12, shortest: 17, spread: 9 }, pager: { row: 30, columns: [10, 13, 16, 19, 22] } };/** The results table turned to `page`: each page has its own rows, and its dot in the pager below is the solid one. */export function paintResults(states: Uint8Array, page: number) {	return paintShape(COLLECT_STORY_GRID, states, roundedBox(TABLE, 2), (column, row) => {		if (TABLE_PARTS.header.includes(row)) return between(column, TABLE.left + 3, TABLE.right - 3) ? DOT_FILLED : DOT_ABSENT;		if (row === TABLE_PARTS.pager.row) return TABLE_PARTS.pager.columns.includes(column) ? (TABLE_PARTS.pager.columns[page] === column ? DOT_FILLED : DOT_HOLLOW) : DOT_ABSENT;		const line = TABLE_PARTS.rows.indexOf(row);		if (line < 0) return DOT_ABSENT;		if (between(column, TABLE_PARTS.key.left, TABLE_PARTS.key.right)) return DOT_FILLED;		const valueEnds = TABLE_PARTS.value.shortest + ((line * 5 + page * 7) % TABLE_PARTS.value.spread);		return between(column, TABLE_PARTS.value.left, valueEnds) ? DOT_HOLLOW : DOT_ABSENT;	});}export const PAGES_SHOWN = 3;export const RESULTS_FIRST_PAGE = pictureOfStates(paintResults(blank(), 0));export const RESULTS_LAST_PAGE = pictureOfStates(paintResults(blank(), PAGES_SHOWN - 1));const PLANE = { nose: { column: 30, row: 7 }, wing: { column: 2, row: 17 }, fold: { column: 12, row: 22 }, tail: { column: 15, row: 31 }, keel: { column: 17, row: 24 }, under: { column: 25, row: 28 } };// A paper plane, nose up and to the right: its upper wing solid, the wing folded under it in rings.const paperPlane = polygon([PLANE.nose, PLANE.wing, PLANE.fold, PLANE.tail, PLANE.keel, PLANE.under], (column, row) => {	const foldRow = PLANE.fold.row + ((PLANE.nose.row - PLANE.fold.row) * (column - PLANE.fold.column)) / (PLANE.nose.column - PLANE.fold.column);	if (Math.abs(row - foldRow) < 0.6 && column >= PLANE.fold.column) return DOT_FILLED;	return row < foldRow ? solidWithSpeckle(column, row) : DOT_HOLLOW;});export const WEBHOOK_PLANE = pictureOf(COLLECT_STORY_GRID, paperPlane);const caption = (label: string, value: string, detail: string) => storyCaption(COLLECT_STORY_GRID, 34, label, value, detail);export const CAPTIONS = {	progress: caption('job status', '0%', 'running'),	results: caption('results', 'page 1', '1,000 results a page'),	webhook: caption('webhook', 'sent', 'batch completed'),};/** The full ring: what the picture shows before it plays, and instead of playing with reduced motion. */export const COLLECT_RESTING = { ...PROGRESS_DONE, texts: [CAPTIONS.progress[0], { ...CAPTIONS.progress[1], text: '100%' }, { ...CAPTIONS.progress[2], text: 'completed' }] };

Reference: site src/components/sections/products/collect-story.tsx.