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:
parent
175691ab32
commit
289908323a
38
examples/portfolio-gallery/blobs.html
Normal file
38
examples/portfolio-gallery/blobs.html
Normal 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 — 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>
|
||||||
38
examples/portfolio-gallery/index.html
Normal file
38
examples/portfolio-gallery/index.html
Normal 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’s</em> pages.</h1>
|
||||||
|
<p class="lede">
|
||||||
|
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.
|
||||||
|
</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>
|
||||||
18
examples/portfolio-gallery/package.json
Normal file
18
examples/portfolio-gallery/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
39
examples/portfolio-gallery/showcase.html
Normal file
39
examples/portfolio-gallery/showcase.html
Normal 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 — 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.
|
||||||
|
</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>
|
||||||
5
examples/portfolio-gallery/src/blobs-main.ts
Normal file
5
examples/portfolio-gallery/src/blobs-main.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { blobs } from './blobs'
|
||||||
|
import { renderGallery } from './gallery'
|
||||||
|
import './styles.css'
|
||||||
|
|
||||||
|
renderGallery('#grid', blobs)
|
||||||
137
examples/portfolio-gallery/src/blobs.ts
Normal file
137
examples/portfolio-gallery/src/blobs.ts
Normal 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)
|
||||||
384
examples/portfolio-gallery/src/builder.ts
Normal file
384
examples/portfolio-gallery/src/builder.ts
Normal 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),
|
||||||
|
}))
|
||||||
204
examples/portfolio-gallery/src/characters.ts
Normal file
204
examples/portfolio-gallery/src/characters.ts
Normal 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)
|
||||||
42
examples/portfolio-gallery/src/gallery.ts
Normal file
42
examples/portfolio-gallery/src/gallery.ts
Normal 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'))
|
||||||
|
}
|
||||||
|
}
|
||||||
5
examples/portfolio-gallery/src/main.ts
Normal file
5
examples/portfolio-gallery/src/main.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { characters } from './characters'
|
||||||
|
import { renderGallery } from './gallery'
|
||||||
|
import './styles.css'
|
||||||
|
|
||||||
|
renderGallery('#grid', characters)
|
||||||
115
examples/portfolio-gallery/src/showcase-main.ts
Normal file
115
examples/portfolio-gallery/src/showcase-main.ts
Normal 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)
|
||||||
114
examples/portfolio-gallery/src/showcase.css
Normal file
114
examples/portfolio-gallery/src/showcase.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
144
examples/portfolio-gallery/src/showcase.ts
Normal file
144
examples/portfolio-gallery/src/showcase.ts
Normal 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)
|
||||||
176
examples/portfolio-gallery/src/styles.css
Normal file
176
examples/portfolio-gallery/src/styles.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
13
examples/portfolio-gallery/tsconfig.json
Normal file
13
examples/portfolio-gallery/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
6
examples/portfolio-gallery/vite.config.ts
Normal file
6
examples/portfolio-gallery/vite.config.ts
Normal 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
13
pnpm-lock.yaml
generated
@ -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':
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user