Extract storyDesign system
VisualSite

Extract story

Dot animation of a page converted to Markdown, HTML, JSON, images, and screenshots.

npx context-ds add story-extract
On the site

Usage

Use it for

The scrape or extract product on a site card. 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-extract.tsx
import { Card } from "@/components/ui/card";import { ExtractStory } from "@/components/visuals/stories/extract-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]">        <ExtractStory />      </div>    </Card>  );}

Source

components/visuals/stories/extract-story.tsx
'use client';import { EXTRACT_RESTING, EXTRACT_STORY_GRID } from '@/components/visuals/stories/extract-story-scenes';import { DotStory, type LoadDotStory } from '@/components/visuals/dot-engine/dot-story';const loadExtractStory: LoadDotStory = async (stage) => {	const { createExtractStoryPlayer } = await import('@/components/visuals/stories/extract-story-player');	return createExtractStoryPlayer(stage);};/** * The Extract card's picture: a scan reads a published page, which becomes a window with the page's Markdown typed * into it. With reduced motion it shows the Markdown. */export function ExtractStory({ className }: { className?: string }) {	return <DotStory grid={EXTRACT_STORY_GRID} resting={EXTRACT_RESTING} load={loadExtractStory} className={className} />;}
components/visuals/stories/extract-story-player.ts
import { BLANK_WINDOW, EXTRACT_STORY_GRID, FORMATS, OUTPUT_TEXT, OUTPUT_WINDOWS, PAGE, PAGE_LABELS, PUBLISHED_PAGE, tabsFor, WINDOW_ROWS, WINDOW_TOP_ROW, type Format } from '@/components/visuals/stories/extract-story-scenes';import { DOT_ABSENT, DOT_FILLED, DOT_HOLLOW } from '@/components/visuals/dot-engine/dot-morph-dots';import { createBeatStoryPlayer, typingSeconds, type StoryBeat } 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 scrape, played as a loop: a scan runs down a live webpage, reading its rings into solid dots; the page melts * into a window; then the window steps through each output format in turn, typing the text ones and building the * image and screenshot ones. */const SCAN = { startsAt: 0.5, seconds: 2.2 };const MELT_SECONDS = 1.1;const WIPE_SECONDS = 0.5;const TAB_SWITCH_SECONDS = 0.35;const TYPING = { startsAt: 0.25, charactersPerSecond: 44, pauseSeconds: 0.1 };const PICTURE_HOLD_SECONDS = 2.2;const { columns } = EXTRACT_STORY_GRID;// Everything above the scan has been read; the scan itself is a solid bar across the page.function scanPage(states: Uint8Array, heldSeconds: number) {	const scanned = clamp01((heldSeconds - SCAN.startsAt) / SCAN.seconds);	if (scanned <= 0) return;	const bar = PAGE.top + Math.round(scanned * (PAGE.bottom - PAGE.top));	for (let row = PAGE.top + 4; row <= Math.min(bar, PAGE.bottom - 1); row++) {		for (let column = PAGE.left + 1; column < PAGE.right; column++) {			const cell = row * columns + column;			if (states[cell] === DOT_HOLLOW || (row === bar && scanned < 1 && PUBLISHED_PAGE.states[cell] === DOT_ABSENT)) states[cell] = DOT_FILLED;		}	}}const topToBottom = (_column: number, row: number) => clamp01((row - WINDOW_TOP_ROW) / WINDOW_ROWS);function formatBeat(format: Format, isFirst: boolean): StoryBeat {	const texts = OUTPUT_TEXT[format];	const typed = format === 'Markdown' || format === 'HTML' || format === 'JSON' || format === 'Highlights';	const startsAt = texts.map((_, index) => TYPING.startsAt + texts.slice(0, index).reduce((total, text) => total + typingSeconds(text, TYPING.charactersPerSecond) + TYPING.pauseSeconds, 0));	const typingEnds = texts.length === 0 ? 0 : startsAt[texts.length - 1] + typingSeconds(texts[texts.length - 1], TYPING.charactersPerSecond);	return {		picture: OUTPUT_WINDOWS[format],		arrival: { kind: 'builds', seconds: isFirst ? WIPE_SECONDS : TAB_SWITCH_SECONDS, order: topToBottom },		holdSeconds: typed ? typingEnds + 0.9 : PICTURE_HOLD_SECONDS,		write(pen, heldSeconds) {			tabsFor(format).forEach((tab) => pen.text(tab));			if (typed) texts.forEach((text, index) => pen.type(text, heldSeconds, { startsAt: startsAt[index], charactersPerSecond: TYPING.charactersPerSecond }));			else texts.forEach((text) => pen.text(text));		},	};}export function createExtractStoryPlayer(stage: DotStoryStage): DotStoryPlayer {	return createBeatStoryPlayer(stage, {		grid: EXTRACT_STORY_GRID,		// It starts on the live page, so a visitor first sees what is being scraped, then each output in turn.		resting: { beat: 0, heldSeconds: 0 },		beats: [			{ picture: PUBLISHED_PAGE, arrival: { kind: 'melts', seconds: MELT_SECONDS }, holdSeconds: SCAN.startsAt + SCAN.seconds + 0.5, animate: scanPage, write: (pen) => PAGE_LABELS.forEach((label) => pen.text(label)) },			{ picture: BLANK_WINDOW, arrival: { kind: 'melts', seconds: MELT_SECONDS }, holdSeconds: 0.2 },			...FORMATS.map((format, index) => formatBeat(format, index === 0)),			{ picture: BLANK_WINDOW, arrival: { kind: 'builds', seconds: WIPE_SECONDS, order: topToBottom }, holdSeconds: 0.1 },		],	});}
components/visuals/stories/extract-story-scenes.ts
import { DOT_ABSENT, DOT_FILLED, DOT_HOLLOW, type DotGrid } from '@/components/visuals/dot-engine/dot-morph-dots';import { pictureOf } from '@/components/visuals/dot-engine/dot-story-beats';import { rectangle, rings, solidWithSpeckle, stack, within, type DotPainter } from '@/components/visuals/dot-engine/dot-story-cells';import type { DotStoryText } from '@/components/visuals/dot-engine/dot-story-player';/** * The Scrape card's pictures, in the brand dot grid: a live webpage in a browser, and the window it is scraped into, * one output format per tab (Markdown, HTML, JSON, images, a screenshot and highlights). */// The ring pattern behind the picture already keeps clear of it, so the dots need no halo of their own.export const EXTRACT_STORY_GRID: DotGrid = { columns: 58, rows: 38, pitch: 10, dotRadius: 3.2, ringStroke: 1, haloRadius: 0 };const PITCH = EXTRACT_STORY_GRID.pitch;const x = (column: number) => column * 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;interface Box {	left: number;	right: number;	top: number;	bottom: number;}const inBox = (box: Box, column: number, row: number) => column >= box.left && column <= box.right && row >= box.top && row <= box.bottom;/** * A marketing page's layout inside `box`, scaled to it: nav, a hero with a heading, copy and two buttons beside a * picture, and a row of three cards. Used for the live page and, smaller, for its screenshot. */function webpageLayout(box: Box): DotPainter {	const width = box.right - box.left;	const height = box.bottom - box.top;	const at = (share: number) => box.left + Math.round(share * width);	const down = (share: number) => box.top + Math.round(share * height);	const nav = { row: down(0.08), logoTo: at(0.1), links: [at(0.62), at(0.74), at(0.86)], linkWidth: Math.max(1, Math.round(width * 0.07)) };	const heading = { top: down(0.2), bottom: down(0.2) + Math.max(1, Math.round(height * 0.05)), to: at(0.52) };	const copy = [		{ row: down(0.36), to: at(0.56) },		{ row: down(0.44), to: at(0.46) },	];	const buttons = { top: down(0.54), bottom: down(0.54) + Math.max(1, Math.round(height * 0.04)), primaryTo: at(0.18), secondaryFrom: at(0.22), secondaryTo: at(0.38) };	const picture = { left: at(0.64), right: at(0.96), top: down(0.2), bottom: down(0.6) };	const cardsTop = down(0.7);	const cards = [0, 1, 2].map((index) => ({ left: at(0.04 + index * 0.32), right: at(0.3 + index * 0.32) }));	return (column, row) => {		if (row === nav.row) return column <= nav.logoTo || nav.links.some((from) => column >= from && column < from + nav.linkWidth) ? (column <= nav.logoTo ? DOT_FILLED : DOT_HOLLOW) : DOT_ABSENT;		if (row >= heading.top && row <= heading.bottom && column <= heading.to) return DOT_FILLED;		const line = copy.find((candidate) => candidate.row === row);		if (line && column <= line.to) return DOT_HOLLOW;		if (row >= buttons.top && row <= buttons.bottom) {			if (column <= buttons.primaryTo) return DOT_FILLED;			if (column >= buttons.secondaryFrom && column <= buttons.secondaryTo) return row === buttons.top || row === buttons.bottom || column === buttons.secondaryFrom || column === buttons.secondaryTo ? DOT_FILLED : DOT_ABSENT;		}		if (inBox(picture, column, row)) return solidWithSpeckle(column, row);		const card = cards.find((candidate) => column >= candidate.left && column <= candidate.right);		if (card && row >= cardsTop && row < box.bottom) {			const cardHeight = box.bottom - cardsTop;			if (row < cardsTop + Math.round(cardHeight * 0.55)) return solidWithSpeckle(column, row);			return (row - cardsTop) % 2 === 0 && column < card.right - 1 ? DOT_HOLLOW : DOT_ABSENT;		}		return DOT_ABSENT;	};}export const PAGE = { left: 4, right: 53, top: 1, bottom: 36 };const ADDRESS_BAR = { top: PAGE.top, bottom: PAGE.top + 3, from: PAGE.left + 10, to: PAGE.right - 2 };const BROWSER_BUTTONS = { row: PAGE.top + 2, columns: [PAGE.left + 2, PAGE.left + 4, PAGE.left + 6] };const PAGE_BODY: Box = { left: PAGE.left + 3, right: PAGE.right - 3, top: ADDRESS_BAR.bottom + 2, bottom: PAGE.bottom - 2 };const browser = rectangle(PAGE, (column, row) => {	if (row <= ADDRESS_BAR.bottom) {		if (row === BROWSER_BUTTONS.row && BROWSER_BUTTONS.columns.includes(column)) return DOT_HOLLOW;		// The address field is left empty, and the page's URL is written in it.		if (row > ADDRESS_BAR.top && row < ADDRESS_BAR.bottom && column >= ADDRESS_BAR.from && column <= ADDRESS_BAR.to) return DOT_ABSENT;		return DOT_FILLED;	}	return inBox(PAGE_BODY, column, row) ? webpageLayout(PAGE_BODY)(column, row) : DOT_ABSENT;});export const PUBLISHED_PAGE = pictureOf(EXTRACT_STORY_GRID, browser);export const PAGE_LABELS: DotStoryText[] = [{ text: 'https://atlas.example/pricing', x: x(ADDRESS_BAR.from + 1), y: baselineOn(ADDRESS_BAR.top + 1.5, 13), size: 13, weight: 700, muted: true }];const WINDOW = { left: 3, right: 54, top: 2, bottom: 35 };const TITLE_BAR_BOTTOM = WINDOW.top + 3;const BUTTONS = { row: WINDOW.top + 2, columns: [6, 8, 10] };const TAB_RULE_ROW = TITLE_BAR_BOTTOM + 5;export const WINDOW_TOP_ROW = TITLE_BAR_BOTTOM;export const WINDOW_ROWS = WINDOW.bottom - TITLE_BAR_BOTTOM;const TAB_SIZE = 13;// Doto is monospaced, each letter three fifths of its size wide.const tabWidthInColumns = (text: string) => (text.length * 0.6 * TAB_SIZE) / PITCH;export const FORMATS = ['Markdown', 'HTML', 'JSON', 'Images', 'Screenshot', 'Highlights'] as const;export type Format = (typeof FORMATS)[number];const TAB_GAP_COLUMNS = 2;const TAB_POSITIONS = FORMATS.reduce<{ format: Format; from: number; to: number }[]>((positions, format) => {	const from = positions.length === 0 ? WINDOW.left + 3 : positions[positions.length - 1].to + TAB_GAP_COLUMNS;	return [...positions, { format, from, to: from + tabWidthInColumns(format) }];}, []);const tabRule = (active: Format): DotPainter => {	const tab = TAB_POSITIONS.find((position) => position.format === active)!;	return (column, row) => {		if (row !== TAB_RULE_ROW || column < WINDOW.left + 2 || column > WINDOW.right - 2) return DOT_ABSENT;		return column >= Math.floor(tab.from) && column <= Math.ceil(tab.to) - 1 ? DOT_FILLED : DOT_HOLLOW;	};};// A window that is only an outline has too little in it to melt into or out of, so it arrives and leaves full of// rings and wipes clear in between.function outputWindow(body: DotPainter): DotPainter {	return rectangle(WINDOW, (column, row) => {		if (row <= TITLE_BAR_BOTTOM) return row === BUTTONS.row && BUTTONS.columns.includes(column) ? DOT_HOLLOW : DOT_FILLED;		return body(column, row);	});}const CONTENT: Box = { left: WINDOW.left + 3, right: WINDOW.right - 3, top: TAB_RULE_ROW + 3, bottom: WINDOW.bottom - 3 };// Three image thumbnails pulled off the page, each with its file name written under it.const THUMBNAILS = [0, 1, 2].map((index) => {	const width = (CONTENT.right - CONTENT.left - 4) / 3;	const left = Math.round(CONTENT.left + index * (width + 2));	return { left, right: Math.round(left + width - 1), top: CONTENT.top, bottom: CONTENT.top + 11 };});const thumbnails: DotPainter = (column, row) => {	const thumbnail = THUMBNAILS.find((box) => inBox(box, column, row));	if (!thumbnail) return DOT_ABSENT;	return column === thumbnail.left || column === thumbnail.right || row === thumbnail.top || row === thumbnail.bottom ? DOT_FILLED : solidWithSpeckle(column, row);};// The page as captured: the same layout, framed and smaller.const SCREENSHOT: Box = { left: CONTENT.left + 4, right: CONTENT.right - 4, top: CONTENT.top, bottom: CONTENT.bottom + 1 };const SCREENSHOT_BODY: Box = { left: SCREENSHOT.left + 2, right: SCREENSHOT.right - 2, top: SCREENSHOT.top + 2, bottom: SCREENSHOT.bottom - 1 };const screenshot = stack(	within(		(column, row) => inBox(SCREENSHOT, column, row) && (column === SCREENSHOT.left || column === SCREENSHOT.right || row === SCREENSHOT.top || row === SCREENSHOT.bottom),		() => DOT_FILLED	),	within((column, row) => inBox(SCREENSHOT_BODY, column, row), webpageLayout(SCREENSHOT_BODY)));const BODIES: Record<Format, DotPainter> = {	Markdown: () => DOT_ABSENT,	HTML: () => DOT_ABSENT,	JSON: () => DOT_ABSENT,	Images: thumbnails,	Screenshot: screenshot,	Highlights: () => DOT_ABSENT,};export const BLANK_WINDOW = pictureOf(EXTRACT_STORY_GRID, outputWindow(rings));export const OUTPUT_WINDOWS = Object.fromEntries(FORMATS.map((format) => [format, pictureOf(EXTRACT_STORY_GRID, outputWindow(stack(tabRule(format), BODIES[format])))])) as Record<Format, ReturnType<typeof pictureOf>>;export const tabsFor = (active: Format): DotStoryText[] => TAB_POSITIONS.map((tab) => ({ text: tab.format, x: x(tab.from), y: baselineOn(TAB_RULE_ROW - 2.5, TAB_SIZE), size: TAB_SIZE, weight: tab.format === active ? 800 : 700, muted: tab.format !== active }));const TEXT_LEFT = x(CONTENT.left);const line = (text: string, rowOffset: number, isStrong: boolean, size = 16): DotStoryText => ({ text, x: TEXT_LEFT, y: baselineOn(CONTENT.top + rowOffset, size), size, weight: isStrong ? 800 : 600 });export const OUTPUT_TEXT: Record<Format, DotStoryText[]> = {	Markdown: [line('# Atlas pricing', 0, true), line('## Starter', 4, true), line('- $19 / month', 7, false), line('- 10,000 requests / month', 10, false), line('## Team', 14, true), line('- $49 / month', 17, false)],	HTML: [line('<h1>Atlas pricing</h1>', 0, true, 15), line('<h2>Starter</h2>', 4, false, 15), line('<p>$19 / month</p>', 7, false, 15), line('<h2>Team</h2>', 11, false, 15), line('<p>$49 / month</p>', 14, false, 15)],	JSON: [line('{', 0, false, 15), line('  "title": "Atlas pricing",', 3, false, 15), line('  "plans": ["Starter", "Team"],', 6, false, 15), line('  "prices": [19, 49]', 9, false, 15), line('}', 12, false, 15)],	Images: THUMBNAILS.map((box, index) => ({ text: ['hero.webp', 'logo.svg', 'team.jpg'][index], x: x(box.left), y: baselineOn(box.bottom + 2.5, 12), size: 12, weight: 700, muted: true })),	Screenshot: [],	Highlights: [line('> Starter is $19 / month', 0, true, 15), line('> Team adds shared access', 4, false, 15), line('> 50,000 requests on Team', 8, false, 15), line('> Annual billing saves 20%', 12, false, 15)],};/** The live page: what the picture shows before it plays (the story starts from it), and instead of playing with reduced motion. */export const EXTRACT_RESTING = { ...PUBLISHED_PAGE, texts: PAGE_LABELS };

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