VisualSite
Scrape story
Dot animation of a URL producing Markdown, images, and a screenshot.
$ npx context-ds add story-scrapeUsage
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.