Add portfolio-gallery example: procedural avatar showcases

New workspace example rendering Bible Strong avatars via
@bible-strong/avatar-web for Rachel Chen's portfolio aesthetic.

- src/builder.ts: shared AvatarDefinition builder — 12 expressions,
  8 named emotes (idle/hello/curious/look-around/excited/surprise/
  sleepy/shy), lobe/pod/ears/antenna node presets, signature emotes.
- src/gallery.ts: shared grid renderer (hover -> signature emote).
- index.html / src/characters.ts: 20 characters across all engine
  surface types (sphere, mickey, cursor, cube, capsule, cylinder,
  cone, diamond).
- blobs.html / src/blobs.ts: 10 organic characters built from
  overlapping same-color lobes, no angular primitives.
- showcase.html / src/showcase.ts / src/showcase-main.ts: emote
  playground for 12 curated characters (4 new personalities driven
  by extreme eye traits) with per-character emote chips, following
  surface-motion (blur crossfade, staggered entrance, press feedback,
  reduced-motion) and surface-interaction (focal hover staging,
  50-use frequency test, interruptible playback) skill guidance.

pnpm-lock.yaml updated for the new workspace package.
This commit is contained in:
David Lafond 2026-08-23 14:07:15 -04:00
parent 175691ab32
commit 289908323a
17 changed files with 1491 additions and 0 deletions

View File

@ -0,0 +1,38 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#fafcfd" />
<title>Blob Ensemble — for Rachel Chen</title>
</head>
<body>
<div class="wrap">
<header class="masthead">
<span class="brand">
<svg viewBox="0 0 30 30" fill="none" aria-hidden="true">
<path
d="M14.5231 29.2759C12.6685 22.5706 7.42938 17.3315 0.724097 15.4769C0.240255 15.3431 0.240255 14.657 0.724097 14.5231C7.42938 12.6685 12.6685 7.42939 14.5231 0.724099C14.6569 0.240257 15.343 0.240257 15.4769 0.724099C17.3315 7.42939 22.5706 12.6685 29.2759 14.5231C29.7597 14.657 29.7597 15.343 29.2759 15.4769C22.5706 17.3315 17.3315 22.5706 15.4769 29.2759C15.343 29.7597 14.657 29.7597 14.5231 29.2759Z"
fill="currentColor"
/>
</svg>
Blob Ensemble
</span>
<h1>Ten <em>soft, boneless</em> things with no straight edges anywhere.</h1>
<p class="lede">
A second, blobbier cast &mdash; every silhouette is built by melting overlapping rounded
lobes into one lumpy, amoeba-like mass. No cones, cubes or points. Same live avatar
engine, same portfolio palette. Each one idles on its own and waves when you hover.
</p>
</header>
<main id="grid" aria-label="Ten organic blob avatar characters"></main>
<footer class="colophon">
<span>Rendered with @bible-strong/avatar-web</span>
<span>10 blobs · hover to greet</span>
</footer>
</div>
<script type="module" src="/src/blobs-main.ts"></script>
</body>
</html>

View File

@ -0,0 +1,38 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#fafcfd" />
<title>Avatar Ensemble — for Rachel Chen</title>
</head>
<body>
<div class="wrap">
<header class="masthead">
<span class="brand">
<svg viewBox="0 0 30 30" fill="none" aria-hidden="true">
<path
d="M14.5231 29.2759C12.6685 22.5706 7.42938 17.3315 0.724097 15.4769C0.240255 15.3431 0.240255 14.657 0.724097 14.5231C7.42938 12.6685 12.6685 7.42939 14.5231 0.724099C14.6569 0.240257 15.343 0.240257 15.4769 0.724099C17.3315 7.42939 22.5706 12.6685 29.2759 14.5231C29.7597 14.657 29.7597 15.343 29.2759 15.4769C22.5706 17.3315 17.3315 22.5706 15.4769 29.2759C15.343 29.7597 14.657 29.7597 14.5231 29.2759Z"
fill="currentColor"
/>
</svg>
Avatar Ensemble
</span>
<h1>Twenty little characters, built to live on <em>Rachel&rsquo;s</em> pages.</h1>
<p class="lede">
A procedural cast rendered live with the Bible Strong avatar engine, tuned to the
portfolio&rsquo;s palette: warm coral against a cool near-white, slate ink, and soft
tints. Each one idles on its own and waves when you hover.
</p>
</header>
<main id="grid" aria-label="Twenty procedural avatar characters"></main>
<footer class="colophon">
<span>Rendered with @bible-strong/avatar-web</span>
<span>20 designs · hover to greet</span>
</footer>
</div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@ -0,0 +1,18 @@
{
"name": "avatar-portfolio-gallery",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"@bible-strong/avatar-web": "workspace:*"
},
"devDependencies": {
"typescript": "~6.0.3",
"vite": "^8.0.13"
}
}

View File

@ -0,0 +1,39 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#fafcfd" />
<title>Emote Playground — for Rachel Chen</title>
</head>
<body>
<div class="wrap">
<header class="masthead">
<span class="brand">
<svg viewBox="0 0 30 30" fill="none" aria-hidden="true">
<path
d="M14.5231 29.2759C12.6685 22.5706 7.42938 17.3315 0.724097 15.4769C0.240255 15.3431 0.240255 14.657 0.724097 14.5231C7.42938 12.6685 12.6685 7.42939 14.5231 0.724099C14.6569 0.240257 15.343 0.240257 15.4769 0.724099C17.3315 7.42939 22.5706 12.6685 29.2759 14.5231C29.7597 14.657 29.7597 15.343 29.2759 15.4769C22.5706 17.3315 17.3315 22.5706 15.4769 29.2759C15.343 29.7597 14.657 29.7597 14.5231 29.2759Z"
fill="currentColor"
/>
</svg>
Emote Playground
</span>
<h1>Every character now knows <em>eight</em> ways to feel.</h1>
<p class="lede">
Same live avatar engine, richer inner life. Each character carries a full repertoire of
emotes &mdash; idle, wave, curious, look-around, excited, surprise, sleepy, shy &mdash;
driven by the engine&rsquo;s own expression timeline, not CSS. The dotted chip marks each
one&rsquo;s signature. Hover to preview it, or tap any emote to pin it.
</p>
</header>
<main id="grid" aria-label="Avatar emote playground"></main>
<footer class="colophon">
<span>Rendered with @bible-strong/avatar-web</span>
<span>12 characters · 8 emotes each</span>
</footer>
</div>
<script type="module" src="/src/showcase-main.ts"></script>
</body>
</html>

