Monitor ways storiesDesign system
VisualSite

Monitor ways stories

Dot animations for page changes, sitemap changes, and extracted-record monitoring.

npx context-ds add story-monitor-ways
On the site

Usage

Use it for

The ways to monitor on a site product page, one picture per card. Exports PageMonitorStory, SitemapMonitorStory and ExtractMonitorStory. Each plays only on screen; with reduced motion they show the diff, the changed list, the scored record.

Avoid

  • Stories as the main content of a page

Dependencies: dot-engine, story-parts

Example

examples/story-monitor-ways.tsx
import { Card } from "@/components/ui/card";import { PageMonitorStory, SitemapMonitorStory, ExtractMonitorStory } from "@/components/visuals/stories/monitor-ways-story";// One card per way to monitor, stacked so each picture is drawn at a size its dots can be read at.export function Site() {  return (    <div className="mx-auto grid w-full max-w-md gap-6">      <Card variant="ring" className="items-center justify-center p-4 md:p-5">        <div className="relative w-full">          <PageMonitorStory />        </div>      </Card>      <Card variant="ring" className="items-center justify-center p-4 md:p-5">        <div className="relative w-full">          <SitemapMonitorStory />        </div>      </Card>      <Card variant="ring" className="items-center justify-center p-4 md:p-5">        <div className="relative w-full">          <ExtractMonitorStory />        </div>      </Card>    </div>  );}

Source

