diff --git a/examples/portfolio-gallery/blobs.html b/examples/portfolio-gallery/blobs.html new file mode 100644 index 0000000..560538f --- /dev/null +++ b/examples/portfolio-gallery/blobs.html @@ -0,0 +1,38 @@ + + + + + + + Blob Ensemble — for Rachel Chen + + +
+
+ + + Blob Ensemble + +

Ten soft, boneless things with no straight edges anywhere.

+

+ A second, blobbier cast — 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. +

+
+ +
+ + +
+ + + diff --git a/examples/portfolio-gallery/index.html b/examples/portfolio-gallery/index.html new file mode 100644 index 0000000..e7200f8 --- /dev/null +++ b/examples/portfolio-gallery/index.html @@ -0,0 +1,38 @@ + + + + + + + Avatar Ensemble — for Rachel Chen + + +
+
+ + + Avatar Ensemble + +

Twenty little characters, built to live on Rachel’s pages.

+

+ A procedural cast rendered live with the Bible Strong avatar engine, tuned to the + portfolio’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. +

+
+ +
+ + +
+ + + diff --git a/examples/portfolio-gallery/package.json b/examples/portfolio-gallery/package.json new file mode 100644 index 0000000..649c9ab --- /dev/null +++ b/examples/portfolio-gallery/package.json @@ -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" + } +} diff --git a/examples/portfolio-gallery/showcase.html b/examples/portfolio-gallery/showcase.html new file mode 100644 index 0000000..065841d --- /dev/null +++ b/examples/portfolio-gallery/showcase.html @@ -0,0 +1,39 @@ + + + + + + + Emote Playground — for Rachel Chen + + +
+
+ + + Emote Playground + +

Every character now knows eight ways to feel.

+

+ Same live avatar engine, richer inner life. Each character carries a full repertoire of + emotes — idle, wave, curious, look-around, excited, surprise, sleepy, shy — + driven by the engine’s own expression timeline, not CSS. The dotted chip marks each + one’s signature. Hover to preview it, or tap any emote to pin it. +

+
+ +
+ + +
+ + + diff --git a/examples/portfolio-gallery/src/blobs-main.ts b/examples/portfolio-gallery/src/blobs-main.ts new file mode 100644 index 0000000..5a056ac --- /dev/null +++ b/examples/portfolio-gallery/src/blobs-main.ts @@ -0,0 +1,5 @@ +import { blobs } from './blobs' +import { renderGallery } from './gallery' +import './styles.css' + +renderGallery('#grid', blobs) diff --git a/examples/portfolio-gallery/src/blobs.ts b/examples/portfolio-gallery/src/blobs.ts new file mode 100644 index 0000000..18f93c0 --- /dev/null +++ b/examples/portfolio-gallery/src/blobs.ts @@ -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) diff --git a/examples/portfolio-gallery/src/builder.ts b/examples/portfolio-gallery/src/builder.ts new file mode 100644 index 0000000..9f25316 --- /dev/null +++ b/examples/portfolio-gallery/src/builder.ts @@ -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 + 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 = { + 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 => ({ 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 => ({ 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), + })) diff --git a/examples/portfolio-gallery/src/characters.ts b/examples/portfolio-gallery/src/characters.ts new file mode 100644 index 0000000..dfdd4a4 --- /dev/null +++ b/examples/portfolio-gallery/src/characters.ts @@ -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) diff --git a/examples/portfolio-gallery/src/gallery.ts b/examples/portfolio-gallery/src/gallery.ts new file mode 100644 index 0000000..e84d795 --- /dev/null +++ b/examples/portfolio-gallery/src/gallery.ts @@ -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(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')) + } +} diff --git a/examples/portfolio-gallery/src/main.ts b/examples/portfolio-gallery/src/main.ts new file mode 100644 index 0000000..914fec8 --- /dev/null +++ b/examples/portfolio-gallery/src/main.ts @@ -0,0 +1,5 @@ +import { characters } from './characters' +import { renderGallery } from './gallery' +import './styles.css' + +renderGallery('#grid', characters) diff --git a/examples/portfolio-gallery/src/showcase-main.ts b/examples/portfolio-gallery/src/showcase-main.ts new file mode 100644 index 0000000..e514704 --- /dev/null +++ b/examples/portfolio-gallery/src/showcase-main.ts @@ -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('#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('.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) diff --git a/examples/portfolio-gallery/src/showcase.css b/examples/portfolio-gallery/src/showcase.css new file mode 100644 index 0000000..8258a8d --- /dev/null +++ b/examples/portfolio-gallery/src/showcase.css @@ -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; + } +} diff --git a/examples/portfolio-gallery/src/showcase.ts b/examples/portfolio-gallery/src/showcase.ts new file mode 100644 index 0000000..739f6c5 --- /dev/null +++ b/examples/portfolio-gallery/src/showcase.ts @@ -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) diff --git a/examples/portfolio-gallery/src/styles.css b/examples/portfolio-gallery/src/styles.css new file mode 100644 index 0000000..297d1f4 --- /dev/null +++ b/examples/portfolio-gallery/src/styles.css @@ -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; + } +} diff --git a/examples/portfolio-gallery/tsconfig.json b/examples/portfolio-gallery/tsconfig.json new file mode 100644 index 0000000..a466ef4 --- /dev/null +++ b/examples/portfolio-gallery/tsconfig.json @@ -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"] +} diff --git a/examples/portfolio-gallery/vite.config.ts b/examples/portfolio-gallery/vite.config.ts new file mode 100644 index 0000000..b14beef --- /dev/null +++ b/examples/portfolio-gallery/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from 'vite' + +export default defineConfig({ + server: { port: 4330, strictPort: true }, + preview: { port: 4330, strictPort: true }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d8eac6e..2e4f4be 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -106,6 +106,19 @@ importers: 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)) + 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: dependencies: '@bible-strong/avatar-core':