View File

@ -0,0 +1,5 @@
import { blobs } from './blobs'
import { renderGallery } from './gallery'
import './styles.css'
renderGallery('#grid', blobs)

View File

@ -0,0 +1,137 @@
import { buildEnsemble, lobe, pod, sf, type Character, type CharacterConfig } from './builder'
// Ten organic "blob" characters. No angular primitives — only fully-rounded
// spheres, capsules and cylinders. Creative silhouettes come from overlapping
// same-colour soft lobes (kept clear of the face so the eyes stay readable),
// which merge into one lumpy, amoeba-like mass. Same portfolio palette.
const ink = '#2a2f3a'
const cream = '#f4f1ea'
const configs: CharacterConfig[] = [
{
id: 'wobble',
name: 'Wobble',
role: 'Lumpy · Blob',
surface: sf('sphere', 200, 200, 200, 1),
body: '#e65f2e',
eyes: ink,
eye: { w: 24, h: 44, y: -6, spacing: 40 },
nodes: [
lobe(96, -92, -58, -8),
lobe(90, 96, -50, -8),
lobe(92, -88, 64, -8),
lobe(86, 92, 72, -8),
],
},
{
id: 'puddle',
name: 'Puddle',
role: 'Amoeba · Blob',
surface: sf('capsule', 264, 172, 200, 1),
body: '#7aaee9',
eyes: '#213040',
eye: { w: 22, h: 34, y: -2, spacing: 46 },
nodes: [
lobe(84, -128, 6, -8),
lobe(78, 126, -8, -8),
lobe(64, -48, -72, -8),
lobe(60, 58, -74, -8),
lobe(70, 10, 86, -8),
],
},
{
id: 'peanut',
name: 'Peanut',
role: 'Nesting · Blob',
surface: sf('sphere', 168, 168, 168, 1),
body: '#e98d34',
eyes: ink,
eye: { w: 22, h: 40, y: -10, spacing: 38 },
nodes: [lobe(168, 0, 128, -10), lobe(60, -96, 120, -6), lobe(60, 96, 120, -6)],
},
{
id: 'sprout',
name: 'Sprout',
role: 'Budding · Blob',
surface: sf('sphere', 210, 210, 210, 1),
body: '#8fd3b6',
eyes: '#1f463a',
eye: { w: 22, h: 40, y: 0, spacing: 36 },
nodes: [lobe(64, 0, -120, 10), lobe(40, -64, -118, 6), lobe(40, 64, -118, 6)],
},
{
id: 'clover',
name: 'Clover',
role: 'Cluster · Blob',
surface: sf('sphere', 150, 150, 150, 1),
body: '#e88aa0',
eyes: '#3a2f3a',
eye: { w: 20, h: 38, y: -14, spacing: 40 },
nodes: [lobe(150, -92, 66, -6), lobe(150, 92, 66, -6), lobe(120, 0, -96, -6)],
},
{
id: 'bean',
name: 'Bean',
role: 'Kidney · Blob',
surface: sf('capsule', 196, 252, 196, 1),
body: '#8a6bb0',
eyes: cream,
eye: { w: 20, h: 46, y: -6, spacing: 30 },
nodes: [lobe(120, 74, 86, -8), lobe(96, -70, -92, -8)],
},
{
id: 'cloud',
name: 'Cloud',
role: 'Nimbus · Blob',
surface: sf('sphere', 150, 150, 150, 1),
body: '#a9c8ef',
eyes: '#213040',
eye: { w: 20, h: 30, y: -6, spacing: 40 },
nodes: [
lobe(118, -118, 26, -8),
lobe(118, 118, 26, -8),
lobe(96, -58, -38, -8),
lobe(96, 62, -42, -8),
lobe(88, 0, 60, -8),
],
},
{
id: 'dew',
name: 'Dew',
role: 'Droplet · Blob',
surface: sf('sphere', 196, 196, 196, 1),
body: '#4f8fe8',
eyes: cream,
eye: { w: 18, h: 42, y: -8, spacing: 30 },
nodes: [pod(120, 150, 0, 120, -10), lobe(50, -40, -96, 8)],
},
{
id: 'ripple',
name: 'Ripple',
role: 'Caterpillar · Blob',
surface: sf('sphere', 150, 150, 150, 1),
body: '#3fb8a6',
eyes: '#123029',
eye: { w: 20, h: 36, y: -4, spacing: 40 },
nodes: [lobe(140, -118, 4, -8), lobe(140, 118, 4, -8), lobe(70, 0, -92, -8)],
},
{
id: 'lumpy',
name: 'Lumpy',
role: 'Cobble · Blob',
surface: sf('cylinder', 208, 196, 208, 1),
body: '#c96a4a',
eyes: cream,
eye: { w: 22, h: 40, y: -4, spacing: 36 },
nodes: [
lobe(80, -108, -6, -8),
lobe(80, 108, -12, -8),
lobe(70, 0, -108, -8),
lobe(64, -70, 86, -8),
lobe(64, 72, 88, -8),
],
},
]
export const blobs: Character[] = buildEnsemble(configs)

View File

