Setup storyDesign system
VisualSite

Setup story

Dot animation of API key and package setup.

npx context-ds add story-setup
On the site

Usage

Use it for

Getting-started or setup steps 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-setup.tsx
import { Card } from "@/components/ui/card";import { SetupStory } from "@/components/visuals/stories/setup-story";export function Site() {  return (    <Card variant="ring" className="min-h-[22rem] w-full max-w-xl items-center justify-center">      {/* Its grid is nearly square and coarser than the other stories, so it is drawn smaller. */}      <div className="relative w-full max-w-60">        <SetupStory />      </div>    </Card>  );}

Source

components/visuals/stories/setup-story.tsx
'use client';import { CLOSED_ENVELOPE, SETUP_STORY_GRID } from '@/components/visuals/stories/setup-story-scenes';import { DotStory, type LoadDotStory } from '@/components/visuals/dot-engine/dot-story';const loadSetupStory: LoadDotStory = async (stage) => {	const { createSetupStoryPlayer } = await import('@/components/visuals/stories/setup-story-player');	return createSetupStoryPlayer(stage);};/** * The "Do it yourself" card's three steps as one looping picture: an envelope opens, becomes a terminal that types an * API key, then a package that is unpacked. With reduced motion it stays the closed envelope. */export function SetupStory({ className }: { className?: string }) {	return <DotStory grid={SETUP_STORY_GRID} resting={CLOSED_ENVELOPE} load={loadSetupStory} className={className} />;}
components/visuals/stories/setup-story-player.ts
import { BOX_RIM_ROW, CLOSED_BOX, CLOSED_ENVELOPE, ENVELOPE_FLAP_ROW, OPEN_BOX, OPEN_ENVELOPE, SETUP_STORY_GRID, TERMINAL_BLANK, TERMINAL_BOUNDS, TERMINAL_SCREEN, TERMINAL_TITLE_ROW, TERMINAL_WINDOW, type SetupScene } from '@/components/visuals/stories/setup-story-scenes';import { createGlyphShader, drawGlyphCells } from '@/components/visuals/dot-engine/dot-glyph-morph';import { DOT_STORY_MUTED_TEXT_COLOR, DOT_STORY_TEXT_COLOR, canvasColor, createDotStoryPlayer, type DotStoryPlayer, type DotStoryStage } from '@/components/visuals/dot-engine/dot-story-player';/** * Plays the setup story as one loop. A picture that changes in place (the envelope and the box opening, the terminal's * screen wiping) ripples out from its hinge, the way a flap lifts; a picture that becomes something else melts into it. */interface Opening {	kind: 'opens';	seconds: number;	hingeRow: number;}interface Melting {	kind: 'melts';	seconds: number;}interface Beat {	scene: SetupScene;	arrival: Opening | Melting;	holdSeconds: number;}const OPEN_SECONDS = 0.7;const WIPE_SECONDS = 0.5;const MELT_SECONDS = 1.1;const BEATS: Beat[] = [	{ scene: CLOSED_ENVELOPE, arrival: { kind: 'melts', seconds: MELT_SECONDS }, holdSeconds: 1.2 },	{ scene: OPEN_ENVELOPE, arrival: { kind: 'opens', seconds: OPEN_SECONDS, hingeRow: ENVELOPE_FLAP_ROW }, holdSeconds: 1.5 },	{ scene: TERMINAL_BLANK, arrival: { kind: 'melts', seconds: MELT_SECONDS }, holdSeconds: 0.2 },	{ scene: TERMINAL_WINDOW, arrival: { kind: 'opens', seconds: WIPE_SECONDS, hingeRow: TERMINAL_TITLE_ROW }, holdSeconds: 3.2 },	{ scene: TERMINAL_BLANK, arrival: { kind: 'opens', seconds: WIPE_SECONDS, hingeRow: TERMINAL_TITLE_ROW }, holdSeconds: 0.1 },	{ scene: CLOSED_BOX, arrival: { kind: 'melts', seconds: MELT_SECONDS }, holdSeconds: 1 },	{ scene: OPEN_BOX, arrival: { kind: 'opens', seconds: OPEN_SECONDS, hingeRow: BOX_RIM_ROW }, holdSeconds: 1.8 },];const TERMINAL_BEAT = BEATS.findIndex((beat) => beat.scene === TERMINAL_WINDOW);const LOOP_SECONDS = BEATS.reduce((total, beat) => total + beat.arrival.seconds + beat.holdSeconds, 0);// The last dot to open flips this far into the arrival, leaving the rest for it to settle.const RIPPLE_SHARE = 0.7;const KEY_LABEL = 'API KEY';const API_KEY = 'ctx_9f2a7c4e1b8d';const LABEL_FONT_SIZE = 20;const KEY_FONT_SIZE = 26;const TEXT_FADE_SECONDS = 0.25;const TYPING_STARTS_AFTER_SECONDS = 0.35;const CHARACTERS_PER_SECOND = 14;const CURSOR_BLINKS_PER_SECOND = 1.4;const CURSOR_GAP = 3;interface Moment {	beat: number;	/** 0 to 1 while the beat's picture arrives; 1 once it holds. */	arrival: number;	heldSeconds: number;}function momentAt(storySeconds: number): Moment {	let remaining = storySeconds % LOOP_SECONDS;	for (let beat = 0; beat < BEATS.length; beat++) {		const { arrival, holdSeconds } = BEATS[beat];		if (remaining < arrival.seconds) return { beat, arrival: remaining / arrival.seconds, heldSeconds: 0 };		remaining -= arrival.seconds;		if (remaining < holdSeconds) return { beat, arrival: 1, heldSeconds: remaining };		remaining -= holdSeconds;	}	return { beat: 0, arrival: 0, heldSeconds: 0 };}const clamp01 = (value: number) => Math.min(1, Math.max(0, value));const previousBeat = (beat: number) => BEATS[(beat + BEATS.length - 1) % BEATS.length];export function createSetupStoryPlayer({ canvas, dotColor, surfaceColor, fontFamily, onFirstFrame }: DotStoryStage): DotStoryPlayer {	const { columns, rows, pitch } = SETUP_STORY_GRID;	const opening = new Uint8Array(columns * rows);	const farthestRow = (hingeRow: number) => Math.max(hingeRow, rows - 1 - hingeRow);	const melts = BEATS.map((beat, index) =>		beat.arrival.kind === 'melts'			? createGlyphShader({					grid: SETUP_STORY_GRID,					box: { left: 0, top: 0, cellsPerSide: Math.max(columns, rows) },					cells: previousBeat(index).scene.cells,					drawTarget: (target) => drawGlyphCells(target, beat.scene.cells, pitch),				})			: null	);	const labelFont = `700 ${LABEL_FONT_SIZE}px ${fontFamily}`;	const keyFont = `900 ${KEY_FONT_SIZE}px ${fontFamily}`;	// The canvas draws with whatever is loaded at the time, so the fonts are asked for before the terminal first shows.	void document.fonts.load(labelFont, KEY_LABEL);	void document.fonts.load(keyFont, API_KEY);	const statesAt = ({ beat, arrival }: Moment) => {		const { scene, arrival: style } = BEATS[beat];		if (arrival >= 1) return scene.states;		if (style.kind === 'melts') return melts[beat]!({ morph: arrival, spin: 0 });		const before = previousBeat(beat).scene.states;		const reach = (arrival / RIPPLE_SHARE) * farthestRow(style.hingeRow);		for (let cell = 0; cell < opening.length; cell++) {			const distance = Math.abs(Math.floor(cell / columns) - style.hingeRow);			opening[cell] = distance <= reach ? scene.states[cell] : before[cell];		}		return opening;	};	// The clear screen has no dots in it to carry a halo, so the backdrop is painted out from under it in one piece.	const isScreenClearAt = ({ beat, arrival }: Moment) => beat === TERMINAL_BEAT || (beat === (TERMINAL_BEAT + 1) % BEATS.length && arrival < 1);	const clearScreen = (context: CanvasRenderingContext2D) => {		context.fillStyle = surfaceColor;		context.fillRect(TERMINAL_BOUNDS.left * pitch, TERMINAL_BOUNDS.top * pitch, (TERMINAL_BOUNDS.right - TERMINAL_BOUNDS.left + 1) * pitch, (TERMINAL_BOUNDS.bottom - TERMINAL_BOUNDS.top + 1) * pitch);	};	// The key is only there while the screen is clear: it types itself once the screen has wiped and leaves as it refills.	const textPresenceAt = ({ beat, arrival, heldSeconds }: Moment) => {		if (beat === TERMINAL_BEAT) return arrival < 1 ? 0 : clamp01(heldSeconds / TEXT_FADE_SECONDS);		if (beat === (TERMINAL_BEAT + 1) % BEATS.length && arrival < 1) return 1 - clamp01((arrival * BEATS[beat].arrival.seconds) / TEXT_FADE_SECONDS);		return 0;	};	const drawKey = (context: CanvasRenderingContext2D, moment: Moment, presence: number) => {		const left = TERMINAL_SCREEN.left * pitch;		const width = (TERMINAL_SCREEN.right + 1) * pitch - left;		const top = TERMINAL_SCREEN.top * pitch;		const height = (TERMINAL_SCREEN.bottom + 1) * pitch - top;		const isLeaving = moment.beat !== TERMINAL_BEAT;		const typedSeconds = isLeaving ? Infinity : moment.heldSeconds - TYPING_STARTS_AFTER_SECONDS;		const typed = API_KEY.slice(0, Math.max(0, Math.min(API_KEY.length, Math.floor(typedSeconds * CHARACTERS_PER_SECOND))));		const isTyping = typed.length < API_KEY.length;		context.fillStyle = canvasColor(canvas, DOT_STORY_MUTED_TEXT_COLOR);		context.textBaseline = 'middle';		context.textAlign = 'left';		context.font = labelFont;		context.globalAlpha = presence;		context.fillText(KEY_LABEL, left, top + height * 0.3);		context.fillStyle = canvasColor(canvas, DOT_STORY_TEXT_COLOR);		context.font = keyFont;		const fullWidth = context.measureText(API_KEY).width + CURSOR_GAP + KEY_FONT_SIZE * 0.5;		const fit = Math.min(1, width / fullWidth);		context.save();		context.translate(left, top + height * 0.62);		context.scale(fit, fit);		context.globalAlpha = presence;		context.fillText(typed, 0, 0);		const isCursorLit = isTyping || Math.floor(typedSeconds * CURSOR_BLINKS_PER_SECOND * 2) % 2 === 0;		if (isCursorLit && !isLeaving) context.fillRect(context.measureText(typed).width + CURSOR_GAP, -KEY_FONT_SIZE * 0.42, KEY_FONT_SIZE * 0.5, KEY_FONT_SIZE * 0.84);		context.restore();		context.globalAlpha = 1;	};	return createDotStoryPlayer({		canvas,		dotColor,		surfaceColor,		onFirstFrame,		grid: SETUP_STORY_GRID,		// The closed envelope at rest is the picture the static art shows.		resting: { states: CLOSED_ENVELOPE.states, atSeconds: BEATS[0].arrival.seconds },		frameAt(storySeconds) {			const moment = momentAt(storySeconds);			const presence = textPresenceAt(moment);			return {				states: statesAt(moment),				paintBeneath: isScreenClearAt(moment) ? clearScreen : undefined,				paintAbove: presence > 0 ? (context) => drawKey(context, moment, presence) : undefined,			};		},	});}
components/visuals/stories/setup-story-scenes.ts
import type { GlyphCell } from '@/components/visuals/dot-engine/dot-glyph-cells';import { DOT_ABSENT, DOT_FILLED, DOT_HOLLOW, type DotGrid } from '@/components/visuals/dot-engine/dot-morph-dots';import { LINE_WIDTH, distanceToSegment, polygon, rectangle, ringsWithSpeckle, solidWithSpeckle, stack, type DotPainter } from '@/components/visuals/dot-engine/dot-story-cells';/** * The pictures the "Do it yourself" card tells its three steps with, each drawn in the brand dot grid: the sign-up * email arrives and is opened, the dashboard hands over an API key, and the SDK arrives as a package and is unpacked. */export const SETUP_STORY_GRID: DotGrid = { columns: 30, rows: 28, pitch: 10, dotRadius: 3.2, ringStroke: 1, haloRadius: 14 };export interface SetupScene {	states: Uint8Array;	cells: GlyphCell[];}const { columns: COLUMNS, rows: ROWS } = SETUP_STORY_GRID;const MIDDLE = (COLUMNS - 1) / 2;const ENVELOPE = { left: 1, right: COLUMNS - 2, top: 8, bottom: ROWS - 2 };const FOLD_DEPTH = 10;const FLAP_RISE = 7;const FOLD_TIP = { column: MIDDLE, row: ENVELOPE.top + FOLD_DEPTH };const FLAP_APEX = { column: MIDDLE, row: ENVELOPE.top - FLAP_RISE };const ENVELOPE_TOP_LEFT = { column: ENVELOPE.left, row: ENVELOPE.top };const ENVELOPE_TOP_RIGHT = { column: ENVELOPE.right, row: ENVELOPE.top };const LETTER = { left: ENVELOPE.left + 4, right: ENVELOPE.right - 4, top: ENVELOPE.top - 4, bottom: ENVELOPE.top + 9 };const LETTER_LINES = [	{ row: LETTER.top + 2, right: LETTER.right - 2 },	{ row: LETTER.top + 4, right: LETTER.right - 5 },	{ row: LETTER.top + 6, right: LETTER.right - 3 },];const nearFold = (column: number, row: number) => Math.min(distanceToSegment(column, row, ENVELOPE_TOP_LEFT, FOLD_TIP), distanceToSegment(column, row, ENVELOPE_TOP_RIGHT, FOLD_TIP)) < LINE_WIDTH;const aboveFold = (column: number, row: number) => row < ENVELOPE.top + ((column <= MIDDLE ? column - ENVELOPE.left : ENVELOPE.right - column) / (MIDDLE - ENVELOPE.left)) * FOLD_DEPTH;const inEnvelope = (column: number, row: number) => column >= ENVELOPE.left && column <= ENVELOPE.right && row >= ENVELOPE.top && row <= ENVELOPE.bottom;const onEnvelopeOutline = (column: number, row: number) => inEnvelope(column, row) && (row === ENVELOPE.top || row === ENVELOPE.bottom || column === ENVELOPE.left || column === ENVELOPE.right);const closedEnvelope: DotPainter = (column, row) => {	if (!inEnvelope(column, row)) return DOT_ABSENT;	if (onEnvelopeOutline(column, row)) return DOT_FILLED;	if (nearFold(column, row)) return DOT_ABSENT;	return aboveFold(column, row) ? ringsWithSpeckle(column, row) : solidWithSpeckle(column, row);};const envelopePocket: DotPainter = (column, row) => {	if (!inEnvelope(column, row) || aboveFold(column, row)) return DOT_ABSENT;	if (onEnvelopeOutline(column, row) || nearFold(column, row)) return DOT_FILLED;	return solidWithSpeckle(column, row);};const letter: DotPainter = (column, row) => {	if (column < LETTER.left || column > LETTER.right || row < LETTER.top || row > LETTER.bottom) return DOT_ABSENT;	if (column === LETTER.left || column === LETTER.right || row === LETTER.top) return DOT_FILLED;	const line = LETTER_LINES.find((candidate) => candidate.row === row);	return line && column >= LETTER.left + 2 && column <= line.right ? DOT_FILLED : DOT_HOLLOW;};const liftedFlap: DotPainter = (column, row) => {	const rise = ENVELOPE.top - row;	const halfWidth = (MIDDLE - ENVELOPE.left) * (1 - rise / FLAP_RISE);	if (rise <= 0 || Math.abs(column - MIDDLE) > halfWidth + LINE_WIDTH) return DOT_ABSENT;	const onEdge = Math.min(distanceToSegment(column, row, ENVELOPE_TOP_LEFT, FLAP_APEX), distanceToSegment(column, row, ENVELOPE_TOP_RIGHT, FLAP_APEX)) < LINE_WIDTH;	return onEdge ? DOT_FILLED : ringsWithSpeckle(column, row);};const envelopeBack: DotPainter = (column, row) => {	if (!inEnvelope(column, row)) return DOT_ABSENT;	return onEnvelopeOutline(column, row) ? DOT_FILLED : ringsWithSpeckle(column, row);};const openEnvelope = stack(envelopePocket, letter, liftedFlap, envelopeBack);const TERMINAL = { left: 1, right: COLUMNS - 2, top: 3, bottom: 25 };const TITLE_BAR_BOTTOM = TERMINAL.top + 2;const WINDOW_BUTTONS = { row: TERMINAL.top + 1, columns: [3, 5, 7] };export const TERMINAL_BOUNDS = TERMINAL;/** The clear space inside the terminal, in cells, where the key is typed. */export const TERMINAL_SCREEN = { left: TERMINAL.left + 2, right: TERMINAL.right - 2, top: TITLE_BAR_BOTTOM + 2, bottom: TERMINAL.bottom - 2 };function terminal(screen: ReturnType<DotPainter>): DotPainter {	return (column, row) => {		if (column < TERMINAL.left || column > TERMINAL.right || row < TERMINAL.top || row > TERMINAL.bottom) return DOT_ABSENT;		if (row <= TITLE_BAR_BOTTOM) return row === WINDOW_BUTTONS.row && WINDOW_BUTTONS.columns.includes(column) ? DOT_HOLLOW : DOT_FILLED;		return column === TERMINAL.left || column === TERMINAL.right || row === TERMINAL.bottom ? DOT_FILLED : screen;	};}const BOX = { left: 3, right: COLUMNS - 4, top: 12, bottom: ROWS - 2 };const LID = { left: BOX.left - 1, right: BOX.right + 1, top: 7, bottom: BOX.top - 1 };const TAPE_COLUMNS = [Math.floor(MIDDLE), Math.ceil(MIDDLE)];const TAPE_BOTTOM = BOX.top + 5;const OPENING_DEPTH = 4;const OPENING_INSET = 3;const FLAP_REACH = 3;const CONTENTS = { column: MIDDLE, row: 4, reach: 3 };const onTape = (column: number) => TAPE_COLUMNS.includes(column);const boxBody: DotPainter = (column, row) => {	const body = rectangle(BOX, solidWithSpeckle)(column, row);	if (body === DOT_ABSENT) return DOT_ABSENT;	if (onTape(column) && row > BOX.top && row <= TAPE_BOTTOM) return DOT_HOLLOW;	return body;};const closedLid: DotPainter = (column, row) => {	const lid = rectangle(LID, ringsWithSpeckle)(column, row);	return lid !== DOT_ABSENT && onTape(column) ? DOT_FILLED : lid;};// The lid's shadow: the body's top edge drops out under the closed lid, the way the envelope's fold does.const closedBox: DotPainter = (column, row) => (row === BOX.top ? DOT_ABSENT : stack(closedLid, boxBody)(column, row));const OPENING_BACK = BOX.top - OPENING_DEPTH;const opening = polygon(	[		{ column: BOX.left, row: BOX.top },		{ column: BOX.right, row: BOX.top },		{ column: BOX.right - OPENING_INSET, row: OPENING_BACK },		{ column: BOX.left + OPENING_INSET, row: OPENING_BACK },	],	ringsWithSpeckle);function sideFlap(hinge: number, outwards: 1 | -1) {	return polygon(		[			{ column: hinge, row: BOX.top },			{ column: hinge - outwards * OPENING_INSET, row: OPENING_BACK },			{ column: hinge - outwards * OPENING_INSET + outwards * FLAP_REACH, row: OPENING_BACK - FLAP_REACH },			{ column: hinge + outwards * FLAP_REACH, row: BOX.top - FLAP_REACH },		],		ringsWithSpeckle	);}// What was packed: the brand pattern's solid cluster, lifting out of the box.const contents: DotPainter = (column, row) => (Math.abs(column - CONTENTS.column) + Math.abs(row - CONTENTS.row) <= CONTENTS.reach ? DOT_FILLED : DOT_ABSENT);const openBox = stack(contents, boxBody, sideFlap(BOX.left, -1), sideFlap(BOX.right, 1), opening);function paint(painter: DotPainter): SetupScene {	const states = new Uint8Array(COLUMNS * ROWS);	const cells: GlyphCell[] = [];	for (let row = 0; row < ROWS; row++) {		for (let column = 0; column < COLUMNS; column++) {			const state = painter(column, row);			states[row * COLUMNS + column] = state;			if (state !== DOT_ABSENT) cells.push({ column, row, filled: state === DOT_FILLED });		}	}	return { states, cells };}export const ENVELOPE_FLAP_ROW = ENVELOPE.top;export const TERMINAL_TITLE_ROW = TITLE_BAR_BOTTOM;export const BOX_RIM_ROW = BOX.top;export const CLOSED_ENVELOPE = paint(closedEnvelope);export const OPEN_ENVELOPE = paint(openEnvelope);// A window that is only an outline has too little in it to melt into or out of, so the terminal arrives and leaves// with its screen full of rings and wipes it clear in between.export const TERMINAL_BLANK = paint(terminal(DOT_HOLLOW));export const TERMINAL_WINDOW = paint(terminal(DOT_ABSENT));export const CLOSED_BOX = paint(closedBox);export const OPEN_BOX = paint(openBox);

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