VisualSite
Enrich story
Dot animation of an identifier resolving to company and person profiles.
$ npx context-ds add story-enrichUsage
Use it for
Entity enrichment 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-enrich.tsx
import { Card } from "@/components/ui/card";import { EnrichStory } from "@/components/visuals/stories/enrich-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]"> <EnrichStory /> </div> </Card> );}Source
components/visuals/stories/enrich-story.tsx
'use client';import { ENRICH_RESTING, ENRICH_STORY_GRID } from '@/components/visuals/stories/enrich-story-scenes';import { DotStory, type LoadDotStory } from '@/components/visuals/dot-engine/dot-story';const loadEnrichStory: LoadDotStory = async (stage) => { const { createEnrichStoryPlayer } = await import('@/components/visuals/stories/enrich-story-player'); return createEnrichStoryPlayer(stage);};/** * The "Enrich any entity your agent sees" card's picture: the agent's eye reads an identifier and melts into the * company and the person behind it, each with its profile. With reduced motion it shows both profiles, linked. */export function EnrichStory({ className }: { className?: string }) { return <DotStory grid={ENRICH_STORY_GRID} resting={ENRICH_RESTING} load={loadEnrichStory} className={className} />;}components/visuals/stories/enrich-story-player.ts
import { ENRICH_RESTING, ENRICH_STORY_GRID, EYE_AT_REST, EYE_LOOK, ICONS, IDENTIFIER, LINK, PROFILES, paintEye } from '@/components/visuals/stories/enrich-story-scenes';import type { GlyphCell } from '@/components/visuals/dot-engine/dot-glyph-cells';import { createGlyphShader, drawGlyphCells } from '@/components/visuals/dot-engine/dot-glyph-morph';import { DOT_FILLED, DOT_HOLLOW } from '@/components/visuals/dot-engine/dot-morph-dots';import { cellIndexIn, cellState, clamp01, hasRippleReached } from '@/components/visuals/dot-engine/dot-story-cells';import { createDotStoryPlayer, dotStoryFont, paintDotStoryCursor, paintDotStoryText, type DotStoryPlayer, type DotStoryStage } from '@/components/visuals/dot-engine/dot-story-player';/** * One sighting, played as a loop: an identifier is typed while the agent's eye reads along, the eye melts into the * company and the person behind it, a card draws itself round each and fills in line by line, and a link joins the * two. Then both profiles melt back into the eye. */const TYPING = { startsAt: 0.4, charactersPerSecond: 11 };const LOOKING = { startsAt: TYPING.startsAt, glances: 2, glanceSeconds: 1.4 };const SPLIT = { startsAt: LOOKING.startsAt + LOOKING.glances * LOOKING.glanceSeconds + 0.3, seconds: 1.1 };const FRAME = { startsAt: SPLIT.startsAt + SPLIT.seconds + 0.2, seconds: 0.5 };const DETAIL = { firstAt: FRAME.startsAt + FRAME.seconds, everySeconds: 0.3, staggerSeconds: 0.15, seconds: 0.3 };const LINK_UP = { startsAt: DETAIL.firstAt + PROFILES[0].details.length * DETAIL.everySeconds + 0.3, seconds: 0.4, pulseSeconds: 0.9 };const RESET = { startsAt: LINK_UP.startsAt + 2.6, seconds: 1.1 };const LOOP_SECONDS = RESET.startsAt + RESET.seconds + 0.3;// Both profiles are filled in and linked, between two pulses: the picture the static art shows.const RESTING_AT_SECONDS = LINK_UP.startsAt + LINK_UP.seconds + LINK_UP.pulseSeconds * 1.5;const TEXT_FADE_SECONDS = 0.3;const CURSOR_GAP = 3;const { columns, rows, pitch } = ENRICH_STORY_GRID;const cellIndex = cellIndexIn(ENRICH_STORY_GRID);const WHOLE_GRID = { left: 0, top: 0, cellsPerSide: Math.max(columns, rows) };const melt = (from: GlyphCell[], into: GlyphCell[]) => createGlyphShader({ grid: ENRICH_STORY_GRID, box: WHOLE_GRID, cells: from, drawTarget: (target) => drawGlyphCells(target, into, pitch) });const detailStartsAt = (card: number, detail: number) => DETAIL.firstAt + card * DETAIL.staggerSeconds + detail * DETAIL.everySeconds;// The glances are whole, so the eye is looking straight ahead again when it starts to melt.function lookAt(seconds: number) { const looking = clamp01((seconds - LOOKING.startsAt) / (LOOKING.glances * LOOKING.glanceSeconds)); return Math.round(EYE_LOOK * Math.sin(looking * LOOKING.glances * Math.PI * 2));}export function createEnrichStoryPlayer({ canvas, dotColor, surfaceColor, fontFamily, onFirstFrame }: DotStoryStage): DotStoryPlayer { const states = new Uint8Array(columns * rows); const split = melt(EYE_AT_REST.cells, ICONS.cells); const reset = melt(ENRICH_RESTING.cells, EYE_AT_REST.cells); // 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 [IDENTIFIER, ...ENRICH_RESTING.texts]) void document.fonts.load(dotStoryFont(text, fontFamily), text.text); const paintProfiles = (seconds: number) => { states.set(ICONS.states); const framed = (seconds - FRAME.startsAt) / FRAME.seconds; PROFILES.forEach((card, index) => { // The card draws itself outwards from its icon, in the corner it starts from. const farthest = Math.hypot(card.region.columns, card.region.rows); for (const cell of card.frame) { if (framed > 0 && hasRippleReached(Math.hypot(cell.column - card.region.left, cell.row - card.region.top) / farthest, framed)) states[cellIndex(cell)] = cellState(cell); } card.details.forEach((detail, step) => { const filledIn = (seconds - detailStartsAt(index, step)) / DETAIL.seconds; for (const cell of detail.cells) { if (filledIn > 0 && hasRippleReached((cell.column - card.region.left) / card.region.columns, filledIn)) states[cellIndex(cell)] = cellState(cell); } }); }); }; const paintLink = (seconds: number) => { const since = seconds - LINK_UP.startsAt; if (since <= 0) return; LINK.forEach((cell, step) => { if (hasRippleReached(step / LINK.length, since / LINK_UP.seconds)) states[cellIndex(cell)] = DOT_HOLLOW; }); if (since < LINK_UP.seconds) return; const head = Math.floor((((since - LINK_UP.seconds) % LINK_UP.pulseSeconds) / LINK_UP.pulseSeconds) * (LINK.length * 2)); const traveling = LINK[head]; if (traveling) states[cellIndex(traveling)] = DOT_FILLED; }; const statesAt = (seconds: number) => { if (seconds >= RESET.startsAt) return reset({ morph: clamp01((seconds - RESET.startsAt) / RESET.seconds), spin: 0 }); if (seconds >= SPLIT.startsAt + SPLIT.seconds) { paintProfiles(seconds); paintLink(seconds); return states; } if (seconds >= SPLIT.startsAt) return split({ morph: (seconds - SPLIT.startsAt) / SPLIT.seconds, spin: 0 }); return paintEye(states, lookAt(seconds)); }; const paintIdentifier = (context: CanvasRenderingContext2D, seconds: number) => { const presence = 1 - clamp01((seconds - SPLIT.startsAt) / TEXT_FADE_SECONDS); if (presence <= 0) return; const typed = IDENTIFIER.text.slice(0, Math.max(0, Math.floor((seconds - TYPING.startsAt) * TYPING.charactersPerSecond))); // The line is centered on what it will read once typed, so it grows to the right rather than from its middle. context.font = dotStoryFont(IDENTIFIER, fontFamily); const left = IDENTIFIER.x - context.measureText(IDENTIFIER.text).width / 2; paintDotStoryText(context, fontFamily, { ...IDENTIFIER, text: typed, x: left, anchor: 'start' }, presence); if (typed.length === IDENTIFIER.text.length) return; paintDotStoryCursor(context, left + context.measureText(typed).width + CURSOR_GAP, IDENTIFIER, presence); }; const paintDetails = (context: CanvasRenderingContext2D, seconds: number) => { const leaving = 1 - clamp01((seconds - RESET.startsAt) / TEXT_FADE_SECONDS); PROFILES.forEach((card, index) => { card.details.forEach((detail, step) => { const presence = clamp01((seconds - detailStartsAt(index, step)) / DETAIL.seconds) * leaving; for (const text of detail.texts) paintDotStoryText(context, fontFamily, text, presence); }); }); }; return createDotStoryPlayer({ canvas, dotColor, surfaceColor, onFirstFrame, grid: ENRICH_STORY_GRID, resting: { states: ENRICH_RESTING.states, atSeconds: RESTING_AT_SECONDS }, frameAt(storySeconds) { const seconds = storySeconds % LOOP_SECONDS; return { states: statesAt(seconds), paintAbove(context) { paintIdentifier(context, seconds); paintDetails(context, seconds); }, }; }, });}components/visuals/stories/enrich-story-scenes.ts
import { glyphCellsFromRows, 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 { cellStates, cellsOfStates, outlineCells, placeCells, ringsWithSpeckle, solidWithSpeckle, type Region } from '@/components/visuals/dot-engine/dot-story-cells';import type { DotStoryText } from '@/components/visuals/dot-engine/dot-story-player';/** * The "Enrich any entity your agent sees" card's pictures, in the brand dot grid: the agent's eye reads an * identifier, and what it saw becomes the two things behind it, a company and a person, each with its profile. */// The ring pattern behind the picture already keeps clear of it, so the dots need no halo of their own.export const ENRICH_STORY_GRID: DotGrid = { columns: 58, rows: 38, pitch: 10, dotRadius: 3.2, ringStroke: 1, haloRadius: 0 };const { columns: COLUMNS, rows: ROWS, pitch: PITCH } = ENRICH_STORY_GRID;const x = (column: number) => column * PITCH;const y = (row: number) => row * PITCH;// Doto sits a third of its size below the middle of the row it is set on.const baselineOn = (row: number, size: number) => y(row + 0.5) + size / 3;export interface StoryPicture { states: Uint8Array; cells: GlyphCell[];}const pictureOf = (states: Uint8Array): StoryPicture => ({ states, cells: cellsOfStates(ENRICH_STORY_GRID, states) });const EYE = { center: { column: 28.5, row: 14.5 }, halfWidth: 16, halfHeight: 9, iris: 5.6, pupil: 2.2 };/** How far the eye looks to either side, in columns. */export const EYE_LOOK = 6;const isInEye = (column: number, row: number) => { const across = (column - EYE.center.column) / EYE.halfWidth; return Math.abs(across) <= 1 && Math.abs(row - EYE.center.row) <= EYE.halfHeight * (1 - across * across);};const isOnEyelid = (column: number, row: number) => !isInEye(column - 1, row) || !isInEye(column + 1, row) || !isInEye(column, row - 1) || !isInEye(column, row + 1);/** The eye, looking `look` columns off center: a ringed white, a solid iris and the brand's hollow circle for a pupil. */export function paintEye(states: Uint8Array, look: number) { for (let row = 0; row < ROWS; row++) { for (let column = 0; column < COLUMNS; column++) { const cell = row * COLUMNS + column; if (!isInEye(column, row)) states[cell] = DOT_ABSENT; else if (isOnEyelid(column, row)) states[cell] = DOT_FILLED; else { const fromIris = Math.hypot(column - EYE.center.column - look, row - EYE.center.row); states[cell] = fromIris <= EYE.pupil ? DOT_HOLLOW : fromIris <= EYE.iris ? solidWithSpeckle(column, row) : ringsWithSpeckle(column, row); } } } return states;}export const EYE_AT_REST = pictureOf(paintEye(new Uint8Array(COLUMNS * ROWS), 0));// daily.dev and its co-founder are a customer story on this site, so the picture enriches an entity the page already names.export const IDENTIFIER: DotStoryText = { text: '@idoshamun, daily.dev', x: x(COLUMNS / 2), y: baselineOn(30, 24), size: 24, weight: 700, anchor: 'middle' };const CARD = { columns: 26, rows: 32, top: 3 };const COMPANY_CARD: Region = { left: 0, top: CARD.top, columns: CARD.columns, rows: CARD.rows };const PERSON_CARD: Region = { left: COLUMNS - CARD.columns, top: CARD.top, columns: CARD.columns, rows: CARD.rows };// A tower with a lower wing beside it: a plain tower on a base line read as a top hat.const BUILDING_ROWS = [' ##### ', ' #o#o# ', ' ##### ', ' #####o#o# ', ' #o#o##### ', ' #####o#o# ', ' #o#o##### ', ' #####o#o# ', ' #o#o##### ', ' ######o## ', ' ######o## '];const PERSON_ROWS = [' ### ', ' #ooo# ', ' #ooo# ', ' #ooo# ', ' ### ', ' ', ' ####### ', ' ######### ', '###########', '###########', '###########'];const ICON_INSET = 2;const FACT_ROWS = [18, 22, 26];const FOOT_ROW = 29;export interface ProfileDetail { cells: GlyphCell[]; texts: DotStoryText[];}export interface Profile { region: Region; icon: GlyphCell[]; frame: GlyphCell[]; /** The profile's lines, in the order they are filled in. */ details: ProfileDetail[];}interface ProfileSpec { region: Region; iconRows: string[]; name: string; handle: string; facts: string[]; foot: ProfileDetail;}function profile({ region, iconRows, name, handle, facts, foot }: ProfileSpec): Profile { const textLeft = x(region.left + ICON_INSET + 12); const heading: ProfileDetail = { cells: [], texts: [ { text: name, x: textLeft, y: baselineOn(region.top + 6, 18), size: 18, weight: 800 }, { text: handle, x: textLeft, y: baselineOn(region.top + 9, 12), size: 12, weight: 700, muted: true }, ], }; const lines = facts.map((fact, index): ProfileDetail => { const row = region.top + FACT_ROWS[index]; return { cells: [{ column: region.left + 3, row, filled: true }], texts: [{ text: fact, x: x(region.left + 5), y: baselineOn(row, 14), size: 14, weight: 600 }] }; }); return { region, icon: placeCells(glyphCellsFromRows(iconRows), { left: region.left + ICON_INSET, top: region.top + ICON_INSET }), frame: outlineCells(region), details: [heading, ...lines, foot] };}// Three brand colors, as three densities of the brand's two circles.const SWATCH = { columns: 3, rows: 2, gap: 1 };const swatches: GlyphCell[] = [0, 1, 2].flatMap((shade) => Array.from({ length: SWATCH.columns * SWATCH.rows }, (_, index) => { const column = index % SWATCH.columns; const row = Math.floor(index / SWATCH.columns); return { column: COMPANY_CARD.left + 3 + shade * (SWATCH.columns + SWATCH.gap) + column, row: COMPANY_CARD.top + FOOT_ROW + row, filled: [true, (column + row) % 2 === 0, false][shade] }; }));export const PROFILES: Profile[] = [ profile({ region: COMPANY_CARD, iconRows: BUILDING_ROWS, name: 'daily.dev', handle: 'Company', facts: ['Developer network', 'Programming news', 'Millions of devs'], foot: { cells: swatches, texts: [] } }), profile({ region: PERSON_CARD, iconRows: PERSON_ROWS, name: 'Ido Shamun', handle: '@idoshamun', facts: ['Co-founder', 'at daily.dev'], foot: { cells: [], texts: [{ text: 'x.com/idoshamun', x: x(PERSON_CARD.left + 3), y: baselineOn(PERSON_CARD.top + FOOT_ROW + 0.5, 13), size: 13, weight: 700, muted: true }] }, }),];/** The company and the person on their own: dense enough for the eye to melt into. */export const ICONS = pictureOf( cellStates( ENRICH_STORY_GRID, PROFILES.flatMap((card) => card.icon) ));const LINK_ROW = CARD.top + Math.floor(CARD.rows / 2);/** The rule that joins the person to the company, in the order a pulse travels it. */export const LINK: GlyphCell[] = Array.from({ length: PERSON_CARD.left - CARD.columns }, (_, step) => ({ column: PERSON_CARD.left - 1 - step, row: LINK_ROW, filled: false }));/** Both profiles, linked: what the picture shows before it plays, and instead of playing with reduced motion. */export const ENRICH_RESTING = { ...pictureOf(cellStates(ENRICH_STORY_GRID, [...PROFILES.flatMap((card) => [...card.frame, ...card.icon, ...card.details.flatMap((detail) => detail.cells)]), ...LINK])), texts: PROFILES.flatMap((card) => card.details.flatMap((detail) => detail.texts)),};Reference: site src/components/sections/home/enrich-story.tsx.