@ -0,0 +1,384 @@
import type { AvatarDefinition } from '@bible-strong/avatar-web'
// Shared avatar builder. Every character is expanded into a complete,
// schema-valid `AvatarDefinition` and rendered through the real
// @bible-strong/avatar-web engine.
export type Surface = {
type: 'sphere' | 'mickey' | 'cursor' | 'cube' | 'capsule' | 'cylinder' | 'cone' | 'diamond'
width: number
height: number
depth: number
roundness: number
morphRoundness?: number
tipRoundness?: number
baseRoundness?: number
}
export type NodeSurface = {
type: 'sphere' | 'cube' | 'capsule' | 'cylinder' | 'cone' | 'diamond'
width: number
height: number
depth: number
roundness: number
tipRoundness?: number
baseRoundness?: number
}
export type BodyNode = {
surface: NodeSurface
position: [number, number, number]
rotation: [number, number, number]
}
export type EyeShape = { w: number; h: number; y: number; angle: number; spacing: number }
export type CharacterConfig = {
id: string
name: string
role: string
surface: Surface
body: string
eyes: string
eye?: Partial<EyeShape>
nodes?: BodyNode[]
// Which emote is this character's hero, played on hover in the gallery.
signature?: EmoteKey
}
export type EmoteKey =
| 'idle'
| 'hello'
| 'curious'
| 'look-around'
| 'excited'
| 'surprise'
| 'sleepy'
| 'shy'
// Ordered emote repertoire every character can perform. `idle` is the resting
// loop; the rest are triggerable one-shot or looping emotes.
export const EMOTES: EmoteKey[] = [
'idle',
'hello',
'curious',
'look-around',
'excited',
'surprise',
'sleepy',
'shy',
]
export const EMOTE_LABELS: Record<EmoteKey, string> = {
idle: 'Idle',
hello: 'Wave',
curious: 'Curious',
'look-around': 'Look around',
excited: 'Excited',
surprise: 'Surprise',
sleepy: 'Sleepy',
shy: 'Shy',
}
export type Character = {
id: string
name: string
role: string
accent: string
signature: EmoteKey
definition: AvatarDefinition
}
export const sf = (
type: Surface['type'],
width: number,
height: number,
depth: number,
roundness: number,
extra: Partial<Surface> = {}
): Surface => ({ type, width, height, depth, roundness, ...extra })
export const nd = (
surface: NodeSurface,
position: [number, number, number],
rotation: [number, number, number] = [0, 0, 0]
): BodyNode => ({ surface, position, rotation })
export const ns = (
type: NodeSurface['type'],
width: number,
height: number,
depth: number,
roundness: number,
extra: Partial<NodeSurface> = {}
): NodeSurface => ({ type, width, height, depth, roundness, ...extra })
// --- Reusable node presets -------------------------------------------------
export const ears = (size: number, x: number, y: number, z = 24): BodyNode[] => [
nd(ns('sphere', size, size, size, 1), [-x, y, z]),
nd(ns('sphere', size, size, size, 1), [x, y, z]),
]
export const antenna = (topY: number): BodyNode[] => [
nd(ns('cylinder', 16, 74, 16, 0.5), [0, topY, 0]),
nd(ns('sphere', 46, 46, 46, 1), [0, topY - 58, 6]),
]
// A single soft, fully-rounded sphere lobe — the building block of blobs.
// Same body colour as the primary surface, so overlaps read as one organic mass.
export const lobe = (size: number, x: number, y: number, z = 18): BodyNode =>
nd(ns('sphere', size, size, size, 1), [x, y, z])
// A rounded capsule lump for drips, tails and stretched bulges.
export const pod = (
w: number,
h: number,
x: number,
y: number,
z = 12,
rot: [number, number, number] = [0, 0, 0]
): BodyNode => nd(ns('capsule', w, h, Math.min(w, h), 1), [x, y, z], rot)
// --- Expression + animation generation ------------------------------------
type EyeValue = { width: number; height: number; x: number; y: number; angle: number }
const eye = (w: number, h: number, x: number, y: number, angle: number): EyeValue => ({
width: w,
height: h,
x,
y,
angle,
})
type MotionKind = { eyes: 'none' | 'microSaccades' | 'shake'; body: 'none' | 'slowDrift' | 'shake' }
const expression = (
head: [number, number, number],
left: EyeValue,
right: EyeValue,
spacing: number,
motion: MotionKind,
perspective = 1
) => ({
head: { x: head[0], y: head[1], z: head[2] },
eyes: { left, right, spacing },
perspective,
motion,
})
const alive: MotionKind = { eyes: 'microSaccades', body: 'slowDrift' }
const still: MotionKind = { eyes: 'none', body: 'none' }
const clampH = (h: number) => Math.max(h, 9)
export const buildDefinition = (c: CharacterConfig): AvatarDefinition => {
const e: EyeShape = { w: 20, h: 50, y: -7, angle: 0, spacing: 35, ...c.eye }
const { w, h, y, spacing: sp } = e
// Symmetric-eye helper: same shape mirrored left/right with an optional
// horizontal look offset and roll angle.
const look = (
head: [number, number, number],
ew: number,
eh: number,
ex: number,
ey: number,
angle: number,
motion: MotionKind,
perspective = 1
) =>
expression(head, eye(ew, eh, ex, ey, angle), eye(ew, eh, ex, ey, angle), sp, motion, perspective)
const expressions = {
// resting + gaze
neutral: look([0, 0, 0], w, h, 0, y, 0, alive),
'glance-left': look([1.5, 17, -6], w, h, -3, y, 0, still),
'glance-right': look([1.5, -17, -6], w, h, 3, y, 0, still),
'look-up': look([-15, 0, 0], w, clampH(h * 1.05), 0, y - 6, 0, still),
'look-down': look([15, 2, 0], w, clampH(h * 0.82), 0, y + 6, 0, still),
// head-roll tilts (curiosity / dizziness)
'tilt-left': look([2, 7, 15], w, h, -2, y, -9, still),
'tilt-right': look([2, -7, -15], w, h, 2, y, 9, still),
// affect
wide: look([-9, 0, 0], w * 1.22, clampH(h * 1.28), 0, y - 4, 0, still, 1.14),
squint: look([7, 0, 0], w * 1.06, clampH(h * 0.3), 0, y + 4, 0, {
eyes: 'none',
body: 'slowDrift',
}),
shy: look([13, 12, 5], w * 0.9, clampH(h * 0.62), 0, y + 6, 0, still, 0.94),
happy: expression(
[-4, 0, 0],
eye(w * 1.05, clampH(h * 0.46), 0, y - 4, -17),
eye(w * 1.05, clampH(h * 0.46), 0, y - 4, 17),
sp,
still
),
'big-happy': expression(
[-9, 0, 0],
eye(w * 1.12, clampH(h * 0.4), 0, y - 6, -23),
eye(w * 1.12, clampH(h * 0.4), 0, y - 6, 23),
sp,
{ eyes: 'none', body: 'shake' }
),
}
const expressionOrder = [
'neutral',
'glance-left',
'glance-right',
'look-up',
'look-down',
'tilt-left',
'tilt-right',
'wide',
'squint',
'shy',
'happy',
'big-happy',
]
const blink = {
enabled: true,
initialDelayMs: 1200,
minIntervalMs: 2600,
maxIntervalMs: 5400,
durationMs: 140,
}
const fastBlink = { ...blink, initialDelayMs: 400, minIntervalMs: 900, maxIntervalMs: 1900, durationMs: 110 }
const slowBlink = { ...blink, initialDelayMs: 300, minIntervalMs: 1400, maxIntervalMs: 2800, durationMs: 300 }
type Trans = 'spring' | 'smooth' | 'snappy'
const step = (expression: string, holdMs: number, transitionMs: number, transition: Trans) => ({
expression,
holdMs,
transitionMs,
transition,
})
const animations = {
idle: {
playbackMode: 'loop' as const,
steps: [
step('neutral', 2400, 520, 'smooth'),
step('glance-left', 1400, 560, 'smooth'),
step('neutral', 1500, 520, 'smooth'),
step('glance-right', 1400, 560, 'smooth'),
step('look-up', 1300, 560, 'smooth'),
step('neutral', 1800, 520, 'smooth'),
step('happy', 1100, 460, 'spring'),
],
blink,
metadata: { label: `${c.name} at rest`, group: 'ensemble' },
},
hello: {
playbackMode: 'loop' as const,
steps: [
step('happy', 620, 220, 'snappy'),
step('neutral', 380, 220, 'snappy'),
step('big-happy', 700, 240, 'snappy'),
step('neutral', 360, 220, 'snappy'),
],
blink: fastBlink,
metadata: { label: `${c.name} waves`, group: 'ensemble' },
},
curious: {
playbackMode: 'loop' as const,
steps: [
step('tilt-left', 1200, 520, 'spring'),
step('look-up', 1100, 560, 'smooth'),
step('tilt-right', 1200, 520, 'spring'),
step('neutral', 900, 520, 'smooth'),
],
blink,
metadata: { label: `${c.name} tilts and wonders`, group: 'ensemble' },
},
'look-around': {
playbackMode: 'loop' as const,
steps: [
step('glance-left', 820, 480, 'smooth'),
step('look-up', 760, 480, 'smooth'),
step('glance-right', 820, 480, 'smooth'),
step('look-down', 760, 480, 'smooth'),
step('neutral', 900, 480, 'smooth'),
],
blink,
metadata: { label: `${c.name} scans the room`, group: 'ensemble' },
},
excited: {
playbackMode: 'pingPong' as const,
steps: [
step('big-happy', 340, 180, 'snappy'),
step('wide', 300, 180, 'snappy'),
step('happy', 320, 180, 'snappy'),
],
blink: fastBlink,
metadata: { label: `${c.name} bounces with joy`, group: 'ensemble' },
},
surprise: {
playbackMode: 'once' as const,
steps: [
step('wide', 820, 150, 'snappy'),
step('neutral', 1200, 460, 'spring'),
],
blink,
metadata: { label: `${c.name} gasps`, group: 'ensemble' },
},
sleepy: {
playbackMode: 'loop' as const,
steps: [
step('squint', 2600, 640, 'smooth'),
step('neutral', 700, 640, 'smooth'),
step('squint', 3000, 720, 'smooth'),
step('look-down', 1400, 680, 'smooth'),
],
blink: slowBlink,
metadata: { label: `${c.name} drifts off`, group: 'ensemble' },
},
shy: {
playbackMode: 'loop' as const,
steps: [
step('shy', 1600, 560, 'smooth'),
step('glance-left', 1000, 520, 'smooth'),
step('shy', 1800, 560, 'smooth'),
],
blink,
metadata: { label: `${c.name} looks away`, group: 'ensemble' },
},
}
const animationOrder = [
'idle',
'hello',
'curious',
'look-around',
'excited',
'surprise',
'sleepy',
'shy',
]
const definition = {
schema: 'bible-strong/avatar-definition' as const,
schemaVersion: 1 as const,
name: c.name,
body: { primary: c.surface, nodes: c.nodes ?? [] },
colors: { body: c.body, eyes: c.eyes },
expressions,
expressionOrder,
animations,
animationOrder,
}
return definition as unknown as AvatarDefinition
}
export const buildEnsemble = (configs: CharacterConfig[]): Character[] =>
configs.map(c => ({
id: c.id,
name: c.name,
role: c.role,
accent: c.body,
signature: c.signature ?? 'hello',
definition: buildDefinition(c),
}))

