Persona storyDesign system
VisualSite

Persona story

Dot animation of a team's task and API result, with captions.

npx context-ds add story-persona
On the site

Usage

Use it for

Use-case and persona cards on the site. onPhase reports which part is playing, to show its longer text beside the picture. Plays only on screen; shows the outcome with reduced motion.

Avoid

  • Stories as the main content of a page

Dependencies: dot-engine, persona-glyph

Example

examples/story-persona.tsx
import { Card } from "@/components/ui/card";import { PersonaStory } from "@/components/visuals/stories/persona-story";import type { PersonaStoryCaptions } from "@/components/visuals/stories/persona-story-scenes";// Kept outside the component: new captions reload the story.const CAPTIONS: PersonaStoryCaptions = {  challenge: ["generic UI", "users cannot picture their brand"],  solution: ["logo + colors", "applied across the platform"],  impact: ["more trials", "customers see themselves in it"],};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]">        <PersonaStory name="portal" captions={CAPTIONS} />      </div>    </Card>  );}

Source

components/visuals/stories/persona-story.tsx
"use client";'use client';import { DotStory, type LoadDotStory } from '@/components/visuals/dot-engine/dot-story';import type { PersonaGlyphName } from '@/components/visuals/stories/persona-glyphs';import { PERSONA_STORY_GRID, personaResting, type PersonaStoryCaptions, type PersonaStoryPhase } from '@/components/visuals/stories/persona-story-scenes';import { useCallback, useEffect, useMemo, useRef } from 'react';/** * A persona card's story: what the team builds crumbles under its challenge, a request is answered, and the team has * what the API gives it. With reduced motion it shows the outcome. `onPhase` hears which of the three is playing. */export function PersonaStory({ name, captions, onPhase, className }: { name: PersonaGlyphName; captions: PersonaStoryCaptions; onPhase?: (phase: PersonaStoryPhase) => void; className?: string }) {	const resting = useMemo(() => personaResting(name, captions), [name, captions]);	// Read through a ref, so a new callback each render doesn't reload the story.	const onPhaseRef = useRef(onPhase);	useEffect(() => {		onPhaseRef.current = onPhase;	}, [onPhase]);	const load = useCallback<LoadDotStory>(		async (stage) => {			const { createPersonaStoryPlayer } = await import('@/components/visuals/stories/persona-story-player');			return createPersonaStoryPlayer(stage, name, captions, (phase) => onPhaseRef.current?.(phase));		},		[name, captions]	);	return <DotStory grid={PERSONA_STORY_GRID} resting={resting} load={load} className={className} />;}
components/visuals/stories/persona-story-player.ts
import { captionedBeat } from '@/components/visuals/dot-engine/story-caption';import { DOT_HOLLOW } from '@/components/visuals/dot-engine/dot-morph-dots';import { createBeatStoryPlayer } from '@/components/visuals/dot-engine/dot-story-beats';import { cellIndexIn, clamp01, hasRippleReached } from '@/components/visuals/dot-engine/dot-story-cells';import type { DotStoryPlayer, DotStoryStage } from '@/components/visuals/dot-engine/dot-story-player';import type { PersonaGlyphName } from '@/components/visuals/stories/persona-glyphs';import { PERSONA_STORY_GRID, PICTURE_CENTER, WINDOW_EMPTY, WINDOW_FETCHED, paintWindow, personaCaption, personaPictures, type PersonaStoryCaptions, type PersonaStoryPhase } from '@/components/visuals/stories/persona-story-scenes';/** * One team's story, played as a loop: what it builds stands, then crumbles to rings as the challenge gets to it; it * melts into the window the request is answered in, which fills as the content comes in; and that melts into what * the API gives the team, which holds before it all starts again. */const CRUMBLE = { startsAt: 0.9, seconds: 1.2 };const FETCH = { startsAt: 0.5, seconds: 1.6 };const HOLD = { challenge: CRUMBLE.startsAt + CRUMBLE.seconds + 1.1, solution: FETCH.startsAt + FETCH.seconds + 1, impact: 3.2 };// The glyphs are mostly lines two dots wide, which a melt thins to almost nothing halfway through; a wave from the// middle of the picture swaps them in whole.const GLYPH_ARRIVAL = { kind: 'waves', seconds: 0.9, origin: PICTURE_CENTER } as const;const cellIndex = cellIndexIn(PERSONA_STORY_GRID);// The dots crumble in no visible order, the way the brand's dot shader speckles its imagery.function crumbleOrder(column: number, row: number) {	const hash = Math.sin(column * 12.9898 + row * 78.233) * 43758.5453;	return hash - Math.floor(hash);}const BEAT_PHASES: PersonaStoryPhase[] = ['challenge', 'solution', 'impact'];export function createPersonaStoryPlayer(stage: DotStoryStage, name: PersonaGlyphName, captions: PersonaStoryCaptions, onPhase?: (phase: PersonaStoryPhase) => void): DotStoryPlayer {	const { built, broken, outcome } = personaPictures(name);	const standing = built.cells.filter((cell) => cell.filled);	const crumble = (states: Uint8Array, heldSeconds: number) => {		const crumbled = clamp01((heldSeconds - CRUMBLE.startsAt) / CRUMBLE.seconds);		for (const cell of standing) {			if (hasRippleReached(crumbleOrder(cell.column, cell.row), crumbled)) states[cellIndex(cell)] = DOT_HOLLOW;		}	};	return createBeatStoryPlayer(stage, {		grid: PERSONA_STORY_GRID,		resting: { beat: 2, heldSeconds: 1 },		onBeat: (beat) => onPhase?.(BEAT_PHASES[beat]),		beats: [			{ ...captionedBeat({ picture: built, departure: broken, caption: personaCaption('challenge', captions.challenge), holdSeconds: HOLD.challenge, animate: crumble }), arrival: GLYPH_ARRIVAL },			captionedBeat({ picture: WINDOW_EMPTY, departure: WINDOW_FETCHED, caption: personaCaption('solution', captions.solution), holdSeconds: HOLD.solution, isTyped: true, animate: (states, heldSeconds) => void paintWindow(states, clamp01((heldSeconds - FETCH.startsAt) / FETCH.seconds)) }),			{ ...captionedBeat({ picture: outcome, caption: personaCaption('impact', captions.impact), holdSeconds: HOLD.impact }), arrival: GLYPH_ARRIVAL },		],	});}
components/visuals/stories/persona-story-scenes.ts
import { storyCaption } from '@/components/visuals/dot-engine/story-caption';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 type { StoryPicture } from '@/components/visuals/dot-engine/dot-story-beats';import { cellStates, cellsOfStates, paintShape, roundedBox, type DotBox } from '@/components/visuals/dot-engine/dot-story-cells';import type { DotStoryText } from '@/components/visuals/dot-engine/dot-story-player';import { PERSONA_GLYPH_GRID, personaGlyph, personaOutcome, type PersonaGlyphName } from '@/components/visuals/stories/persona-glyphs';/** * A persona card's story pictures, in the brand dot grid: the card's own glyph grown large for what the team builds, * the window a request is answered in, and the glyph for what the API gives it. */// The ring pattern behind the picture already keeps clear of it, so the dots need no halo of their own.export const PERSONA_STORY_GRID: DotGrid = { columns: 58, rows: 38, pitch: 10, dotRadius: 3.2, ringStroke: 1, haloRadius: 0 };const { columns: COLUMNS, rows: ROWS } = PERSONA_STORY_GRID;const pictureOfStates = (states: Uint8Array): StoryPicture => ({ states, cells: cellsOfStates(PERSONA_STORY_GRID, states) });const pictureOfCells = (cells: GlyphCell[]) => pictureOfStates(cellStates(PERSONA_STORY_GRID, cells));const blank = () => new Uint8Array(COLUMNS * ROWS);// Every picture stands in the same place, left of the words that say what it means. Doto is a wide monospace, so// the words get the larger share: thirteen characters of the big line fit beside the picture.export const PICTURE_CENTER = { column: 14, row: 19 };const CAPTION_COLUMN = 30;const GROWTH = 2;const GLYPH_SIDE = PERSONA_GLYPH_GRID.columns * GROWTH;const GLYPH_ORIGIN = { left: PICTURE_CENTER.column - GLYPH_SIDE / 2, top: PICTURE_CENTER.row - GLYPH_SIDE / 2 };/** The card's small glyph grown to twice its size, each of its dots a block of four; `filled` overrides what they are. */function grown(cells: GlyphCell[], filled?: boolean): GlyphCell[] {	return cells.flatMap((cell) =>		Array.from({ length: GROWTH * GROWTH }, (_, part) => ({			column: GLYPH_ORIGIN.left + cell.column * GROWTH + (part % GROWTH),			row: GLYPH_ORIGIN.top + cell.row * GROWTH + Math.floor(part / GROWTH),			filled: filled ?? cell.filled,		}))	);}export interface PersonaPictures {	/** What the team builds, standing. */	built: StoryPicture;	/** The same, hollowed out: the challenge has got to it. */	broken: StoryPicture;	/** What the API gives it. */	outcome: StoryPicture;}export function personaPictures(name: PersonaGlyphName): PersonaPictures {	const team = personaGlyph(name);	return { built: pictureOfCells(grown(team)), broken: pictureOfCells(grown(team, false)), outcome: pictureOfCells(grown(personaOutcome(name))) };}const WINDOW: DotBox = { left: 1, right: 27, top: 8, bottom: 30 };const WINDOW_PARTS = { lights: { row: 10, columns: [4, 6, 8] }, divider: 12, lines: [{ row: 15, right: 23 }, { row: 18, right: 19 }, { row: 21, right: 24 }, { row: 24, right: 17 }, { row: 27, right: 21 }], indent: 4 };const isWindow = roundedBox(WINDOW, 2);/** The window a request is answered in, its lines `fetched` of the way filled from the top as the content comes in. */export function paintWindow(states: Uint8Array, fetched: number) {	states.fill(DOT_ABSENT);	return paintShape(PERSONA_STORY_GRID, states, isWindow, (column, row) => {		if (row === WINDOW_PARTS.lights.row) return WINDOW_PARTS.lights.columns.includes(column) ? DOT_FILLED : DOT_ABSENT;		if (row === WINDOW_PARTS.divider) return column > WINDOW.left && column < WINDOW.right ? DOT_HOLLOW : DOT_ABSENT;		const line = WINDOW_PARTS.lines.findIndex((candidate) => candidate.row === row);		if (line < 0 || column < WINDOW_PARTS.indent || column > WINDOW_PARTS.lines[line].right) return DOT_ABSENT;		const along = (column - WINDOW_PARTS.indent) / (WINDOW_PARTS.lines[line].right - WINDOW_PARTS.indent);		return (line + along) / WINDOW_PARTS.lines.length < fetched ? DOT_FILLED : DOT_HOLLOW;	});}export const WINDOW_EMPTY = pictureOfStates(paintWindow(blank(), 0));export const WINDOW_FETCHED = pictureOfStates(paintWindow(blank(), 1));/** The words beside each picture: the line that matters, up to 13 characters, and a line of detail under it, up to 33. */export type PersonaStoryPhase = keyof PersonaStoryCaptions;export interface PersonaStoryCaptions {	challenge: [value: string, detail: string];	solution: [value: string, detail: string];	impact: [value: string, detail: string];}export const personaCaption = (label: keyof PersonaStoryCaptions, [value, detail]: PersonaStoryCaptions[keyof PersonaStoryCaptions]): DotStoryText[] => storyCaption(PERSONA_STORY_GRID, CAPTION_COLUMN, label, value, detail);/** What the API gives the team: what the picture shows before it plays, and instead of playing with reduced motion. */export const personaResting = (name: PersonaGlyphName, captions: PersonaStoryCaptions) => ({ ...personaPictures(name).outcome, texts: personaCaption('impact', captions.impact) });

Reference: site src/components/sections/usecases/persona-story.tsx.