Scrape storyDesign system
VisualSite

Scrape story

Dot animation of a URL producing Markdown, images, and a screenshot.

npx context-ds add story-scrape
On the site

Usage

Use it for

Scraping on a site card or product hero. Plays only on screen; shows its resting picture with reduced motion.

Avoid

  • Stories as the main content of a page

Dependencies: dot-engine

Example

examples/story-scrape.tsx
import { Card } from "@/components/ui/card";import { ScrapeStory } from "@/components/visuals/stories/scrape-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]">        <ScrapeStory />      </div>    </Card>  );}

Source

components/visuals/stories/scrape-story.tsx
'use client';import { SCRAPE_RESTING, SCRAPE_STORY_GRID } from '@/components/visuals/stories/scrape-story-scenes';import { DotStory, type LoadDotStory } from '@/components/visuals/dot-engine/dot-story';const loadScrapeStory: LoadDotStory = async (stage) => {	const { createScrapeStoryPlayer } = await import('@/components/visuals/stories/scrape-story-player');	return createScrapeStoryPlayer(stage);};/** * The "Scrape anything" card's picture: a URL is typed, its Markdown streams out, and the page is captured as a * screenshot with its images lifted out. With reduced motion it shows everything delivered. */export function ScrapeStory({ className }: { className?: string }) {	return <DotStory grid={SCRAPE_STORY_GRID} resting={SCRAPE_RESTING} load={loadScrapeStory} className={className} />;}
components/visuals/stories/scrape-story-player.ts
import { CONTENT_LINES, FRAME_CELLS, IMAGE_CELLS, IMAGE_TILES, LABELS, PAGE_CELLS, SCRAPE_RESTING, SCRAPE_STORY_GRID, SCREENSHOT, SCREENSHOT_BORDER, URL_TEXT, cellIndex, cellState, type Region } from '@/components/visuals/stories/scrape-story-scenes';import type { GlyphCell } from '@/components/visuals/dot-engine/dot-glyph-cells';import { createGlyphShader } from '@/components/visuals/dot-engine/dot-glyph-morph';import { DOT_ABSENT, DOT_FILLED, DOT_HOLLOW } from '@/components/visuals/dot-engine/dot-morph-dots';import { createDotStoryPlayer, dotStoryFont, paintDotStoryCursor, paintDotStoryText, type DotStoryPlayer, type DotStoryStage, type DotStoryText } from '@/components/visuals/dot-engine/dot-story-player';/** * One request, played as a loop: the URL is typed, its Markdown streams into the output panel, and meanwhile a * marquee is dragged out over the page, the page loads inside it, a flash captures it, and its images are lifted out * below. Then the results clear and the request is made again. */const URL_TYPING = { startsAt: 0.4, charactersPerSecond: 11 };const CONTENT_TYPING = { startsAt: 1.6, charactersPerSecond: 45 };const MARQUEE = { startsAt: 1.6, seconds: 0.8, marchesPerSecond: 8, dash: 3 };const PAGE_LOAD = { startsAt: 2.5, seconds: 0.7 };const FLASH = { startsAt: 3.8, litSeconds: 0.12, glowSeconds: 0.4, glowOpacity: 0.3 };const IMAGES = { startsAt: 4.6, staggerSeconds: 0.35, seconds: 0.5 };const SHIMMERS = [	{ startsAt: 6.2, seconds: 1.2 },	{ startsAt: 8.4, seconds: 0.8 },];const SHIMMER_WIDTH = 1.5;const CLEAR = { startsAt: 9.2, textSeconds: 0.3, seconds: 0.8 };const LOOP_SECONDS = 10.4;// Everything has arrived and no shimmer is passing: the picture the static art shows.const RESTING_AT_SECONDS = 8;// The last dot of a reveal appears this far into it, leaving the rest for it to settle.const RIPPLE_SHARE = 0.7;const CURSOR_LINGERS_SECONDS = 0.5;const CURSOR_GAP = 3;const { columns, rows, pitch } = SCRAPE_STORY_GRID;const clamp01 = (value: number) => Math.min(1, Math.max(0, value));const easeOut = (t: number) => 1 - (1 - t) ** 3;function statesOf(cells: GlyphCell[]) {	const states = new Uint8Array(columns * rows);	for (const cell of cells) states[cellIndex(cell)] = cellState(cell);	return states;}const FRAME_STATES = statesOf(FRAME_CELLS);const RESTING_STATES = statesOf(SCRAPE_RESTING.cells);const MEDIA_CELLS = [...SCREENSHOT_BORDER, ...PAGE_CELLS, ...IMAGE_CELLS.flat()];const SHIMMERING_CELLS = MEDIA_CELLS.filter((cell) => !cell.filled);const DIAGONALS = SHIMMERING_CELLS.map((cell) => cell.column + cell.row);const FIRST_DIAGONAL = Math.min(...DIAGONALS) - SHIMMER_WIDTH;const LAST_DIAGONAL = Math.max(...DIAGONALS) + SHIMMER_WIDTH;const perimeterLength = (width: number, height: number) => Math.max(1, (width + height) * 2);// The marquee's border as a walk round its rectangle, so the dashes can march along it.function perimeterStep(column: number, row: number, width: number, height: number) {	if (row === 0) return column;	if (column === width) return width + row;	if (row === height) return width + height + (width - column);	return width * 2 + height + (height - row);}const centerOf = ({ left, top, columns: width, rows: height }: Region) => ({ column: left + (width - 1) / 2, row: top + (height - 1) / 2 });const farthestFrom = (region: Region) => Math.hypot((region.columns - 1) / 2, (region.rows - 1) / 2);export function createScrapeStoryPlayer({ canvas, dotColor, surfaceColor, fontFamily, onFirstFrame }: DotStoryStage): DotStoryPlayer {	const states = new Uint8Array(columns * rows);	const meltAway = createGlyphShader({ grid: SCRAPE_STORY_GRID, box: { left: 0, top: 0, cellsPerSide: Math.max(columns, rows) }, cells: MEDIA_CELLS, drawTarget: () => {} });	// The canvas draws with whatever is loaded at the time, so the fonts are asked for before any text is typed.	for (const text of [URL_TEXT, ...LABELS, ...CONTENT_LINES]) void document.fonts.load(dotStoryFont(text, fontFamily), text.text);	const paintMarquee = (seconds: number) => {		const grown = easeOut(clamp01((seconds - MARQUEE.startsAt) / MARQUEE.seconds));		const width = Math.round((SCREENSHOT.columns - 1) * grown);		const height = Math.round((SCREENSHOT.rows - 1) * grown);		const march = Math.floor((seconds - MARQUEE.startsAt) * MARQUEE.marchesPerSecond);		const length = perimeterLength(width, height);		for (let row = 0; row <= height; row++) {			for (let column = 0; column <= width; column++) {				if (row !== 0 && row !== height && column !== 0 && column !== width) continue;				const step = (perimeterStep(column, row, width, height) + length - (march % length)) % length;				if (step % MARQUEE.dash !== MARQUEE.dash - 1) states[cellIndex({ column: SCREENSHOT.left + column, row: SCREENSHOT.top + row })] = DOT_FILLED;			}		}	};	const paintPage = (seconds: number) => {		const loaded = (seconds - PAGE_LOAD.startsAt) / PAGE_LOAD.seconds;		for (const cell of PAGE_CELLS) {			if (((cell.row - SCREENSHOT.top) / SCREENSHOT.rows) * RIPPLE_SHARE <= loaded) states[cellIndex(cell)] = cellState(cell);		}	};	const paintFlash = () => {		for (let row = 1; row < SCREENSHOT.rows - 1; row++) {			for (let column = 1; column < SCREENSHOT.columns - 1; column++) states[cellIndex({ column: SCREENSHOT.left + column, row: SCREENSHOT.top + row })] = DOT_FILLED;		}	};	const paintImages = (seconds: number) => {		IMAGE_TILES.forEach((tile, index) => {			const arrived = (seconds - IMAGES.startsAt - index * IMAGES.staggerSeconds) / IMAGES.seconds;			if (arrived <= 0) return;			const center = centerOf(tile);			const reach = (arrived / RIPPLE_SHARE) * farthestFrom(tile);			for (const cell of IMAGE_CELLS[index]) {				if (Math.hypot(cell.column - center.column, cell.row - center.row) <= reach) states[cellIndex(cell)] = cellState(cell);			}		});	};	const paintShimmer = (seconds: number) => {		const shimmer = SHIMMERS.find(({ startsAt, seconds: length }) => seconds >= startsAt && seconds < startsAt + length);		if (!shimmer) return;		const crest = FIRST_DIAGONAL + ((seconds - shimmer.startsAt) / shimmer.seconds) * (LAST_DIAGONAL - FIRST_DIAGONAL);		SHIMMERING_CELLS.forEach((cell, index) => {			if (Math.abs(DIAGONALS[index] - crest) < SHIMMER_WIDTH && states[cellIndex(cell)] === DOT_HOLLOW) states[cellIndex(cell)] = DOT_FILLED;		});	};	const paintMedia = (seconds: number) => {		if (seconds >= CLEAR.startsAt) {			const melted = meltAway({ morph: clamp01((seconds - CLEAR.startsAt) / CLEAR.seconds), spin: 0 });			for (const cell of MEDIA_CELLS) states[cellIndex(cell)] = melted[cellIndex(cell)];			return;		}		if (seconds < MARQUEE.startsAt) return;		if (seconds < FLASH.startsAt) paintMarquee(seconds);		else for (const cell of SCREENSHOT_BORDER) states[cellIndex(cell)] = DOT_FILLED;		if (seconds >= FLASH.startsAt && seconds < FLASH.startsAt + FLASH.litSeconds) paintFlash();		else paintPage(seconds);		paintImages(seconds);		paintShimmer(seconds);	};	const typed = (text: DotStoryText, secondsTyping: number, charactersPerSecond: number): DotStoryText => ({ ...text, text: text.text.slice(0, Math.max(0, Math.floor(secondsTyping * charactersPerSecond))) });	const paintText = (context: CanvasRenderingContext2D, seconds: number) => {		for (const label of LABELS) paintDotStoryText(context, fontFamily, label);		const presence = 1 - clamp01((seconds - CLEAR.startsAt) / CLEAR.textSeconds);		const url = typed(URL_TEXT, seconds - URL_TYPING.startsAt, URL_TYPING.charactersPerSecond);		paintDotStoryText(context, fontFamily, url, presence);		const typedUntil = URL_TYPING.startsAt + URL_TEXT.text.length / URL_TYPING.charactersPerSecond;		if (seconds < typedUntil + CURSOR_LINGERS_SECONDS) {			context.font = dotStoryFont(URL_TEXT, fontFamily);			paintDotStoryCursor(context, URL_TEXT.x + context.measureText(url.text).width + CURSOR_GAP, URL_TEXT);		}		let secondsTyping = seconds - CONTENT_TYPING.startsAt;		for (const line of CONTENT_LINES) {			paintDotStoryText(context, fontFamily, typed(line, secondsTyping, CONTENT_TYPING.charactersPerSecond), presence);			secondsTyping -= line.text.length / CONTENT_TYPING.charactersPerSecond;		}	};	const paintGlow = (context: CanvasRenderingContext2D, seconds: number) => {		const glow = 1 - (seconds - FLASH.startsAt) / FLASH.glowSeconds;		if (glow <= 0 || glow > 1) return;		context.globalAlpha = FLASH.glowOpacity * glow;		context.fillRect(SCREENSHOT.left * pitch, SCREENSHOT.top * pitch, SCREENSHOT.columns * pitch, SCREENSHOT.rows * pitch);		context.globalAlpha = 1;	};	return createDotStoryPlayer({		canvas,		dotColor,		surfaceColor,		onFirstFrame,		grid: SCRAPE_STORY_GRID,		resting: { states: RESTING_STATES, atSeconds: RESTING_AT_SECONDS },		frameAt(storySeconds) {			const seconds = storySeconds % LOOP_SECONDS;			states.fill(DOT_ABSENT);			paintMedia(seconds);			for (const cell of FRAME_CELLS) states[cellIndex(cell)] = FRAME_STATES[cellIndex(cell)];			return {				states,				paintAbove(context) {					paintText(context, seconds);					paintGlow(context, seconds);				},			};		},	});}
components/visuals/stories/scrape-story-scenes.ts
import { glyphCellsFromRows, type GlyphCell } from '@/components/visuals/dot-engine/dot-glyph-cells';import { DOT_FILLED, DOT_HOLLOW, type DotGrid } from '@/components/visuals/dot-engine/dot-morph-dots';import type { DotStoryText } from '@/components/visuals/dot-engine/dot-story-player';/** * The "Scrape anything" card's picture, in the brand dot grid: a URL goes in at the top, its Markdown comes out on the * left, and on the right the page is captured as a screenshot and its images are lifted out. */// The ring pattern behind the picture already keeps clear of it, so the dots need no halo of their own.export const SCRAPE_STORY_GRID: DotGrid = { columns: 58, rows: 38, pitch: 10, dotRadius: 3.2, ringStroke: 1, haloRadius: 0 };export interface Region {	left: number;	top: number;	columns: number;	rows: number;}const { columns: COLUMNS, pitch: PITCH } = SCRAPE_STORY_GRID;const INPUT: Region = { left: 0, top: 0, columns: COLUMNS, rows: 5 };const OUTPUT: Region = { left: 0, top: 7, columns: 34, rows: 31 };export const SCREENSHOT: Region = { left: 36, top: 7, columns: 22, rows: 14 };const GALLERY_TOP = 23;export const IMAGE_TILES: Region[] = [	{ left: 36, top: GALLERY_TOP, columns: 10, rows: 15 },	{ left: 48, top: GALLERY_TOP, columns: 10, rows: 7 },	{ left: 48, top: GALLERY_TOP + 8, columns: 10, rows: 7 },];const INPUT_ICON = { column: 2, row: 2 };const TAB_RULE_ROW = 5;const ACTIVE_TAB = { from: 2, to: 9 };function outline({ columns, rows }: Region): GlyphCell[] {	const cells: GlyphCell[] = [];	for (let row = 0; row < rows; row++) {		for (let column = 0; column < columns; column++) {			if (row === 0 || row === rows - 1 || column === 0 || column === columns - 1) cells.push({ column, row, filled: true });		}	}	return cells;}const placed = (cells: GlyphCell[], { left, top }: Region): GlyphCell[] => cells.map((cell) => ({ ...cell, column: cell.column + left, row: cell.row + top }));/** Art is drawn inside a region's outline, so its first row and column sit one cell in. */const inside = (rows: string[], region: Region) => placed(glyphCellsFromRows(rows), { ...region, left: region.left + 1, top: region.top + 1 });const tabRule: GlyphCell[] = Array.from({ length: OUTPUT.columns - 2 }, (_, index) => ({ column: index + 1, row: TAB_RULE_ROW, filled: index + 1 >= ACTIVE_TAB.from && index + 1 <= ACTIVE_TAB.to }));/** The parts that never change: the URL field and the output panel, with its tab rule. */export const FRAME_CELLS: GlyphCell[] = [...placed(outline(INPUT), INPUT), { ...INPUT_ICON, filled: false }, ...placed(outline(OUTPUT), OUTPUT), ...placed(tabRule, OUTPUT)];const PAGE_ROWS = ['##    oo oo oo    ##', '', '    ############    ', '      ########      ', '', '     oooooooooo     ', '', '       ######       ', '', 'oooooooooooooooooooo', 'oo#ooooo#oooo#oooo#o', 'oooooooooooooooooooo'];const PHOTO_ROWS = ['oooooooo', 'ooooo##o', 'ooooo##o', 'oooooooo', 'oooooooo', 'oooooooo', 'oo#ooooo', 'o###oo#o', '#####o##', '########', '########', '########', '########'];const LOGO_ROWS = ['oooo###o', 'o#oo###o', '###o###o', 'o#oooooo', 'oooooooo'];const TEXTURE_ROWS = ['########', '#o######', 'o#o#o###', 'oooo#o#o', 'oooooooo'];export const SCREENSHOT_BORDER = placed(outline(SCREENSHOT), SCREENSHOT);export const PAGE_CELLS = inside(PAGE_ROWS, SCREENSHOT);export const IMAGE_CELLS = [PHOTO_ROWS, LOGO_ROWS, TEXTURE_ROWS].map((rows, index) => [...placed(outline(IMAGE_TILES[index]), IMAGE_TILES[index]), ...inside(rows, IMAGE_TILES[index])]);export const cellIndex = ({ column, row }: Pick<GlyphCell, 'column' | 'row'>) => row * COLUMNS + column;export const cellState = (cell: GlyphCell) => (cell.filled ? DOT_FILLED : DOT_HOLLOW);const x = (column: number) => column * PITCH;const y = (row: number) => row * PITCH;const CONTENT_SIZE = 17;export const URL_TEXT: DotStoryText = { text: 'context.dev', x: x(4.5), y: y(3.2), size: 20, weight: 700 };export const CONTENT_LINES: DotStoryText[] = [	{ text: '# Web data API for AI agents', x: x(2), y: y(16), size: CONTENT_SIZE, weight: 800 },	{ text: 'Web scraping, data, and', x: x(2), y: y(21), size: CONTENT_SIZE, weight: 600 },	{ text: 'search infrastructure', x: x(2), y: y(23.8), size: CONTENT_SIZE, weight: 600 },	{ text: 'for AI products.', x: x(2), y: y(26.6), size: CONTENT_SIZE, weight: 600 },];export const LABELS: DotStoryText[] = [	{ text: 'Webpages & PDFs', x: x(COLUMNS - 2), y: y(3.05), size: 15, weight: 700, muted: true, anchor: 'end' },	{ text: 'Markdown', x: x(2), y: y(OUTPUT.top + 3.6), size: 15, weight: 800 },	{ text: 'HTML', x: x(11.5), y: y(OUTPUT.top + 3.6), size: 15, weight: 700, muted: true },	{ text: 'JSON', x: x(17), y: y(OUTPUT.top + 3.6), size: 15, weight: 700, muted: true },];/** Everything delivered: what the picture shows before it plays, and instead of playing with reduced motion. */export const SCRAPE_RESTING = {	cells: [...FRAME_CELLS, ...SCREENSHOT_BORDER, ...PAGE_CELLS, ...IMAGE_CELLS.flat()],	texts: [URL_TEXT, ...LABELS, ...CONTENT_LINES],};

Reference: site src/components/sections/home/scrape-story.tsx.