View File

@ -0,0 +1,204 @@
import {
antenna,
buildEnsemble,
ears,
sf,
type Character,
type CharacterConfig,
} from './builder'
// The original twenty characters for Rachel Chen's portfolio: warm coral
// (#e65f2e) accents on a cool near-white field, slate ink, and soft cool tints.
const ink = '#2a2f3a'
const cream = '#f4f1ea'
const configs: CharacterConfig[] = [
{
id: 'pip',
name: 'Pip',
role: 'Curious · Sphere',
surface: sf('sphere', 236, 236, 236, 1),
body: '#e65f2e',
eyes: ink,
eye: { w: 26, h: 46, y: -6, spacing: 40 },
},
{
id: 'sol',
name: 'Sol',
role: 'Warm · Mickey',
surface: sf('mickey', 232, 222, 222, 1),
body: '#e98d34',
eyes: ink,
eye: { w: 20, h: 48, spacing: 34 },
},
{
id: 'beacon',
name: 'Beacon',
role: 'Focused · Cursor',
surface: sf('cursor', 224, 244, 224, 0.55),
body: '#2f7fe0',
eyes: cream,
eye: { w: 16, h: 54, y: -4, spacing: 30 },
},
{
id: 'mochi',
name: 'Mochi',
role: 'Soft · Capsule',
surface: sf('capsule', 212, 250, 212, 1),
body: '#e88aa0',
eyes: '#3a2f3a',
eye: { w: 22, h: 40, spacing: 36 },
},
{
id: 'pixel',
name: 'Pixel',
role: 'Sharp · Cube',
surface: sf('cube', 220, 220, 220, 0.24),
body: '#3d424d',
eyes: cream,
eye: { w: 18, h: 32, y: -4, spacing: 38 },
},
{
id: 'ember',
name: 'Ember',
role: 'Bold · Cone',
surface: sf('cone', 236, 240, 236, 0.5, { baseRoundness: 1, tipRoundness: 0.3 }),
body: '#d15b32',
eyes: ink,
eye: { w: 20, h: 42, y: 6, spacing: 30 },
},
{
id: 'barrel',
name: 'Barrel',
role: 'Steady · Cylinder',
surface: sf('cylinder', 224, 232, 224, 0.6),
body: '#7aaee9',
eyes: '#213040',
eye: { w: 22, h: 42, spacing: 36 },
},
{
id: 'facet',
name: 'Facet',
role: 'Crisp · Diamond',
surface: sf('diamond', 244, 244, 244, 0.4),
body: '#b3bdf2',
eyes: '#32404f',
eye: { w: 18, h: 40, spacing: 34 },
},
{
id: 'juno',
name: 'Juno',
role: 'Bright · Sphere',
surface: sf('sphere', 236, 236, 236, 1),
body: '#3fb8a6',
eyes: '#123029',
eye: { w: 28, h: 32, y: -5, spacing: 42 },
},
{
id: 'willow',
name: 'Willow',
role: 'Gentle · Capsule',
surface: sf('capsule', 204, 252, 204, 1),
body: '#8a6bb0',
eyes: cream,
eye: { w: 20, h: 50, spacing: 30 },
},
{
id: 'sunny',
name: 'Sunny',
role: 'Playful · Ears',
surface: sf('sphere', 224, 224, 224, 1),
body: '#e98d34',
eyes: ink,
eye: { w: 24, h: 44, y: -4, spacing: 40 },
nodes: ears(78, 96, -96),
},
{
id: 'atlas',
name: 'Atlas',
role: 'Calm · Antenna',
surface: sf('cube', 214, 214, 214, 0.32),
body: '#32404f',
eyes: cream,
eye: { w: 18, h: 40, spacing: 36 },
nodes: antenna(-132),
},
{
id: 'pod',
name: 'Pod',
role: 'Tiny · Sphere',
surface: sf('sphere', 202, 202, 202, 1),
body: '#8fd3b6',
eyes: '#1f463a',
eye: { w: 16, h: 34, spacing: 30 },
},
{
id: 'clay',
name: 'Clay',
role: 'Earthy · Capsule',
surface: sf('capsule', 218, 246, 218, 0.9),
body: '#c96a4a',
eyes: '#241d18',
eye: { w: 22, h: 40, spacing: 36 },
},
{
id: 'iris',
name: 'Iris',
role: 'Dreamy · Sphere',
surface: sf('sphere', 232, 232, 232, 1),
body: '#d0a8d8',
eyes: '#3a2f4a',
eye: { w: 24, h: 46, spacing: 38 },
},
{
id: 'nimbus',
name: 'Nimbus',
role: 'Airy · Mickey',
surface: sf('mickey', 234, 224, 224, 1),
body: '#a9c8ef',
eyes: '#213040',
eye: { w: 20, h: 46, spacing: 36 },
},
{
id: 'spark',
name: 'Spark',
role: 'Electric · Diamond',
surface: sf('diamond', 236, 236, 236, 0.36),
body: '#e65f2e',
eyes: ink,
eye: { w: 18, h: 42, spacing: 32 },
nodes: antenna(-150),
},
{
id: 'dot',
name: 'Dot',
role: 'Minimal · Sphere',
surface: sf('sphere', 230, 230, 230, 1),
body: '#46434b',
eyes: cream,
eye: { w: 18, h: 44, spacing: 34 },
},
{
id: 'tuck',
name: 'Tuck',
role: 'Cozy · Ears',
surface: sf('capsule', 210, 246, 210, 1),
body: '#d97089',
eyes: cream,
eye: { w: 20, h: 44, spacing: 34 },
nodes: ears(66, 82, -104),
},
{
id: 'vertex',
name: 'Vertex',
role: 'Precise · Cone',
surface: sf('cone', 232, 244, 232, 0.4, { baseRoundness: 0.9, tipRoundness: 0.2 }),
body: '#2f7fe0',
eyes: '#eaf1fb',
eye: { w: 18, h: 40, y: 4, spacing: 32 },
},
]
export const characters: Character[] = buildEnsemble(configs)

