VisualSite
Persona glyph
Animated glyph tiles for site personas and static menu icons.
$ npx context-ds add persona-glyphUsage
Use it for
Icons in the header's menu panels (MenuGlyph as a menu item's icon), persona cards and use-case links. Give each one in a list its index so they change out of step. 45 named pairs, from scrape, batches and monitor to theme, team and generate.
Avoid
- Line icons in a blue tile where the site uses a glyph
- Glyphs at body text size
Dependencies: dot-engine
Example
examples/persona-glyph.tsx
import { MenuGlyph, PersonaGlyph } from "@/components/visuals/persona-glyph";const NAMES = ["scrape", "batches", "monitor", "theme", "team", "generate"] as const;export function Site() { return ( <div className="flex flex-col items-center gap-8"> <div className="flex flex-wrap justify-center gap-3"> {NAMES.map((name, i) => ( <PersonaGlyph key={name} name={name} index={i} /> ))} </div> <div className="flex gap-3"> {NAMES.map((name) => ( <MenuGlyph key={name} name={name} /> ))} </div> </div> );}Source
components/visuals/persona-glyph.tsx
"use client";import { useCallback, useMemo } from "react";import { DotStory, type LoadDotStory } from "@/components/visuals/dot-engine/dot-story";import { PERSONA_GLYPH_GRID, personaGlyph, type PersonaGlyphName } from "@/components/visuals/stories/persona-glyphs";import { cn } from "@/lib/utils";// Cards join the loop this far apart, so neighbors never melt at the same moment.const STAGGER_SECONDS = 0.85;/** * A small picture in white dots on the brand's blue: a glyph for what a team builds or a product does, giving way to * one for what the API gives it and back. The site uses it for menu icons, persona cards and use-case links; pass * each one's place in its list as `index` so a row of them never changes in step. `still` keeps the glyph. With * reduced motion it shows the glyph. */function PersonaGlyph({ name, index = 0, still = false, className }: { name: PersonaGlyphName; index?: number; still?: boolean; className?: string }) { const resting = useMemo(() => ({ cells: personaGlyph(name) }), [name]); const load = useCallback<LoadDotStory>( async (stage) => { const { createPersonaGlyphPlayer } = await import("@/components/visuals/persona-glyph-player"); return createPersonaGlyphPlayer(stage, name, index * STAGGER_SECONDS); }, [name, index], ); return ( <span data-slot="persona-glyph" className={cn("flex size-14 shrink-0 items-center justify-center rounded-card bg-brand p-2", className)}> <DotStory grid={PERSONA_GLYPH_GRID} resting={resting} load={load} still={still} className="w-full text-white" /> </span> );}/** * The header menus' icon: a persona glyph in a 36px blue tile, as the site's Products and Use cases menus draw them. * Held still: an icon in a menu should not move while the pointer is over it. */function MenuGlyph({ name, className }: { name: PersonaGlyphName; className?: string }) { return <PersonaGlyph name={name} still className={cn("size-9 rounded-control p-2.5", className)} />;}export { MenuGlyph, PersonaGlyph, type PersonaGlyphName };components/visuals/persona-glyph-player.ts
import { createBeatStoryPlayer, type StoryPicture } from "@/components/visuals/dot-engine/dot-story-beats";import { cellStates, cellsOfStates } from "@/components/visuals/dot-engine/dot-story-cells";import type { DotStoryPlayer, DotStoryStage } from "@/components/visuals/dot-engine/dot-story-player";import type { GlyphCell } from "@/components/visuals/dot-engine/dot-glyph-cells";import { PERSONA_GLYPH_GRID, personaGlyph, personaOutcome, type PersonaGlyphName } from "@/components/visuals/stories/persona-glyphs";/** A persona's glyph, played as a loop: what the team builds gives way to what the API gives it, and back. */// A glyph this small is mostly one-dot lines, which a melt thins to nothing halfway through; a wave from the// middle swaps one glyph for the other without ever emptying the card.const WAVE = { seconds: 0.8, origin: { column: 5, row: 5 } };const OUTCOME_HOLD_SECONDS = 2.2;const GLYPH_HOLD_SECONDS = 2.8;const pictureOfCells = (cells: GlyphCell[]): StoryPicture => { const states = cellStates(PERSONA_GLYPH_GRID, cells); return { states, cells: cellsOfStates(PERSONA_GLYPH_GRID, states) };};/** `startsAtSeconds` into the glyph's hold is where this card joins the loop, so a grid of cards does not melt in step. */export function createPersonaGlyphPlayer(stage: DotStoryStage, name: PersonaGlyphName, startsAtSeconds: number): DotStoryPlayer { return createBeatStoryPlayer(stage, { grid: PERSONA_GLYPH_GRID, resting: { beat: 1, heldSeconds: startsAtSeconds % GLYPH_HOLD_SECONDS }, beats: [ { picture: pictureOfCells(personaOutcome(name)), arrival: { kind: "waves", ...WAVE }, holdSeconds: OUTCOME_HOLD_SECONDS }, { picture: pictureOfCells(personaGlyph(name)), arrival: { kind: "waves", ...WAVE }, holdSeconds: GLYPH_HOLD_SECONDS }, ], });}components/visuals/stories/persona-glyphs.ts
import { glyphCellsFromRows, type GlyphCell } from '@/components/visuals/dot-engine/dot-glyph-cells';import type { DotGrid } from '@/components/visuals/dot-engine/dot-morph-dots';/** * The small pictures on the persona cards, in the brand dot grid: for each kind of team, a glyph for what it builds * and a second for what the API gives it, which the first gives way to and returns from. */export const PERSONA_GLYPH_GRID: DotGrid = { columns: 11, rows: 11, pitch: 10, dotRadius: 3.7, ringStroke: 1.3, haloRadius: 0 };// Every drawing, one string per row: `#` a solid dot, `o` a ring.const ART = { cylinder: [' ####### ', ' # # ', ' ######### ', ' # # ', ' # # ', ' ######### ', ' # # ', ' # # ', ' ######### ', ' # # ', ' ####### '], chunks: ['### ### ###', '### ### ###', '### ### ###', ' ', '### ooo ###', '### ooo ###', '### ooo ###', ' ', '### ### ###', '### ### ###', '### ### ###'], robot: [' # ', ' # ', ' ######### ', ' # # ', ' # ## ## # ', ' # ## ## # ', ' # # ', ' # ### # ', ' # # ', ' ######### ', ' ## ## '], browser: ['###########', '#o o o #', '###########', '# #', '# ####### #', '# #', '# ### #', '# #', '# ##### #', '# #', '###########'], magnifier: [' ##### ', ' #ooooo# ', '#ooooooo# ', '#ooooooo# ', '#ooooooo# ', '#ooooooo# ', ' #ooooo# ', ' ###### ', ' ## ', ' ## ', ' ##'], bubble: [' ######### ', '# #', '# ####### #', '# #', '# ##### #', '# #', ' ######### ', ' ## ', ' ## ', ' # ', ' '], shield: [' # ', ' ####### ', ' #ooooooo# ', ' #ooooooo# ', ' #ooooooo# ', ' #ooooooo# ', ' #ooooooo# ', ' #ooooo# ', ' #ooo# ', ' ### ', ' # '], eye: [' ', ' ', ' ##### ', ' ## ## ', '# ### #', '# ##### #', '# ### #', ' ## ## ', ' ##### ', ' ', ' '], book: [' ', ' #### #### ', '# # #', '# ## # ## #', '# # #', '# ## # ## #', '# # #', '# ## # ## #', '# # #', ' #### #### ', ' '], layers: [' # ', ' ## ## ', ' ## ## ', ' ## ## ', ' # # # ', ' ## ## ', ' ### ', ' # # ', ' ## ## ', ' ### ', ' '], file: [' ####### ', ' # ## ', ' # ## ', ' # ### # ', ' # # ', ' # ##### # ', ' # # ', ' # ### # ', ' # # ', ' # # ', ' ######### '], checklist: ['## #######', '## ', ' ', '## ##### ', '## ', ' ', '## #######', '## ', ' ', '## #### ', '## '], bolt: [' ## ', ' ## ', ' ## ', ' ### ', ' ######## ', ' ### ', ' ## ', ' ## ', ' ## ', ' ## ', ' # '], pencil: [' ## ', ' ####', ' ## ##', ' ## ## ', ' ## ## ', ' ## ## ', ' ## ## ', ' # ### ', ' # # ', ' ### ', ' '], globe: [' ##### ', ' # # # # ', ' # # # # ', '###########', '# # # #', '# # # #', '# # # #', '###########', ' # # # # ', ' # # # # ', ' ##### '], image: ['###########', '# #', '# ## #', '# ## #', '# # #', '# ### # #', '##### ####', '###########', '###########', '###########', '###########'], briefcase: [' ', ' ##### ', ' # # ', '###########', '#ooooooooo#', '#ooooooooo#', '#####o#####', '# #', '# #', '###########', ' '], contactCard: ['###########', '# #', '# ## #', '# #### ## #', '# ## #', '# #### ## #', '# #### #', '# ## #', '# #', '###########', ' '], tower: [' ####### ', ' # # ', ' # # # # ', ' # # ', ' # # # # ', ' # # ', ' # # # # ', ' # # ', ' # ### # ', ' # # # # ', '###########'], tiles: ['##### #####', '# # # #', '# # # # # #', '# # # #', '##### #####', ' ', '##### #####', '# # # #', '# # # # # #', '# # # #', '##### #####'], bars: [' ##', ' ##', ' ## ##', ' ## ##', ' ## ##', ' ## ## ##', ' ## ## ##', '## ## ## ##', '## ## ## ##', '## ## ## ##', '###########'], trend: [' ####', ' ##', ' # #', ' # #', ' # # ', ' # ## ', ' # ', '# ', ' ', '###########', ' '], chain: [' ', ' #### ', ' # # ', '# #### ', '# # # # ', '# # # # ', ' # # # # ', ' #### # ', ' # # ', ' #### ', ' '], logo: [' #######', ' #######', ' #######', ' ###ooo###', ' ####ooo###', '#####ooo###', '####### ', '####### ', ' ##### ', ' ### ', ' '], storefront: [' ######### ', ' #ooooooo# ', '###########', '# # # # # #', ' ######### ', ' # # ', ' # ## # # ', ' # ## # # ', ' # ## # ', ' # ## # ', ' ######### '], tag: [' ', ' ', ' ######## ', ' ## # ', '## ## # ', '# ## # ', '## # ', ' ## # ', ' ######## ', ' ', ' '], scroll: [' ######## ', '# # ', ' # ##### # ', ' # # ', ' # ##### # ', ' # # ', ' # ### # ', ' # # ', ' # ##', ' # #', ' ######## '], tick: [' ', ' #', ' ##', ' ## ', ' ## ', '# ## ', '## ## ', ' ## ## ', ' ### ', ' # ', ' '], signedShield: [' # ', ' ####### ', ' ######### ', ' #######o# ', ' ######o## ', ' #o###o### ', ' ##o#o#### ', ' ##o#### ', ' ##### ', ' ### ', ' # '], key: [' ', ' ', ' ### ', ' # # ', '# #####', '# # # ', ' # # ', ' ### ', ' ', ' ', ' '], clock: [' ##### ', ' # # ', ' # # # ', '# # #', '# # #', '# ### #', '# #', '# #', ' # # ', ' # # ', ' ##### '], log: [' ', '# #########', ' ', '# ###### ', ' ', '# #########', ' ', '# ##### ', ' ', '# ######## ', ' '], wand: [' # #', ' # ', ' # #', ' # ', ' # ', ' # ', ' # ', ' # ', ' # ', ' # ', '# '], bell: [' # ', ' ### ', ' ##### ', ' ####### ', ' ####### ', ' ####### ', ' ####### ', ' ######### ', '###########', ' ', ' ### '], office: [' ##### ', ' # # ', ' # # # ', ' # ####', ' # # # #', ' # # ##', ' # # # #', ' # # ##', ' # # # #', ' # # #', '###########'], bust: [' ##### ', ' # # ', ' # # ', ' # # ', ' ##### ', ' ', ' ####### ', ' # # ', '# #', '# #', '###########'], rocket: [' # ', ' ### ', ' ##### ', ' ## ## ', ' ##### ', ' ##### ', ' ####### ', ' ## ### ## ', ' # ### # ', ' # # ', ' # '], form: ['###########', '# #', '# ####### #', '# #', '# ##### #', '# #', '# ####### #', '# #', '# ### #', '# ### #', '###########'], dashboard: ['###########', '# # #', '# # ##### #', '# # #', '# # ## ## #', '# # ## ## #', '# # #', '# # ##### #', '# # #', '# # #', '###########'], swatches: ['##### ooooo', '##### ooooo', '##### ooooo', '##### ooooo', '##### ooooo', ' ', 'ooooo #####', 'ooooo # # #', 'ooooo ## ##', 'ooooo # # #', 'ooooo #####'], people: [' ### ## ', ' ##### ## ', ' ##### ####', ' ### ####', ' ## ', ' ##### ', '####### ###', '###########', '###########', '###########', ' '], mail: [' ', '###########', '## ##', '# # # #', '# # # #', '# # # #', '# # #', '# #', '# #', '###########', ' '], flow: ['### ', '### ', '### ', ' # ', ' # ### ', ' ######### ', ' # ### ', ' # ', '### ', '### ', '### '], megaphone: [' ##', ' ####', ' ######', '###########', '###########', '###########', ' ######', ' ## ####', ' ## ##', ' ## ', ' '], pen: [' # ', ' ### ', ' ### ', ' ## ## ', ' ## ## ', ' ## ## ', ' ## # ## ', ' ####### ', ' ##### ', ' ##### ', ' ### '], slide: ['###########', '# #', '# ### #', '# ## #', '# ## ## #', '# # ## #', '###########', ' # ', ' ### ', ' # # ', ' # # '], brandedDoc: [' ######### ', ' # # ', ' # ## # ', ' # ## # ', ' # # ', ' # ##### # ', ' # # ', ' # ##### # ', ' # # ', ' # ## # ', ' ######### '], palette: [' ##### ', ' ####### ', ' ##o###o## ', '####### ###', '#o##### ', '###### ', '#o##### ', '########## ', ' ##o###o## ', ' ####### ', ' ##### '], pie: [' ##### ', ' ### # ', ' #### # ', '##### #', '##### #', '###########', '# #', '# #', ' # # ', ' # # ', ' ##### '], share: [' ###', ' ###', ' ## ', ' ## ', '### ', '### ', ' ## ', ' ## ', ' ###', ' ###', ' '], spark: [' # ', ' # ', ' ### ', ' ### ', ' ####### ', '###########', ' ####### ', ' ### ', ' ### ', ' # ', ' # '], textLines: ['###########', ' ', '######## ', ' ', '###########', ' ', '###### ', ' ', '###########', ' ', '####### '],} satisfies Record<string, string[]>;// Each card's pair: what the team builds or the feature is, then what the API gives it. The last five of the first// group belong to feature cards rather than teams, and the last five of all to the products in the header's menu.const PAIRS = { database: ['cylinder', 'chunks'], bot: ['robot', 'browser'], search: ['magnifier', 'bubble'], shield: ['shield', 'eye'], book: ['book', 'layers'], file: ['file', 'checklist'], bolt: ['bolt', 'pencil'], globe: ['globe', 'image'], briefcase: ['briefcase', 'contactCard'], building: ['tower', 'tiles'], chart: ['bars', 'trend'], link: ['chain', 'logo'], store: ['storefront', 'tag'], scroll: ['scroll', 'tick'], signed: ['signedShield', 'key'], history: ['clock', 'log'], wand: ['wand', 'bell'], company: ['office', 'logo'], person: ['bust', 'contactCard'], rocket: ['rocket', 'form'], theme: ['dashboard', 'swatches'], portal: ['browser', 'logo'], team: ['people', 'mail'], tenants: ['layers', 'swatches'], agency: ['people', 'swatches'], whitelabel: ['flow', 'tag'], email: ['mail', 'swatches'], design: ['pen', 'slide'], campaign: ['megaphone', 'mail'], document: ['file', 'brandedDoc'], creative: ['palette', 'layers'], report: ['pie', 'bars'], social: ['share', 'image'], generate: ['spark', 'textLines'], imagegen: ['image', 'swatches'], writing: ['pencil', 'textLines'], slides: ['slide', 'swatches'], chat: ['robot', 'bubble'], outreach: ['bubble', 'mail'], scrape: ['browser', 'textLines'], map: ['globe', 'log'], batches: ['layers', 'checklist'], monitor: ['clock', 'bell'], styleguide: ['palette', 'swatches'],} satisfies Record<string, [keyof typeof ART, keyof typeof ART]>;export type PersonaGlyphName = keyof typeof PAIRS;export const personaGlyph = (name: PersonaGlyphName): GlyphCell[] => glyphCellsFromRows(ART[PAIRS[name][0]]);export const personaOutcome = (name: PersonaGlyphName): GlyphCell[] => glyphCellsFromRows(ART[PAIRS[name][1]]);Reference: site src/components/sections/usecases/persona-glyph.tsx.