components/visuals/stories/monitor-ways-story.tsx
'use client';import { EXTRACT_MONITOR_RESTING, MONITOR_WAYS_GRID, PAGE_MONITOR_RESTING, SITEMAP_MONITOR_RESTING } from '@/components/visuals/stories/monitor-ways-story-scenes';import { DotStory, type LoadDotStory } from '@/components/visuals/dot-engine/dot-story';const loadPlayers = () => import('@/components/visuals/stories/monitor-ways-story-player');const loadPageMonitorStory: LoadDotStory = async (stage) => (await loadPlayers()).createPageMonitorStoryPlayer(stage);const loadSitemapMonitorStory: LoadDotStory = async (stage) => (await loadPlayers()).createSitemapMonitorStoryPlayer(stage);const loadExtractMonitorStory: LoadDotStory = async (stage) => (await loadPlayers()).createExtractMonitorStoryPlayer(stage);/** A page monitor's picture: a page re-scraped until a line changes, then that line's diff. With reduced motion it shows the diff. */export function PageMonitorStory({ className }: { className?: string }) {	return <DotStory grid={MONITOR_WAYS_GRID} resting={PAGE_MONITOR_RESTING} load={loadPageMonitorStory} className={className} />;}/** A sitemap monitor's picture: a list of URLs loses one and gains one, then the new page. With reduced motion it shows the changed list. */export function SitemapMonitorStory({ className }: { className?: string }) {	return <DotStory grid={MONITOR_WAYS_GRID} resting={SITEMAP_MONITOR_RESTING} load={loadSitemapMonitorStory} className={className} />;}/** An extract monitor's picture: a plain-language instruction, then the record read from the page and its confidence. With reduced motion it shows the scored record. */export function ExtractMonitorStory({ className }: { className?: string }) {	return <DotStory grid={MONITOR_WAYS_GRID} resting={EXTRACT_MONITOR_RESTING} load={loadExtractMonitorStory} className={className} />;}
components/visuals/stories/monitor-ways-story-player.ts
import {	CONFIDENT_SEGMENTS,	EXTRACT_CAPTION,	METER_SEGMENTS,	MONITOR_WAYS_GRID,	NEW_PAGE_PICTURE,	PAGE_CAPTIONS,	PAGE_CHANGED,	PAGE_UNCHANGED,	PROMPT_FIELD,	PROMPT_FILLED,	PROMPT_TEXT,	RECORD_SCORED,	RECORD_UNSCORED,	SITEMAP_AFTER,	SITEMAP_BEFORE,	SITEMAP_CAPTIONS,	TEXT_DIFF,	alongPrompt,	paintRecord,	paintSitemapList,	paintWatchedPage,} from '@/components/visuals/stories/monitor-ways-story-scenes';import { captionedBeat } from '@/components/visuals/dot-engine/story-caption';import { createBeatStoryPlayer, typingSeconds } from '@/components/visuals/dot-engine/dot-story-beats';import type { DotStoryPlayer, DotStoryStage } from '@/components/visuals/dot-engine/dot-story-player';const MELT_SECONDS = 1.1;// The page is scraped twice; the second scrape finds the line changed as its scan passes it.const SCRAPES = { startsAt: 0.4, seconds: 1.1, times: 2, changeSeenAt: 0.55 };/** A page monitor, played as a loop: the page is re-scraped until a line of it changes, then melts into that line's diff. */export function createPageMonitorStoryPlayer(stage: DotStoryStage): DotStoryPlayer {	const scrapesAt = (heldSeconds: number) => (heldSeconds - SCRAPES.startsAt) / SCRAPES.seconds;	return createBeatStoryPlayer(stage, {		grid: MONITOR_WAYS_GRID,		resting: { beat: 1, heldSeconds: 2 },		beats: [			captionedBeat({				picture: PAGE_UNCHANGED,				departure: PAGE_CHANGED,				caption: PAGE_CAPTIONS.watching,				holdSeconds: SCRAPES.startsAt + SCRAPES.seconds * SCRAPES.times + 0.7,				isTyped: true,				animate(states, heldSeconds) {					const scrapes = scrapesAt(heldSeconds);					paintWatchedPage(states, scrapes >= 0 && scrapes < SCRAPES.times ? scrapes % 1 : 0, scrapes >= SCRAPES.times - 1 + SCRAPES.changeSeenAt);				},			}),			captionedBeat({ picture: TEXT_DIFF, caption: PAGE_CAPTIONS.changed, holdSeconds: 3 }),		],	});}const LISTING = { removedAt: 1, addedAt: 1.7 };/** A sitemap monitor, played as a loop: its list of URLs loses one and gains one, then melts into the new page. */export function createSitemapMonitorStoryPlayer(stage: DotStoryStage): DotStoryPlayer {	return createBeatStoryPlayer(stage, {		grid: MONITOR_WAYS_GRID,		resting: { beat: 0, heldSeconds: LISTING.addedAt + 1 },		beats: [			{				...captionedBeat({ picture: SITEMAP_BEFORE, departure: SITEMAP_AFTER, caption: SITEMAP_CAPTIONS.watching, holdSeconds: LISTING.addedAt + 2, animate: (states, heldSeconds) => void paintSitemapList(states, heldSeconds >= LISTING.removedAt, heldSeconds >= LISTING.addedAt) }),				write: (pen, heldSeconds) => (heldSeconds >= LISTING.addedAt ? SITEMAP_CAPTIONS.changed : SITEMAP_CAPTIONS.watching).forEach((text) => pen.text(text)),			},			captionedBeat({ picture: NEW_PAGE_PICTURE, caption: SITEMAP_CAPTIONS.added, holdSeconds: 2.6, isTyped: true }),		],	});}const PROMPT_TYPING = { startsAt: 0.4, charactersPerSecond: 11 };const SCORING = { startsAt: 0.5, everySeconds: 0.12 };const FIELD_WIPE_SECONDS = 0.45;/** An extract monitor, played as a loop: what to watch is typed in plain words, and melts into the record read from the page, scored. */export function createExtractMonitorStoryPlayer(stage: DotStoryStage): DotStoryPlayer {	const [label, score, detail] = EXTRACT_CAPTION;	const scoredAt = (heldSeconds: number) => (heldSeconds < SCORING.startsAt ? 0 : Math.min(CONFIDENT_SEGMENTS, Math.floor((heldSeconds - SCORING.startsAt) / SCORING.everySeconds) + 1));	return createBeatStoryPlayer(stage, {		grid: MONITOR_WAYS_GRID,		resting: { beat: 3, heldSeconds: SCORING.startsAt + CONFIDENT_SEGMENTS * SCORING.everySeconds + 0.8 },		beats: [			{ picture: PROMPT_FILLED, arrival: { kind: 'melts', seconds: MELT_SECONDS }, holdSeconds: 0.1 },			{				picture: PROMPT_FIELD,				arrival: { kind: 'builds', seconds: FIELD_WIPE_SECONDS, order: alongPrompt },				holdSeconds: PROMPT_TYPING.startsAt + typingSeconds(PROMPT_TEXT, PROMPT_TYPING.charactersPerSecond) + 0.9,				write: (pen, heldSeconds) => pen.type(PROMPT_TEXT, heldSeconds, { ...PROMPT_TYPING, cursorLingers: Infinity }),			},			{ picture: PROMPT_FILLED, arrival: { kind: 'builds', seconds: FIELD_WIPE_SECONDS, order: alongPrompt }, holdSeconds: 0.1 },			{				...captionedBeat({ picture: RECORD_UNSCORED, departure: RECORD_SCORED, caption: EXTRACT_CAPTION, holdSeconds: SCORING.startsAt + CONFIDENT_SEGMENTS * SCORING.everySeconds + 2.8, animate: (states, heldSeconds) => void paintRecord(states, scoredAt(heldSeconds)) }),				write(pen, heldSeconds) {					pen.text(label);					pen.text({ ...score, text: (scoredAt(heldSeconds) / METER_SEGMENTS).toFixed(2) });					pen.text(detail);				},			},		],	});}
components/visuals/stories/monitor-ways-story-scenes.ts
import { storyCaption } from '@/components/visuals/dot-engine/story-caption';import { promptField } from '@/components/visuals/stories/prompt-field';import { DOT_ABSENT, DOT_FILLED, DOT_HOLLOW, type DotGrid } from '@/components/visuals/dot-engine/dot-morph-dots';import type { StoryPicture } from '@/components/visuals/dot-engine/dot-story-beats';import { cellsOfStates, paintShape, roundedBox, solidWithSpeckle, type DotBox } from '@/components/visuals/dot-engine/dot-story-cells';import type { DotStoryText } from '@/components/visuals/dot-engine/dot-story-player';/** * The pictures for the three ways to watch a website, in the brand dot grid. A page monitor: a page re-scraped until * one of its lines changes, and the exact diff of that line. A sitemap monitor: a list of URLs that loses one and * gains one, and the new page. An extract monitor: what to watch, said in plain words, and the fields read from the * page with how sure the monitor is that one of them changed. */// The ring pattern behind the picture already keeps clear of it, so the dots need no halo of their own.export const MONITOR_WAYS_GRID: DotGrid = { columns: 58, rows: 38, pitch: 10, dotRadius: 3.2, ringStroke: 1, haloRadius: 0 };const { columns: COLUMNS, rows: ROWS, pitch: PITCH } = MONITOR_WAYS_GRID;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;const pictureOfStates = (states: Uint8Array): StoryPicture => ({ states, cells: cellsOfStates(MONITOR_WAYS_GRID, states) });const blank = () => new Uint8Array(COLUMNS * ROWS);const between = (value: number, from: number, to: number) => value >= from && value <= to;const caption = (label: string, value: string, detail: string) => storyCaption(MONITOR_WAYS_GRID, 34, label, value, detail);const SHEET: DotBox = { left: 4, right: 26, top: 3, bottom: 34 };const PAGE = { title: { rows: [6, 7], right: 16 }, left: 7, lines: [{ row: 11, right: 22 }, { row: 13, right: 19 }, { row: 15, right: 21 }, { row: 24, right: 22 }, { row: 26, right: 18 }, { row: 28, right: 21 }, { row: 30, right: 15 }], changed: { rows: [19, 20], right: 20 } };/** The watched page with a scan `scanned` of the way down it (none at 0 or 1); once changed, one line of it is solid. */export function paintWatchedPage(states: Uint8Array, scanned: number, isChanged: boolean) {	const bar = SHEET.top + 1 + Math.round(scanned * (SHEET.bottom - SHEET.top - 2));	return paintShape(MONITOR_WAYS_GRID, states, roundedBox(SHEET, 2), (column, row) => {		if (scanned > 0 && scanned < 1 && row === bar) return DOT_FILLED;		if (!between(column, PAGE.left, SHEET.right - 3)) return DOT_ABSENT;		if (PAGE.title.rows.includes(row)) return column <= PAGE.title.right ? DOT_FILLED : DOT_ABSENT;		if (PAGE.changed.rows.includes(row)) return column > PAGE.changed.right ? DOT_ABSENT : isChanged ? DOT_FILLED : row === PAGE.changed.rows[0] ? DOT_HOLLOW : DOT_ABSENT;		return PAGE.lines.some((line) => line.row === row && column <= line.right) ? DOT_HOLLOW : DOT_ABSENT;	});}export const PAGE_UNCHANGED = pictureOfStates(paintWatchedPage(blank(), 0, false));export const PAGE_CHANGED = pictureOfStates(paintWatchedPage(blank(), 0, true));const DIFF = { left: 2, right: 30, tab: 6, removed: { top: 9, bottom: 16 }, added: { top: 21, bottom: 28 } };// The line as it was, in rings behind a minus, and as it is now, solid behind a plus: each sign cut out of its tab.const diffLine = (states: Uint8Array, { top, bottom }: { top: number; bottom: number }, isAdded: boolean) =>	paintShape(MONITOR_WAYS_GRID, states, roundedBox({ left: DIFF.left, right: DIFF.right, top, bottom }, 2), (column, row) => {		const middle = (top + bottom) / 2;		const tabCenter = DIFF.left + DIFF.tab / 2;		if (column <= DIFF.left + DIFF.tab) {			const isBar = Math.abs(row - middle) < 1 && Math.abs(column - tabCenter) <= 2;			const isStem = isAdded && Math.abs(column - tabCenter) < 1 && Math.abs(row - middle) <= 2.5;			return isBar || isStem ? DOT_HOLLOW : DOT_FILLED;		}		return isAdded ? solidWithSpeckle(column, row) : DOT_HOLLOW;	});export const TEXT_DIFF = pictureOfStates(diffLine(diffLine(blank(), DIFF.removed, false), DIFF.added, true));export const PAGE_CAPTIONS = {	watching: caption('every hour', 're-scrape', 'one URL, its visible text'),	changed: caption('exact diff', '1 line', '$49 -> $59 a month'),};const LIST = { left: 3, right: 28, tab: 4, rowHeight: 4, every: 5, top: 4, count: 6, removed: 2, added: 5 };const listRow = (index: number): DotBox => ({ left: LIST.left, right: LIST.right, top: LIST.top + index * LIST.every, bottom: LIST.top + index * LIST.every + LIST.rowHeight - 1 });/** The sitemap's URLs. `isRemoved` empties the one that was delisted; `isAdded` brings in the new one, solid. */export function paintSitemapList(states: Uint8Array, isRemoved: boolean, isAdded: boolean) {	states.fill(DOT_ABSENT);	for (let index = 0; index < LIST.count; index++) {		if (index === LIST.added && !isAdded) continue;		const box = listRow(index);		paintShape(MONITOR_WAYS_GRID, states, roundedBox(box, 1), (column) => {			if (index === LIST.removed && isRemoved) return DOT_ABSENT;			if (column <= box.left + LIST.tab) return DOT_FILLED;			return index === LIST.added ? DOT_FILLED : DOT_HOLLOW;		});	}	return states;}export const SITEMAP_BEFORE = pictureOfStates(paintSitemapList(blank(), false, false));export const SITEMAP_AFTER = pictureOfStates(paintSitemapList(blank(), true, true));const NEW_PAGE = { plus: { column: 23, row: 28, radius: 5.4, arm: 3, width: 0.8 }, title: { rows: [7, 8], right: 17 }, lines: [{ row: 12, right: 22 }, { row: 14, right: 20 }, { row: 16, right: 22 }, { row: 18, right: 15 }] };const newPageStates = paintShape(MONITOR_WAYS_GRID, blank(), roundedBox(SHEET, 2), (column, row) => {	if (!between(column, PAGE.left, SHEET.right - 3)) return DOT_ABSENT;	if (NEW_PAGE.title.rows.includes(row)) return column <= NEW_PAGE.title.right ? DOT_FILLED : DOT_ABSENT;	return NEW_PAGE.lines.some((line) => line.row === row && column <= line.right) ? DOT_HOLLOW : DOT_ABSENT;});// The new page wears a solid badge with a plus cut out of it.paintShape(	MONITOR_WAYS_GRID,	newPageStates,	(column, row) => Math.hypot(column - NEW_PAGE.plus.column, row - NEW_PAGE.plus.row) <= NEW_PAGE.plus.radius,	(column, row) => {		const across = Math.abs(column - NEW_PAGE.plus.column);		const down = Math.abs(row - NEW_PAGE.plus.row);		return (across < NEW_PAGE.plus.width && down <= NEW_PAGE.plus.arm) || (down < NEW_PAGE.plus.width && across <= NEW_PAGE.plus.arm) ? DOT_HOLLOW : DOT_FILLED;	});export const NEW_PAGE_PICTURE = pictureOfStates(newPageStates);export const SITEMAP_CAPTIONS = {	watching: caption('sitemap.xml', '128 URLs', 'checked on a schedule'),	changed: caption('sitemap.xml', '+1  -1', 'one added, one removed'),	added: caption('new URL', 'added', '/products/atlas-pro'),};const PROMPT: DotBox = { left: 5, right: 52, top: 13, bottom: 23 };const FIELD = promptField(MONITOR_WAYS_GRID, PROMPT);export const PROMPT_FIELD = FIELD.empty;export const PROMPT_FILLED = FIELD.filled;export const alongPrompt = FIELD.along;export const PROMPT_TEXT: DotStoryText = { text: 'track pricing plans', x: x(PROMPT.left + 4), y: baselineOn((PROMPT.top + PROMPT.bottom) / 2, 22), size: 22, weight: 700 };const RECORD: DotBox = { left: 3, right: 29, top: 4, bottom: 33 };const FIELDS = { rows: [8, 12, 16], key: { left: 6, right: 11 }, value: { left: 14, ends: [25, 21, 24] }, changed: 1 };const METER = { row: 25, rows: 3, left: 6, segments: 10, every: 2 };/** The record read from the page, and under it a meter with `confident` of its segments filled; the changed field is solid. */export function paintRecord(states: Uint8Array, confident: number) {	return paintShape(MONITOR_WAYS_GRID, states, roundedBox(RECORD, 2), (column, row) => {		const field = FIELDS.rows.findIndex((fieldRow) => row === fieldRow || row === fieldRow + 1);		if (field >= 0) {			if (between(column, FIELDS.key.left, FIELDS.key.right)) return row === FIELDS.rows[field] ? DOT_HOLLOW : DOT_ABSENT;			if (!between(column, FIELDS.value.left, FIELDS.value.ends[field])) return DOT_ABSENT;			return field === FIELDS.changed ? DOT_FILLED : row === FIELDS.rows[field] ? DOT_HOLLOW : DOT_ABSENT;		}		if (row === METER.row - 4) return DOT_HOLLOW;		const segment = (column - METER.left) / METER.every;		if (!between(row, METER.row, METER.row + METER.rows - 1) || !Number.isInteger(segment) || !between(segment, 0, METER.segments - 1)) return DOT_ABSENT;		return segment < confident ? DOT_FILLED : DOT_HOLLOW;	});}/** How many of the meter's segments fill: the monitor is 0.9 sure the change is a real one. */export const CONFIDENT_SEGMENTS = 9;export const METER_SEGMENTS = METER.segments;export const RECORD_UNSCORED = pictureOfStates(paintRecord(blank(), 0));export const RECORD_SCORED = pictureOfStates(paintRecord(blank(), CONFIDENT_SEGMENTS));export const EXTRACT_CAPTION = caption('confidence', '0.90', 'Team plan: $49 -> $59');export const PAGE_MONITOR_RESTING = { ...TEXT_DIFF, texts: PAGE_CAPTIONS.changed };export const SITEMAP_MONITOR_RESTING = { ...SITEMAP_AFTER, texts: SITEMAP_CAPTIONS.changed };export const EXTRACT_MONITOR_RESTING = { ...RECORD_SCORED, texts: EXTRACT_CAPTION };

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