View File

@ -0,0 +1,42 @@
import { createAvatar } from '@bible-strong/avatar-web'
import type { Character } from './builder'
export const renderGallery = (mountSelector: string, characters: Character[]): void => {
const grid = document.querySelector<HTMLElement>(mountSelector)
if (!grid) throw new Error(`Gallery grid '${mountSelector}' was not found.`)
for (const character of characters) {
const card = document.createElement('article')
card.className = 'card'
card.style.setProperty('--accent', character.accent)
const tile = document.createElement('div')
tile.className = 'tile'
const mount = document.createElement('div')
mount.className = 'avatar'
tile.appendChild(mount)
const meta = document.createElement('div')
meta.className = 'meta'
const name = document.createElement('h2')
name.textContent = character.name
const role = document.createElement('p')
role.textContent = character.role
meta.append(name, role)
card.append(tile, meta)
grid.appendChild(card)
const controller = createAvatar(mount, {
definition: character.definition,
defaultAnimation: 'idle',
size: '100%',
ariaLabel: `${character.name} — ${character.role}`,
onError: error => console.error(`[${character.id}]`, error),
})
card.addEventListener('pointerenter', () => controller.play('hello'))
card.addEventListener('pointerleave', () => controller.play('idle'))
}
}

View File

@ -0,0 +1,5 @@
import { characters } from './characters'
import { renderGallery } from './gallery'
import './styles.css'
renderGallery('#grid', characters)

