VisualSite
Glossary hero story
Dot animation of glossary entries and lookup, with captions.
$ npx context-ds add story-glossary-heroUsage
Use it for
The glossary page hero on the site. Plays only on screen; shows the book with reduced motion.
Avoid
- Stories as the main content of a page
Dependencies: story-parts, dot-engine
Example
examples/story-glossary-hero.tsx
import { Card } from "@/components/ui/card";import { GlossaryHeroStory } from "@/components/visuals/stories/glossary-hero-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]"> <GlossaryHeroStory /> </div> </Card> );}Source
components/visuals/stories/glossary-hero-story.tsx
'use client';import { GLOSSARY_RESTING } from '@/components/visuals/stories/glossary-hero-story-scenes';import { HERO_STORY_GRID } from '@/components/visuals/stories/hero-story-kit';import { DotStory, type LoadDotStory } from '@/components/visuals/dot-engine/dot-story';const loadGlossaryHeroStory: LoadDotStory = async (stage) => { const { createGlossaryHeroStoryPlayer } = await import('@/components/visuals/stories/glossary-hero-story-player'); return createGlossaryHeroStoryPlayer(stage);};/** * The glossary hero's picture: an open book read line by line, then one entry looked up term after term. With * reduced motion it shows the book. */export function GlossaryHeroStory({ className }: { className?: string }) { return <DotStory grid={HERO_STORY_GRID} resting={GLOSSARY_RESTING} load={loadGlossaryHeroStory} className={className} />;}components/visuals/stories/glossary-hero-story-player.ts
import { BOOK_CAPTION, BOOK_LINE_COUNT, BOOK_UNREAD, ENTRIES, ENTRY_CAPTIONS, TERMS, paintBook, paintEntry } from '@/components/visuals/stories/glossary-hero-story-scenes';import { HERO_STORY_GRID, stepAt } from '@/components/visuals/stories/hero-story-kit';import { captionedBeat } from '@/components/visuals/dot-engine/story-caption';import { createBeatStoryPlayer } from '@/components/visuals/dot-engine/dot-story-beats';import type { DotStoryPlayer, DotStoryStage } from '@/components/visuals/dot-engine/dot-story-player';/** * The glossary, played as a loop: a line of the open book is read at a time, then the book melts into a single * entry, and one term after another is looked up on it. */const READING = { startsAt: 0.4, everySeconds: 0.2 };const READ_AT = READING.startsAt + BOOK_LINE_COUNT * READING.everySeconds;const LOOKING_UP = { startsAt: 0, everySeconds: 1.5 };const TYPING = { startsAfter: 0.15, charactersPerSecond: 14 };const lastTerm = TERMS.length - 1;// Past the last line nothing is being read, so the book leaves as it arrived.const readingAt = (heldSeconds: number) => { const line = stepAt(heldSeconds, READING, BOOK_LINE_COUNT); return line === BOOK_LINE_COUNT ? -1 : line;};export function createGlossaryHeroStoryPlayer(stage: DotStoryStage): DotStoryPlayer { return createBeatStoryPlayer(stage, { grid: HERO_STORY_GRID, resting: { beat: 0, heldSeconds: READ_AT + 0.5 }, beats: [ captionedBeat({ picture: BOOK_UNREAD, caption: BOOK_CAPTION, holdSeconds: READ_AT + 1.2, isTyped: true, animate: (states, heldSeconds) => void paintBook(states, readingAt(heldSeconds)) }), { ...captionedBeat({ picture: ENTRIES[0], departure: ENTRIES[lastTerm], caption: ENTRY_CAPTIONS[0], holdSeconds: TERMS.length * LOOKING_UP.everySeconds + 0.6, animate: (states, heldSeconds) => void paintEntry(states, stepAt(heldSeconds, LOOKING_UP, lastTerm)) }), write(pen, heldSeconds) { const term = stepAt(heldSeconds, LOOKING_UP, lastTerm); const [label, word, detail] = ENTRY_CAPTIONS[term]; pen.text(label); pen.type(word, heldSeconds, { startsAt: term * LOOKING_UP.everySeconds + TYPING.startsAfter, charactersPerSecond: TYPING.charactersPerSecond }); pen.text(detail); }, }, ], });}components/visuals/stories/glossary-hero-story-scenes.ts
import { HERO_STORY_GRID, between, blankScreen, heroCaption, pictureOfStates } from '@/components/visuals/stories/hero-story-kit';import { DOT_ABSENT, DOT_FILLED, DOT_HOLLOW } from '@/components/visuals/dot-engine/dot-morph-dots';import { paintShape, roundedBox, type DotBox } from '@/components/visuals/dot-engine/dot-story-cells';/** The glossary hero's pictures, in the brand dot grid: an open book being read, and one of its entries up close. */const PAGES: DotBox[] = [ { left: 3, right: 15, top: 6, bottom: 31 }, { left: 17, right: 29, top: 6, bottom: 31 },];const PAGE_LINE = { firstRow: 10, every: 3, perPage: 7, inset: 2 };// How far short of the page's edge each line stops, so the two pages read as text rather than ruled paper.const LINE_SHORTFALLS = [0, 2, 0, 4, 1, 0, 5];export const BOOK_LINE_COUNT = PAGES.length * PAGE_LINE.perPage;/** The book with the line being read (none when negative) solid, and the rest rings. Lines run down one page, then the next. */export function paintBook(states: Uint8Array, reading: number) { PAGES.forEach((page, pageIndex) => { paintShape(HERO_STORY_GRID, states, roundedBox(page, 1), (column, row) => { const line = (row - PAGE_LINE.firstRow) / PAGE_LINE.every; if (!Number.isInteger(line) || !between(line, 0, PAGE_LINE.perPage - 1)) return DOT_ABSENT; if (!between(column, page.left + PAGE_LINE.inset, page.right - PAGE_LINE.inset - LINE_SHORTFALLS[line])) return DOT_ABSENT; return pageIndex * PAGE_LINE.perPage + line === reading ? DOT_FILLED : DOT_HOLLOW; }); }); return states;}export const BOOK_UNREAD = pictureOfStates(paintBook(blankScreen(), -1));const ENTRY: DotBox = { left: 3, right: 29, top: 7, bottom: 30 };const ENTRY_LEFT = 6;const TERM_ROWS = [10, 11];const DEFINITION = [ { row: 15, right: 26 }, { row: 18, right: 23 }, { row: 21, right: 25 }, { row: 24, right: 21 }, { row: 27, right: 16 },];/** The terms looked up, and how many dots wide each sets on the entry. */export const TERMS = [ { word: 'crawling', dots: 12 }, { word: 'HTTP', dots: 7 }, { word: 'APIs', dots: 7 }, { word: 'brands', dots: 10 },];/** The entry for the term at `index`: the term set solid, its definition in rings beneath. */export function paintEntry(states: Uint8Array, index: number) { return paintShape(HERO_STORY_GRID, states, roundedBox(ENTRY, 2), (column, row) => { if (TERM_ROWS.includes(row)) return between(column, ENTRY_LEFT, ENTRY_LEFT + TERMS[index].dots - 1) ? DOT_FILLED : DOT_ABSENT; const line = DEFINITION.find((candidate) => candidate.row === row); return line && between(column, ENTRY_LEFT, line.right) ? DOT_HOLLOW : DOT_ABSENT; });}export const ENTRIES = TERMS.map((_, index) => pictureOfStates(paintEntry(blankScreen(), index)));export const BOOK_CAPTION = heroCaption('the glossary', 'A to Z', 'plain-English definitions');export const ENTRY_CAPTIONS = TERMS.map(({ word }) => heroCaption('look up', word, 'and what it means'));/** The open book: what the picture shows before it plays, and instead of playing with reduced motion. */export const GLOSSARY_RESTING = { ...BOOK_UNREAD, texts: BOOK_CAPTION };Reference: site src/components/sections/page-heroes/glossary-hero-story.tsx.