View File

@ -0,0 +1,115 @@
import { createAvatar } from '@bible-strong/avatar-web'
import { EMOTES, EMOTE_LABELS, type Character, type EmoteKey } from './builder'
import { showcase } from './showcase'
import './showcase.css'
const grid = document.querySelector<HTMLElement>('#grid')
if (!grid) throw new Error('Showcase grid was not found.')
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
// surface-motion: blur-mask crossfade when the emote label text changes.
const swapLabel = (label: HTMLElement, text: string) => {
if (reduceMotion) {
label.textContent = text
return
}
label.style.opacity = '0'
label.style.filter = 'blur(4px)'
window.setTimeout(() => {
label.textContent = text
label.style.opacity = '1'
label.style.filter = 'blur(0px)'
}, 120)
}
const buildCard = (character: Character, index: number) => {
const card = document.createElement('article')
card.className = 'card show-card'
card.style.setProperty('--accent', character.accent)
card.style.setProperty('--i', String(index))
const tile = document.createElement('div')
tile.className = 'tile'
const mount = document.createElement('div')
mount.className = 'avatar'
tile.appendChild(mount)
const meta = document.createElement('div')
meta.className = 'meta'
const name = document.createElement('h2')
name.textContent = character.name
const role = document.createElement('p')
role.textContent = character.role
meta.append(name, role)
const now = document.createElement('p')
now.className = 'now'
now.textContent = reduceMotion ? 'Paused' : `Now · ${EMOTE_LABELS.idle}`
const chips = document.createElement('div')
chips.className = 'chips'
card.append(tile, meta, now, chips)
grid.appendChild(card)
const controller = createAvatar(mount, {
definition: character.definition,
defaultAnimation: reduceMotion ? undefined : 'idle',
autoplay: !reduceMotion,
size: '100%',
ariaLabel: `${character.name} — ${character.role}`,
onError: error => console.error(`[${character.id}]`, error),
})
if (reduceMotion) controller.setExpression('neutral')
// Chip state: `pinned` is the emote the user explicitly selected. Hover only
// previews the signature emote while nothing is pinned.
let pinned: EmoteKey | null = null
const setActiveChip = (active: EmoteKey) => {
for (const button of chips.querySelectorAll<HTMLButtonElement>('.chip')) {
button.setAttribute('aria-pressed', String(button.dataset.emote === active))
}
}
const play = (emote: EmoteKey) => {
const result = controller.play(emote)
if (!result.ok) {
console.error(`[${character.id}] play(${emote})`, result.error)
return
}
swapLabel(now, `Now · ${EMOTE_LABELS[emote]}`)
setActiveChip(emote)
}
for (const emote of EMOTES) {
const chip = document.createElement('button')
chip.type = 'button'
chip.className = 'chip'
chip.dataset.emote = emote
chip.textContent = EMOTE_LABELS[emote]
chip.setAttribute('aria-pressed', String(emote === 'idle' && !reduceMotion))
if (emote === character.signature) chip.classList.add('is-signature')
chip.addEventListener('click', () => {
pinned = emote === 'idle' ? null : emote
play(emote)
})
chips.appendChild(chip)
}
// surface-interaction: the hovered card is the focal one (it lifts and plays
// its signature). We deliberately do NOT dim the rest of the grid — at
// gallery hover frequency that fails the 50-use test.
if (!reduceMotion) {
card.addEventListener('pointerenter', () => {
if (!pinned) play(character.signature)
})
card.addEventListener('pointerleave', () => {
if (!pinned) play('idle')
})
}
}
showcase.forEach(buildCard)

View File

@ -0,0 +1,114 @@
@import './styles.css';
/* Playground-specific layer: emote chips, label crossfade, staged entrance.
All motion follows surface-motion: transform/opacity only, ease
cubic-bezier(0.16,1,0.3,1), durations <=300ms, exits faster than entrances. */
#grid {
grid-template-columns: repeat(auto-fill, minmax(248px, 1fr));
}
.now {
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: color-mix(in oklab, var(--accent) 70%, var(--foreground));
margin: 2px 4px 0;
transition:
opacity 140ms cubic-bezier(0.16, 1, 0.3, 1),
filter 140ms cubic-bezier(0.16, 1, 0.3, 1);
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 4px 2px 0;
}
.chip {
appearance: none;
border: 1px solid var(--border);
background: color-mix(in oklab, var(--accent) 6%, #ffffff);
color: var(--foreground);
font-family: var(--font-mono);
font-size: 10.5px;
letter-spacing: 0.04em;
padding: 5px 9px;
border-radius: 999px;
cursor: pointer;
transition:
transform 140ms cubic-bezier(0.16, 1, 0.3, 1),
background-color 140ms ease,
border-color 140ms ease,
color 140ms ease;
}
.chip:hover {
border-color: color-mix(in oklab, var(--accent) 40%, var(--border));
}
/* Press feedback: subtle scale-down per surface-motion (0.96-0.98). */
.chip:active {
transform: scale(0.97);
}
.chip:focus-visible {
outline: 2px solid color-mix(in oklab, var(--accent) 60%, var(--foreground));
outline-offset: 2px;
}
.chip[aria-pressed='true'] {
background: color-mix(in oklab, var(--accent) 88%, #ffffff);
border-color: transparent;
color: #ffffff;
}
/* The signature emote carries a small dot so each character's hero is legible. */
.chip.is-signature::before {
content: '';
display: inline-block;
width: 5px;
height: 5px;
border-radius: 999px;
margin-right: 5px;
vertical-align: middle;
background: var(--accent);
}
.chip.is-signature[aria-pressed='true']::before {
background: #ffffff;
}
/* Staged entrance: children cascade in (surface-motion staggered entrance).
Keyframe, because it runs once and is not interactive. */
@keyframes card-enter {
from {
opacity: 0;
transform: translateY(10px);
filter: blur(6px);
}
to {
opacity: 1;
transform: translateY(0);
filter: blur(0);
}
}
.show-card {
opacity: 0;
animation: card-enter 460ms cubic-bezier(0.16, 1, 0.3, 1) both;
animation-delay: calc(var(--i, 0) * 55ms);
}
@media (prefers-reduced-motion: reduce) {
.show-card {
animation: none;
opacity: 1;
}
.now,
.chip {
transition: none;
}
}

View File

@ -0,0 +1,144 @@
import { buildEnsemble, lobe, sf, type Character, type CharacterConfig } from './builder'
// A curated cast for the emote playground. Each character is assigned a
// distinct "signature" hero emote so the ensemble reads as individuals, not
// palette swaps. Four are new personalities built around an extreme eye trait.
const ink = '#2a2f3a'
const cream = '#f4f1ea'
const configs: CharacterConfig[] = [
{
id: 'pip',
name: 'Pip',
role: 'Curious · Sphere',
surface: sf('sphere', 236, 236, 236, 1),
body: '#e65f2e',
eyes: ink,
eye: { w: 26, h: 46, y: -6, spacing: 40 },
signature: 'curious',
},
{
id: 'juno',
name: 'Juno',
role: 'Bright · Sphere',
surface: sf('sphere', 236, 236, 236, 1),
body: '#3fb8a6',
eyes: '#123029',
eye: { w: 28, h: 34, y: -5, spacing: 42 },
signature: 'excited',
},
{
id: 'willow',
name: 'Willow',
role: 'Gentle · Capsule',
surface: sf('capsule', 204, 252, 204, 1),
body: '#8a6bb0',
eyes: cream,
eye: { w: 20, h: 50, spacing: 30 },
signature: 'shy',
},
{
id: 'sol',
name: 'Sol',
role: 'Warm · Mickey',
surface: sf('mickey', 232, 222, 222, 1),
body: '#e98d34',
eyes: ink,
eye: { w: 20, h: 48, spacing: 34 },
signature: 'look-around',
},
{
id: 'wobble',
name: 'Wobble',
role: 'Lumpy · Blob',
surface: sf('sphere', 200, 200, 200, 1),
body: '#e65f2e',
eyes: ink,
eye: { w: 24, h: 44, y: -6, spacing: 40 },
nodes: [lobe(96, -92, -58, -8), lobe(90, 96, -50, -8), lobe(92, -88, 64, -8), lobe(86, 92, 72, -8)],
signature: 'hello',
},
{
id: 'cloud',
name: 'Cloud',
role: 'Nimbus · Blob',
surface: sf('sphere', 150, 150, 150, 1),
body: '#a9c8ef',
eyes: '#213040',
eye: { w: 20, h: 30, y: -6, spacing: 40 },
nodes: [
lobe(118, -118, 26, -8),
lobe(118, 118, 26, -8),
lobe(96, -58, -38, -8),
lobe(96, 62, -42, -8),
lobe(88, 0, 60, -8),
],
signature: 'sleepy',
},
{
id: 'bean',
name: 'Bean',
role: 'Kidney · Blob',
surface: sf('capsule', 196, 252, 196, 1),
body: '#8a6bb0',
eyes: cream,
eye: { w: 20, h: 46, y: -6, spacing: 30 },
nodes: [lobe(120, 74, 86, -8), lobe(96, -70, -92, -8)],
signature: 'curious',
},
{
id: 'ripple',
name: 'Ripple',
role: 'Caterpillar · Blob',
surface: sf('sphere', 150, 150, 150, 1),
body: '#3fb8a6',
eyes: '#123029',
eye: { w: 20, h: 36, y: -4, spacing: 40 },
nodes: [lobe(140, -118, 4, -8), lobe(140, 118, 4, -8), lobe(70, 0, -92, -8)],
signature: 'look-around',
},
// --- New personalities, each built around one extreme eye trait ----------
{
id: 'sage',
name: 'Sage',
role: 'Half-lidded · Capsule',
surface: sf('capsule', 212, 244, 212, 1),
body: '#5b6b7d',
eyes: cream,
eye: { w: 26, h: 18, y: -2, spacing: 40 }, // wide, low lids → drowsy wisdom
signature: 'sleepy',
},
{
id: 'zap',
name: 'Zap',
role: 'Saucer-eyed · Sphere',
surface: sf('sphere', 232, 232, 232, 1),
body: '#f04e23',
eyes: '#1a1414',
eye: { w: 40, h: 58, y: -4, spacing: 52 }, // huge eyes → hyper
signature: 'excited',
},
{
id: 'momo',
name: 'Momo',
role: 'Pin-dot · Sphere',
surface: sf('sphere', 214, 214, 214, 1),
body: '#e88aa0',
eyes: '#3a2f3a',
eye: { w: 12, h: 16, y: -2, spacing: 22 }, // tiny close-set → bashful
signature: 'shy',
},
{
id: 'orbit',
name: 'Orbit',
role: 'Wide-set · Mickey',
surface: sf('mickey', 238, 226, 226, 1),
body: '#b3bdf2',
eyes: '#2a2f4a',
eye: { w: 22, h: 44, y: -4, spacing: 66 }, // far-apart eyes → dreamy
signature: 'curious',
},
]
export const showcase: Character[] = buildEnsemble(configs)

View File

@ -0,0 +1,176 @@
@import url('https://fonts.bunny.net/css?family=fraunces:400,500,600|geist:400,500|geist-mono:400,500');
:root {
--background: #fafcfd;
--foreground: #32404f;
--muted: #32404f95;
--accent-brand: #e65f2e;
--border: color-mix(in oklab, #32404f 12%, transparent);
--font-display: 'Fraunces', 'Tiempos', Georgia, serif;
--font-body: 'Geist', system-ui, sans-serif;
--font-mono: 'Geist Mono', ui-monospace, monospace;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
}
body {
background: var(--background);
color: var(--foreground);
font-family: var(--font-body);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
.wrap {
max-width: 1240px;
margin: 0 auto;
padding: clamp(40px, 7vw, 96px) clamp(20px, 5vw, 64px) 96px;
}
header.masthead {
display: flex;
flex-direction: column;
gap: 20px;
padding-bottom: clamp(40px, 6vw, 72px);
}
.brand {
display: inline-flex;
align-items: center;
gap: 10px;
font-family: var(--font-mono);
font-size: 12px;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--muted);
}
.brand svg {
width: 16px;
height: 16px;
color: var(--accent-brand);
}
h1 {
font-family: var(--font-display);
font-weight: 400;
font-size: clamp(34px, 6vw, 68px);
line-height: 1.02;
letter-spacing: -0.02em;
margin: 0;
max-width: 18ch;
}
h1 em {
font-style: italic;
color: var(--accent-brand);
}
.lede {
max-width: 62ch;
margin: 0;
font-size: 17px;
line-height: 1.55;
color: var(--muted);
}
#grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(216px, 1fr));
gap: clamp(16px, 2vw, 28px);
}
.card {
--tile-tint: color-mix(in oklab, var(--accent) 12%, var(--background));
--tile-edge: color-mix(in oklab, var(--accent) 22%, var(--background));
display: flex;
flex-direction: column;
gap: 14px;
padding: 16px 16px 20px;
border: 1px solid var(--border);
border-radius: 22px;
background: #ffffff;
transition:
transform 0.4s cubic-bezier(0.22, 1, 0.36, 1),
box-shadow 0.4s cubic-bezier(0.22, 1, 0.36, 1),
border-color 0.4s ease;
will-change: transform;
}
.card:hover {
transform: translateY(-6px);
box-shadow: 0 24px 48px -28px color-mix(in oklab, var(--accent) 60%, transparent);
border-color: color-mix(in oklab, var(--accent) 32%, var(--border));
}
.tile {
aspect-ratio: 1;
border-radius: 16px;
display: grid;
place-items: center;
background: radial-gradient(120% 120% at 50% 18%, var(--tile-tint), var(--tile-edge));
overflow: hidden;
}
.avatar {
width: 78%;
height: 78%;
display: block;
}
.avatar svg {
width: 100%;
height: 100%;
overflow: visible;
}
.meta {
display: flex;
flex-direction: column;
gap: 3px;
padding: 0 4px;
}
.meta h2 {
font-family: var(--font-display);
font-weight: 500;
font-size: 20px;
letter-spacing: -0.01em;
margin: 0;
}
.meta p {
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
margin: 0;
}
footer.colophon {
margin-top: clamp(48px, 7vw, 88px);
padding-top: 28px;
border-top: 1px solid var(--border);
font-family: var(--font-mono);
font-size: 12px;
letter-spacing: 0.04em;
color: var(--muted);
display: flex;
flex-wrap: wrap;
gap: 8px 20px;
justify-content: space-between;
}
@media (prefers-reduced-motion: reduce) {
.card {
transition: none;
}
}

View File

@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"types": ["vite/client"],
"noEmit": true
},
"include": ["src"]
}

View File

@ -0,0 +1,6 @@
import { defineConfig } from 'vite'
export default defineConfig({
server: { port: 4330, strictPort: true },
preview: { port: 4330, strictPort: true },
})

13
pnpm-lock.yaml generated
View File

@ -106,6 +106,19 @@ importers:
specifier: ^4.1.10 specifier: ^4.1.10
version: 4.1.10(@types/node@24.13.3)(jsdom@30.0.1)(vite@8.2.1(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0)) version: 4.1.10(@types/node@24.13.3)(jsdom@30.0.1)(vite@8.2.1(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0))
examples/portfolio-gallery:
dependencies:
'@bible-strong/avatar-web':
specifier: workspace:*
version: link:../../packages/avatar-web
devDependencies:
typescript:
specifier: ~6.0.3
version: 6.0.3
vite:
specifier: ^8.0.13
version: 8.2.1(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0)
examples/react-vite-consumer: examples/react-vite-consumer:
dependencies: dependencies:
'@bible-strong/avatar-core': '@bible-strong/avatar-core':