feat(runtime): unify React and ESM avatar exports

Add shared core-backed React and browser runtimes, continuous playback transitions, export previews and API documentation. Package the JavaScript export as a CDN-powered demo with the portable avatar definition.
This commit is contained in:
smontlouis 2026-08-17 17:27:20 +02:00
parent 7bed34cc56
commit 2386b78817
46 changed files with 3663 additions and 1704 deletions

View File

@ -20,7 +20,7 @@ The application runs entirely in the browser. Projects are stored locally and ca
- Choose loop, play-once, or ping-pong playback and configure automatic blinking.
- Preview, play, pause, and stop animations inside the Studio.
- Take SVG or PNG snapshots with transparent, solid, linear-gradient, or radial-gradient backgrounds.
- Export a standalone React package or a framework-free JavaScript/HTML package.
- Export one portable `.avatar.json` definition for React or framework-free JavaScript/ESM.
- Export and import the complete Studio project as JSON.
- Use the interface in English, French, or Simplified Chinese.
@ -45,13 +45,21 @@ This copy-on-write model lets multiple avatars share the defaults without accide
## Export formats
### React package
### Avatar definition
The React export is a local ZIP package containing a reusable TypeScript/React avatar component and the selected animations. It is intended for integration into React applications without shipping the Avatar Lab interface.
The selected avatar and animations are stored in one portable `.avatar.json` definition. React and
JavaScript use this exact same file, so visual behavior does not diverge between renderers.
### JavaScript package
### React / TypeScript
The JavaScript export is a self-contained ZIP project with an ES module, the selected avatar data and animations, and an HTML demo. It can be used without React.
Install `@bible-strong/avatar-react`, import the JSON and pass it to `createAvatar`. The React
package depends on `@bible-strong/avatar-core` for validation, playback and geometry.
### JavaScript / ESM
Install `@bible-strong/avatar-web` for a DOM renderer without React. The integration ZIP contains
the same `.avatar.json`, a lightweight ESM wrapper and usage instructions; it does not copy the
rendering engine into every avatar export. `avatar-web` also depends on `avatar-core`.
### Photo Mode

View File

@ -1,5 +1,5 @@
import { validateAvatarDefinition, type AvatarDefinition } from '@bible-strong/avatar-core'
import { Avatar } from '@bible-strong/avatar-react'
import { Avatar, createAvatar } from '@bible-strong/avatar-react'
import '@bible-strong/avatar-react/styles.css'
import { StrictMode, useState } from 'react'
import { createRoot } from 'react-dom/client'
@ -10,7 +10,9 @@ import './styles.css'
const validation = validateAvatarDefinition(definitionJson)
if (!validation.ok) throw new Error(validation.errors[0]?.message)
const definition = validation.value as AvatarDefinition
const expressions = Object.keys(definition.expressions)
const StrobiAvatar = createAvatar(definitionJson)
type StrobiExpressionKey = keyof typeof definitionJson.expressions
const expressions = Object.keys(definition.expressions) as StrobiExpressionKey[]
const horizontalPosition = (expression: string) =>
expression.includes('left') ? 'left' : expression.includes('right') ? 'right' : 'center'
const verticalPosition = (expression: string) =>
@ -38,7 +40,7 @@ const formatExpressionName = (expression: string) =>
expression.replaceAll('-', ' ').replace(/\b\w/g, letter => letter.toUpperCase())
function Demo() {
const [expression, setExpression] = useState('neutral')
const [expression, setExpression] = useState<StrobiExpressionKey>('neutral')
return (
<main>
@ -80,15 +82,14 @@ function Demo() {
))}
</div>
</section>
<Avatar
definition={definition}
expression={expression}
mode="floating"
draggable
size={128}
initialPosition={{ right: 24, bottom: 24 }}
ariaLabel="Floating draggable Strobi avatar"
/>
<div className="avatar-overlay" aria-label="Positioned avatar example">
<Avatar
definition={definition}
expression={expression}
size={128}
ariaLabel="Positioned Strobi avatar"
/>
</div>
</main>
)
}

View File

@ -1728,6 +1728,5 @@
"scared",
"playful",
"celebrate"
],
"standardAnimationSet": 1
]
}

View File

@ -27,6 +27,15 @@ main {
background: white;
}
.avatar-overlay {
position: fixed;
right: 24px;
bottom: 24px;
z-index: 10;
width: 128px;
height: 128px;
}
.expression-picker {
width: min(100%, 720px);
}

View File

@ -0,0 +1,21 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Avatar Web consumer</title>
</head>
<body>
<main>
<div id="avatar"></div>
<div class="controls">
<button type="button" data-animation="sleeping">Sleeping</button>
<button type="button" data-animation="idle">Idle</button>
<button type="button" data-expression="neutral">Neutral</button>
<button type="button" id="pause">Pause</button>
<button type="button" id="stop">Stop</button>
</div>
</main>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

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

View File

@ -0,0 +1,21 @@
import { createAvatar } from '@bible-strong/avatar-web'
import definition from '../../react-vite-consumer/src/strobi.avatar.json'
import './styles.css'
const avatar = createAvatar('#avatar', {
definition,
defaultAnimation: 'sleeping',
size: '100%',
})
document.querySelectorAll<HTMLButtonElement>('[data-animation]').forEach(button => {
button.addEventListener('click', () => avatar.play(button.dataset.animation ?? 'idle'))
})
document.querySelectorAll<HTMLButtonElement>('[data-expression]').forEach(button => {
button.addEventListener('click', () =>
avatar.setExpression(button.dataset.expression ?? 'neutral')
)
})
document.querySelector('#pause')?.addEventListener('click', () => avatar.pause())
document.querySelector('#stop')?.addEventListener('click', () => avatar.stop())

View File

@ -0,0 +1,41 @@
:root {
color: #17191d;
background: #f4f6fa;
font-family: Inter, system-ui, sans-serif;
}
body {
display: grid;
min-height: 100vh;
margin: 0;
place-items: center;
}
main {
display: grid;
width: min(480px, calc(100% - 32px));
gap: 20px;
}
#avatar {
width: 100%;
aspect-ratio: 1;
border: 1px solid #dce1ea;
border-radius: 24px;
background: white;
}
.controls {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 8px;
}
button {
padding: 9px 13px;
border: 1px solid #cfd6e2;
border-radius: 9px;
background: white;
cursor: pointer;
}

View File

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

View File

@ -28,21 +28,23 @@ Both functions return a non-mutating discriminated result. Successful values are
errors include an RFC 6901 JSON Pointer, code and message. The committed JSON Schema is exported as
`@bible-strong/avatar-core/schema`.
Definitions from the earlier pre-release runtime export may still contain
`standardAnimationSet: 1`; the marker is accepted for compatibility but does not add any
implicit animations.
## Semantic lookup and playback
Public calls use semantic keys only. `resolveExpression` and `resolveAnimation` return typed errors
for unknown keys; standard animations additionally report
`unavailable_standard_animation` when their required expressions are absent.
for keys that are not present in the validated definition. Every animation is explicit in the
definition, so the JSON remains the single source of truth for what an avatar can play.
```ts
import {
advanceAvatarPlayback,
getStandardAnimationAvailabilityV1,
playAvatarAnimation,
renderAvatarFrame,
} from '@bible-strong/avatar-core'
const availability = getStandardAnimationAvailabilityV1(definition.expressions)
const started = playAvatarAnimation(definition, 'idle', 0)
if (!started.ok) throw new Error(started.error.message)
@ -70,5 +72,9 @@ animated frame. Both return paths, visibility and resolved colors without creati
- `@bible-strong/avatar-core/geometry`, `/body`, `/surfaces`, `/ambient-motion`: advanced pure
primitives for renderer authors.
Application integrations are kept in separate packages: `@bible-strong/avatar-react` renders with
React 19, while `@bible-strong/avatar-web` mounts the same definition directly into the DOM. Both
depend on this package and use the same playback and scene implementation.
This package is private while copyright ownership, Apache-2.0 relicensing and repository metadata
are confirmed. Local tarballs are for verification only and must not be published.

View File

@ -12,6 +12,7 @@ import {
renderAvatarFrame,
resumeAvatarPlayback,
resolveAnimation,
sampleAvatarFrame,
type AvatarDefinition,
} from '../index'
@ -41,13 +42,32 @@ const definition: AvatarDefinition = {
'curious-left': { ...expression, head: { x: 0, y: -12, z: 3 } },
},
expressionOrder: ['neutral', 'upward-side-glance', 'curious-left'],
animations: {},
animationOrder: [],
standardAnimationSet: 1,
animations: {
idle: {
playbackMode: 'loop',
steps: [
{
expression: 'upward-side-glance',
holdMs: 5_200,
transitionMs: 500,
transition: 'smooth',
},
{ expression: 'curious-left', holdMs: 5_200, transitionMs: 500, transition: 'smooth' },
],
blink: {
enabled: true,
initialDelayMs: 2_600,
minIntervalMs: 3_400,
maxIntervalMs: 6_200,
durationMs: 280,
},
},
},
animationOrder: ['idle'],
}
describe('@bible-strong/avatar-core', () => {
it('loads a JSON definition and resolves a standard semantic animation', () => {
it('loads a JSON definition and resolves an explicit semantic animation', () => {
const parsed = parseAvatarDefinition(JSON.stringify(definition))
expect(parsed.ok).toBe(true)
if (!parsed.ok) return
@ -61,6 +81,23 @@ describe('@bible-strong/avatar-core', () => {
])
})
it('accepts the legacy standard-animation marker without restoring hidden animations', () => {
const legacy = {
...definition,
animations: {},
animationOrder: [],
standardAnimationSet: 1 as const,
}
const parsed = parseAvatarDefinition(JSON.stringify(legacy))
expect(parsed.ok).toBe(true)
if (!parsed.ok) return
expect(resolveAnimation(parsed.value, 'idle')).toMatchObject({
ok: false,
error: { code: 'unknown_animation', key: 'idle' },
})
})
it('advances playback deterministically from transition to hold and the next step', () => {
const started = playAvatarAnimation(definition, 'idle', 1_000)
expect(started.ok).toBe(true)
@ -85,12 +122,15 @@ describe('@bible-strong/avatar-core', () => {
})
it('interpolates and completes a direct expression transition', () => {
const from = sampleAvatarFrame(definition, createAvatarPlaybackState(), 1_000, {
random: () => 0.5,
})
const state = {
...createAvatarPlaybackState(),
activeExpression: 'curious-left',
status: 'playing' as const,
directTransition: {
from: 'neutral',
from,
startedAt: 1_000,
durationMs: 400,
transition: 'smooth' as const,
@ -108,6 +148,84 @@ describe('@bible-strong/avatar-core', () => {
})
})
it('starts a new animation from the currently displayed frame instead of neutral', () => {
const current = {
...createAvatarPlaybackState(),
activeExpression: 'curious-left',
}
const now = 1_000
const from = sampleAvatarFrame(definition, current, now, { random: () => 0.5 })
const started = playAvatarAnimation(definition, 'idle', now, from)
if (!started.ok) throw new Error(started.error.message)
expect(renderAvatarFrame(definition, started.value, now, { random: () => 0.5 })).toEqual(
renderAvatarFrame(definition, current, now, { random: () => 0.5 })
)
})
it('retargets a direct transition from its exact in-flight frame', () => {
const neutral = createAvatarPlaybackState()
const firstStartedAt = 1_000
const first = {
...neutral,
activeExpression: 'curious-left',
status: 'playing' as const,
directTransition: {
from: sampleAvatarFrame(definition, neutral, firstStartedAt, { random: () => 0.5 }),
startedAt: firstStartedAt,
durationMs: 400,
transition: 'smooth' as const,
},
}
const retargetedAt = 1_200
const inFlight = sampleAvatarFrame(definition, first, retargetedAt, { random: () => 0.5 })
const second = {
...createAvatarPlaybackState(),
activeExpression: 'upward-side-glance',
status: 'playing' as const,
directTransition: {
from: inFlight,
startedAt: retargetedAt,
durationMs: 400,
transition: 'smooth' as const,
},
}
expect(renderAvatarFrame(definition, second, retargetedAt, { random: () => 0.5 })).toEqual(
renderAvatarFrame(definition, first, retargetedAt, { random: () => 0.5 })
)
})
it('interpolates expression color overrides during a transition', () => {
const colored: AvatarDefinition = {
...definition,
expressions: {
...definition.expressions,
'curious-left': {
...definition.expressions['curious-left'],
colors: { body: '#ff0000', eyes: '#ffffff' },
},
},
}
const neutral = createAvatarPlaybackState()
const state = {
...neutral,
activeExpression: 'curious-left',
status: 'playing' as const,
directTransition: {
from: sampleAvatarFrame(colored, neutral, 1_000, { random: () => 0.5 }),
startedAt: 1_000,
durationMs: 400,
transition: 'smooth' as const,
},
}
expect(renderAvatarFrame(colored, state, 1_200, { random: () => 0.5 }).colors).toEqual({
body: '#ad4073',
eyes: '#88898b',
})
})
it('generates the same geometry through the public definition adapter', () => {
const scene = renderAvatarDefinition(definition, 'curious-left')
const body = bodyFromDefinition(definition.body)
@ -192,18 +310,18 @@ describe('@bible-strong/avatar-core', () => {
expect(closed.geometry.leftPath).not.toBe(open.geometry.leftPath)
})
it('returns typed errors for unknown and unavailable semantic animations', () => {
it('returns a typed error for an animation that is not present in the definition', () => {
expect(resolveAnimation(definition, 'missing')).toMatchObject({
ok: false,
error: { code: 'unknown_animation', key: 'missing' },
})
expect(resolveAnimation(definition, 'happy')).toMatchObject({
ok: false,
error: { code: 'unavailable_standard_animation', key: 'happy' },
error: { code: 'unknown_animation', key: 'happy' },
})
})
it('lets an explicit animation override the standard animation with the same key', () => {
it('resolves an explicit animation by its semantic key', () => {
const overridden: AvatarDefinition = {
...definition,
animations: {

View File

@ -12,8 +12,7 @@
"expressions",
"expressionOrder",
"animations",
"animationOrder",
"standardAnimationSet"
"animationOrder"
],
"properties": {
"schema": { "const": "bible-strong/avatar-definition" },
@ -49,7 +48,7 @@
"uniqueItems": true,
"items": { "$ref": "#/$defs/semanticKey" }
},
"standardAnimationSet": { "const": 1 }
"standardAnimationSet": { "const": 1, "deprecated": true }
},
"$defs": {
"semanticKey": {

View File

@ -107,139 +107,8 @@ export type AvatarDefinition = {
expressionOrder: ExpressionKey[]
animations: Record<AnimationKey, AvatarAnimationDefinition>
animationOrder: AnimationKey[]
standardAnimationSet: 1
}
export const STANDARD_ANIMATION_ORDER_V1 = [
'idle',
'happy',
'sad',
'thinking',
'excited',
'celebrate',
] as const
export type StandardAnimationKeyV1 = (typeof STANDARD_ANIMATION_ORDER_V1)[number]
export type StandardAnimationManifestEntry = AvatarAnimationDefinition & {
requiredExpressions: ExpressionKey[]
}
const standardStep = (
expression: ExpressionKey,
holdMs: number
): AvatarAnimationStepDefinition => ({
expression,
holdMs,
transitionMs: 500,
transition: 'smooth',
})
const standardBlink = (
initialDelayMs: number,
minIntervalMs: number,
maxIntervalMs: number,
durationMs: number
) => ({ enabled: true, initialDelayMs, minIntervalMs, maxIntervalMs, durationMs })
export const STANDARD_ANIMATIONS_V1 = {
idle: {
requiredExpressions: ['upward-side-glance', 'curious-left'],
playbackMode: 'loop',
steps: [standardStep('upward-side-glance', 5200), standardStep('curious-left', 5200)],
blink: standardBlink(2600, 3400, 6200, 280),
},
happy: {
requiredExpressions: [
'joyful-down-right',
'joyful-wide',
'playful-right',
'gentle-downward-gaze',
],
playbackMode: 'loop',
steps: [
standardStep('joyful-down-right', 2300),
standardStep('joyful-wide', 2300),
standardStep('playful-right', 2300),
standardStep('gentle-downward-gaze', 2300),
],
blink: standardBlink(2100, 2800, 5000, 260),
},
sad: {
requiredExpressions: ['sleepy-squint', 'eyes-closed', 'drowsy-closed'],
playbackMode: 'loop',
steps: [
standardStep('sleepy-squint', 3600),
standardStep('eyes-closed', 3600),
standardStep('drowsy-closed', 3600),
],
blink: standardBlink(4800, 6500, 9500, 420),
},
thinking: {
requiredExpressions: [
'curious-left',
'angry-left',
'skeptical-left',
'playful-right',
'skeptical-right',
],
playbackMode: 'loop',
steps: [
standardStep('curious-left', 2300),
standardStep('angry-left', 2300),
standardStep('skeptical-left', 2300),
standardStep('playful-right', 2300),
standardStep('skeptical-right', 2300),
],
blink: standardBlink(2100, 2800, 5000, 260),
},
excited: {
requiredExpressions: [
'joyful-down-right',
'playful-right',
'surprised-wide-left',
'surprised-left',
'joyful-wide',
],
playbackMode: 'loop',
steps: [
standardStep('joyful-down-right', 2300),
standardStep('playful-right', 2300),
standardStep('surprised-wide-left', 2300),
standardStep('surprised-left', 2300),
standardStep('joyful-wide', 2300),
],
blink: standardBlink(1200, 1800, 3600, 220),
},
celebrate: {
requiredExpressions: ['joyful-down-right', 'curious-left', 'playful-right'],
playbackMode: 'loop',
steps: [
standardStep('joyful-down-right', 2300),
standardStep('curious-left', 2300),
standardStep('playful-right', 2300),
],
blink: standardBlink(1200, 1800, 3600, 220),
},
} satisfies Record<StandardAnimationKeyV1, StandardAnimationManifestEntry>
export type StandardAnimationAvailabilityV1 = {
available: StandardAnimationKeyV1[]
unavailable: { key: StandardAnimationKeyV1; missingExpressions: ExpressionKey[] }[]
}
export const getStandardAnimationAvailabilityV1 = (
expressions: Readonly<Record<ExpressionKey, AvatarExpressionDefinition>>
): StandardAnimationAvailabilityV1 => {
const available: StandardAnimationKeyV1[] = []
const unavailable: StandardAnimationAvailabilityV1['unavailable'] = []
STANDARD_ANIMATION_ORDER_V1.forEach(key => {
const missingExpressions = STANDARD_ANIMATIONS_V1[key].requiredExpressions.filter(
expression => !(expression in expressions)
)
if (missingExpressions.length) unavailable.push({ key, missingExpressions })
else available.push(key)
})
return { available, unavailable }
/** @deprecated Accepted for pre-release JSON compatibility; it no longer adds animations. */
standardAnimationSet?: 1
}
export type AvatarDefinitionError = {
@ -342,6 +211,7 @@ const semanticErrors = (definition: AvatarDefinition): AvatarDefinitionError[] =
path: '/expressionOrder' | '/animationOrder'
) => {
const ordered = new Set(order)
const knownKeys = new Set(keys)
keys.forEach(key => {
if (!ordered.has(key)) {
errors.push({
@ -352,7 +222,7 @@ const semanticErrors = (definition: AvatarDefinition): AvatarDefinitionError[] =
}
})
order.forEach((key, index) => {
if (!keys.includes(key)) {
if (!knownKeys.has(key)) {
errors.push({
path: childPointer(path, index),
code: 'unknown_order_key',

View File

@ -478,12 +478,21 @@ export const poseFromExpression = (expression: Expression): AvatarPose => ({
export const interpolatePose = (from: AvatarPose, to: AvatarPose, progress: number): AvatarPose => {
const expression: Expression = { ...from.expression }
expressionFields.forEach(field => {
expression[field] =
from.expression[field] + (to.expression[field] - from.expression[field]) * progress
let target = to.expression[field]
if (
field === 'headX' ||
field === 'headY' ||
field === 'headZ' ||
field === 'leftAngle' ||
field === 'rightAngle'
) {
target = nearestEquivalentAngle(target, from.expression[field])
}
expression[field] = from.expression[field] + (target - from.expression[field]) * progress
})
return {
expression,
orientation: slerpQuaternion(from.orientation, to.orientation, progress),
orientation: poseFromExpression(expression).orientation,
}
}

View File

@ -1,6 +1,4 @@
import {
getStandardAnimationAvailabilityV1,
STANDARD_ANIMATIONS_V1,
type AnimationKey,
type AvatarAnimationDefinition,
type AvatarDefinition,
@ -8,11 +6,11 @@ import {
type ExpressionKey,
} from './avatarDefinition'
import { applyAmbientMotion } from './ambientMotion'
import { interpolatePose, poseFromExpression } from './geometry'
import { interpolatePose, poseFromExpression, type Expression } from './geometry'
import { expressionFromDefinition, renderAvatarExpression, type AvatarScene } from './scene'
export type AvatarRuntimeError = {
code: 'unknown_animation' | 'unavailable_standard_animation' | 'unknown_expression'
code: 'unknown_animation' | 'unknown_expression'
key: string
message: string
}
@ -38,28 +36,12 @@ export const resolveAnimation = (
key: AnimationKey
): AvatarCommandResult<Readonly<AvatarAnimationDefinition>> => {
const explicit = definition.animations[key]
if (explicit) return { ok: true, value: explicit }
if (!(key in STANDARD_ANIMATIONS_V1)) {
return {
ok: false,
error: { code: 'unknown_animation', key, message: `Unknown animation '${key}'` },
}
}
const availability = getStandardAnimationAvailabilityV1(definition.expressions)
const unavailable = availability.unavailable.find(item => item.key === key)
if (unavailable) {
return {
ok: false,
error: {
code: 'unavailable_standard_animation',
key,
message: `Animation '${key}' requires: ${unavailable.missingExpressions.join(', ')}`,
},
}
}
const { requiredExpressions: _requiredExpressions, ...animation } =
STANDARD_ANIMATIONS_V1[key as keyof typeof STANDARD_ANIMATIONS_V1]
return { ok: true, value: animation }
return explicit
? { ok: true, value: explicit }
: {
ok: false,
error: { code: 'unknown_animation', key, message: `Unknown animation '${key}'` },
}
}
export type AvatarPlaybackState = {
@ -71,17 +53,23 @@ export type AvatarPlaybackState = {
phase: 'transition' | 'hold'
phaseStartedAt: number
transitionFrom: ExpressionKey
transitionSnapshot?: AvatarFrameSnapshot
pausedAt?: number
blinkDueAt?: number
blinkStartedAt?: number
directTransition?: {
from: ExpressionKey
from: AvatarFrameSnapshot
startedAt: number
durationMs: number
transition: AvatarAnimationDefinition['steps'][number]['transition']
}
}
export type AvatarFrameSnapshot = {
expression: Expression
colors: AvatarScene['colors']
}
export type AvatarRuntimeEnvironment = {
random: () => number
reduceMotion?: boolean
@ -100,7 +88,8 @@ export const createAvatarPlaybackState = (): AvatarPlaybackState => ({
export const playAvatarAnimation = (
definition: Readonly<AvatarDefinition>,
key: AnimationKey,
now: number
now: number,
from?: AvatarFrameSnapshot
): AvatarCommandResult<AvatarPlaybackState> => {
const result = resolveAnimation(definition, key)
if (!result.ok) return result
@ -115,6 +104,7 @@ export const playAvatarAnimation = (
phase: 'transition',
phaseStartedAt: now,
transitionFrom: 'neutral',
...(from ? { transitionSnapshot: from } : {}),
blinkDueAt: now + result.value.blink.initialDelayMs,
},
}
@ -189,6 +179,7 @@ export const advanceAvatarPlayback = (
next.direction = cursor.direction
next.phase = 'transition'
next.transitionFrom = next.activeExpression
delete next.transitionSnapshot
next.activeExpression = animation.steps[cursor.stepIndex].expression
}
return next
@ -210,6 +201,14 @@ export const resumeAvatarPlayback = (
...state,
status: 'playing',
phaseStartedAt: state.phaseStartedAt + pauseDuration,
...(state.directTransition
? {
directTransition: {
...state.directTransition,
startedAt: state.directTransition.startedAt + pauseDuration,
},
}
: {}),
...(state.blinkDueAt === undefined ? {} : { blinkDueAt: state.blinkDueAt + pauseDuration }),
...(state.blinkStartedAt === undefined
? {}
@ -229,6 +228,38 @@ const easing = (
return Math.max(0, Math.min(1, (1 - Math.exp(-6 * progress) * Math.cos(8 * progress)) / end))
}
const expressionColors = (
definition: Readonly<AvatarDefinition>,
expression: Readonly<AvatarExpressionDefinition>
): AvatarScene['colors'] => ({
body: expression.colors?.body ?? definition.colors.body,
eyes: expression.colors?.eyes ?? definition.colors.eyes,
})
const interpolateHexColor = (from: string, to: string, progress: number) => {
const parse = (color: string) => {
const value = color.slice(1)
const expanded = value.length === 3 ? [...value].map(part => `${part}${part}`).join('') : value
return [0, 2, 4].map(index => Number.parseInt(expanded.slice(index, index + 2), 16))
}
const source = parse(from)
const target = parse(to)
if (source.some(Number.isNaN) || target.some(Number.isNaN)) return progress < 1 ? from : to
return `#${source
.map((value, index) => Math.round(value + (target[index] - value) * progress))
.map(value => value.toString(16).padStart(2, '0'))
.join('')}`
}
const interpolateColors = (
from: AvatarScene['colors'],
to: AvatarScene['colors'],
progress: number
): AvatarScene['colors'] => ({
body: interpolateHexColor(from.body, to.body, progress),
eyes: interpolateHexColor(from.eyes, to.eyes, progress),
})
export const blinkOpacityAt = (
animation: Readonly<AvatarAnimationDefinition>,
state: Readonly<AvatarPlaybackState>,
@ -240,54 +271,78 @@ export const blinkOpacityAt = (
return Math.abs(progress * 2 - 1)
}
export const renderAvatarFrame = (
export const sampleAvatarFrame = (
definition: Readonly<AvatarDefinition>,
state: Readonly<AvatarPlaybackState>,
now: number,
environment: AvatarRuntimeEnvironment
): AvatarScene => {
): AvatarFrameSnapshot & { blink: number; sampledAt: number } => {
const sampledAt = state.status === 'paused' && state.pausedAt !== undefined ? state.pausedAt : now
const targetDefinition = definition.expressions[state.activeExpression]
if (!targetDefinition)
return renderAvatarExpression(
definition,
expressionFromDefinition('neutral', definition.expressions.neutral)
)
if (!targetDefinition) {
const neutral = definition.expressions.neutral
return {
expression: expressionFromDefinition('neutral', neutral),
colors: expressionColors(definition, neutral),
blink: 1,
sampledAt,
}
}
let expression = expressionFromDefinition(state.activeExpression, targetDefinition)
let colors = expressionColors(definition, targetDefinition)
let blink = 1
if (state.directTransition && !environment.reduceMotion) {
const fromDefinition = definition.expressions[state.directTransition.from]
if (fromDefinition) {
const from = expressionFromDefinition(state.directTransition.from, fromDefinition)
const progress = easing(
state.directTransition.transition,
(now - state.directTransition.startedAt) / Math.max(state.directTransition.durationMs, 1)
)
expression = interpolatePose(
poseFromExpression(from),
poseFromExpression(expression),
progress
).expression
}
const progress = easing(
state.directTransition.transition,
(sampledAt - state.directTransition.startedAt) /
Math.max(state.directTransition.durationMs, 1)
)
expression = interpolatePose(
poseFromExpression(state.directTransition.from.expression),
poseFromExpression(expression),
progress
).expression
colors = interpolateColors(state.directTransition.from.colors, colors, progress)
} else if (state.activeAnimation) {
const resolved = resolveAnimation(definition, state.activeAnimation)
if (resolved.ok) {
const step = resolved.value.steps[state.stepIndex]
if (state.phase === 'transition' && step && !environment.reduceMotion) {
const fromDefinition = definition.expressions[state.transitionFrom]
if (fromDefinition) {
const from = expressionFromDefinition(state.transitionFrom, fromDefinition)
const from =
state.transitionSnapshot?.expression ??
(fromDefinition
? expressionFromDefinition(state.transitionFrom, fromDefinition)
: undefined)
const fromColors =
state.transitionSnapshot?.colors ??
(fromDefinition ? expressionColors(definition, fromDefinition) : undefined)
if (from && fromColors) {
const duration = Math.max(step.transitionMs, 1)
const progress = easing(step.transition, (now - state.phaseStartedAt) / duration)
const progress = easing(step.transition, (sampledAt - state.phaseStartedAt) / duration)
expression = interpolatePose(
poseFromExpression(from),
poseFromExpression(expression),
progress
).expression
colors = interpolateColors(fromColors, colors, progress)
}
}
blink = blinkOpacityAt(resolved.value, state, now)
blink = blinkOpacityAt(resolved.value, state, sampledAt)
}
}
if (!environment.reduceMotion) expression = applyAmbientMotion(expression, now)
return renderAvatarExpression(definition, expression, targetDefinition.colors, blink)
return { expression, colors, blink, sampledAt }
}
export const renderAvatarFrame = (
definition: Readonly<AvatarDefinition>,
state: Readonly<AvatarPlaybackState>,
now: number,
environment: AvatarRuntimeEnvironment
): AvatarScene => {
const frame = sampleAvatarFrame(definition, state, now, environment)
const expression = environment.reduceMotion
? frame.expression
: applyAmbientMotion(frame.expression, frame.sampledAt)
return renderAvatarExpression(definition, expression, frame.colors, frame.blink)
}

View File

@ -9,6 +9,9 @@ dependencies; `@bible-strong/avatar-core` is installed as a normal dependency.
pnpm add @bible-strong/avatar-react react react-dom
```
The packages are currently private. This registry command will work after publication; use the
workspace packages or locally packed tarballs for development verification in the meantime.
Import the package stylesheet once in the application entry point:
```tsx
@ -29,69 +32,96 @@ export function Assistant({ definition }: { definition: AvatarDefinition }) {
}
```
For a reusable component tied to one JSON definition, use `createAvatar`. It validates the
definition once and, when the JSON is statically typed, narrows `animation`, `defaultAnimation`,
`expression` and `defaultExpression` to the semantic keys present in that definition:
```tsx
import { createAvatar } from '@bible-strong/avatar-react'
import avatarJson from './strobi.avatar.json'
const StrobiAvatar = createAvatar(avatarJson)
export function Strobi() {
return <StrobiAvatar defaultAnimation="idle" />
}
```
Definitions fetched at runtime are validated by the same factory, but their keys are necessarily
checked at runtime rather than inferred by TypeScript.
`play` and `setExpression` return `{ ok: true }` or a typed error with one of
`unknown_animation`, `unavailable_standard_animation`, `unknown_expression` or
`controlled_by_props`. `pause` freezes the exact timeline position, calling `play` with the paused
key resumes it, and `stop` returns an uncontrolled avatar to `neutral`.
`unknown_animation`, `unknown_expression` or `controlled_by_props`. `pause` freezes the exact
timeline position, calling `play` with the paused key resumes it, and `stop` returns an
uncontrolled avatar to `neutral`.
## Playback props
## Props reference
- `animation` and `expression` are mutually exclusive controlled targets. They take priority over
defaults and imperative target changes.
- `defaultAnimation` and `defaultExpression` initialize uncontrolled use. Animation autoplay is on
by default when `defaultAnimation` is present; set `autoplay={false}` to show its first expression
statically.
- `onAnimationEnd` fires once when a `once` animation completes naturally.
- `onExpressionChange` reports semantic expression changes.
- `size`, `className`, `style` and `ariaLabel` customize layout without changing the definition.
`Avatar` exposes typed props for the definition, playback state and presentation. `AnimationKey`
and `ExpressionKey` are semantic string keys from the supplied definition.
### Definition and playback
| Prop | Type | Default | Behavior |
| ------------------- | ------------------------------------ | -------- | -------------------------------------------------------------------------------------------------------------------------- |
| `definition` | `AvatarDefinition` | required | Validated JSON definition containing the expressions and animations to render. |
| `animation` | `AnimationKey \| undefined` | — | Controlled timeline. Each step chooses the displayed expression. Mutually exclusive with `expression`. |
| `expression` | `ExpressionKey \| undefined` | — | Controlled direct expression. Mutually exclusive with `animation`. |
| `defaultAnimation` | `AnimationKey \| undefined` | — | Initial uncontrolled timeline, read on mount. Autoplay is enabled by default. Mutually exclusive with `defaultExpression`. |
| `defaultExpression` | `ExpressionKey \| undefined` | — | Initial uncontrolled expression, read on mount without starting a timeline. Mutually exclusive with `defaultAnimation`. |
| `autoplay` | `boolean \| undefined` | `true` | Starts `defaultAnimation` automatically. It has no effect without `defaultAnimation`. |
| `ref` | `Ref<AvatarController> \| undefined` | — | Exposes the imperative API described below. |
`animation` and `expression` are two alternative sources of truth. Passing both throws an error;
the component never silently overrides one with the other. A controlled target takes priority over
an uncontrolled default when they are intentionally mixed.
### Presentation
| Prop | Type | Default | Behavior |
| ----------- | ------------------------------- | ------------------- | --------------------------------------------------------------------------- |
| `size` | `number \| string \| undefined` | `240` | Number or CSS value applied to the wrapper width and height. |
| `className` | `string \| undefined` | — | CSS class added to the outer wrapper. |
| `style` | `CSSProperties \| undefined` | — | Inline styles for the outer wrapper. `width` and `height` come from `size`. |
| `ariaLabel` | `string \| undefined` | `Procedural avatar` | Accessible name announced to screen readers. |
### Playback callbacks
| Prop | Type | Receives |
| -------------------- | ------------------------------------- | ---------------------------------------------------------------------- |
| `onAnimationEnd` | `(animation: AnimationKey) => void` | The key of a `once` animation when it completes naturally. |
| `onExpressionChange` | `(expression: ExpressionKey) => void` | The semantic expression key whenever the displayed expression changes. |
| `onError` | `(error: AvatarRuntimeError) => void` | An unknown animation or expression key supplied through props. |
Unknown keys passed through `animation`, `expression`, `defaultAnimation` or `defaultExpression`
are reported to `onError`. Without an error handler, the component writes the typed runtime error
to the developer console instead of failing silently.
## Imperative API
Pass a `ref` to receive an `AvatarController`. Use it when buttons, events or another imperative
source need to drive an uncontrolled avatar:
| Method | Type | Behavior |
| --------------------------- | ---------------------------------------------------- | -------------------------------------------------------------- |
| `play(animation)` | `(animation: AnimationKey) => AvatarCommandResult` | Starts an animation or resumes it from its paused position. |
| `pause()` | `() => void` | Freezes the exact timeline position. |
| `stop()` | `() => void` | Stops playback and resets an uncontrolled avatar to `neutral`. |
| `setExpression(expression)` | `(expression: ExpressionKey) => AvatarCommandResult` | Shows one expression directly. |
| `getState()` | `() => AvatarPlaybackState` | Returns `activeAnimation?`, `activeExpression` and `status`. |
`play` and `setExpression` return `{ ok: true }` or `{ ok: false, error }`. Errors include
`unknown_animation`, `unknown_expression` and `controlled_by_props`. When `animation` or
`expression` is controlled by props, use those props to change the target; imperative target
commands cannot replace the parent value.
The definition is validated once per immutable object reference and revalidated/reinitialized when
that reference changes.
## Embedded and floating layout
Embedded mode is the default and stays in the caller's layout:
```tsx
<div className="assistant-zone">
<Avatar definition={definition} animation="idle" />
</div>
```
Floating mode uses fixed positioning and portals to `document.body` after hydration. Supply
`portalContainer` for a dedicated overlay root. Server rendering emits a neutral fixed-size
placeholder before the portal handoff.
```tsx
<Avatar
definition={definition}
mode="floating"
draggable
initialPosition={{ right: 24, bottom: 24 }}
constrainTo="viewport"
zIndex={1000}
/>
```
`initialPosition` accepts `{ x, y }` or top/right/bottom/left anchors. A controlled `position` wins
over it. `constrainTo` accepts `none`, `viewport` or `parent`; floating defaults to `viewport`, while
embedded defaults to `none`. A constrained embedded parent needs a definite rendered size.
Dragging uses Pointer Events and direct transforms. `onPositionPreview` is limited to one callback
per animation frame and `onPositionCommit` reports the final clamped point. `onDragStart` and
`onDragEnd` bracket pointer movement. In controlled position mode callbacks report suggestions but
the supplied position remains authoritative. When resized bounds invalidate a controlled position,
`onPositionChange` reports the clamped suggestion. Resize re-clamping never emits a movement commit.
When `draggable` is enabled, arrow keys move by 10 px, Shift+Arrow by 1 px, and Escape cancels an
active pointer drag. Accessible directional and reset buttons provide the same movement without a
pointer.
## Styling hooks
The stylesheet exposes `.bs-avatar`, `.bs-avatar--embedded`, `.bs-avatar--floating`,
`.bs-avatar--draggable`, `.bs-avatar--dragging`, `.bs-avatar__svg` and
`.bs-avatar__move-controls`. Consumer `className` and `style` are applied to the outer wrapper.
The stylesheet exposes `.bs-avatar` and `.bs-avatar__svg`. Consumer `className` and `style` are
applied to the outer wrapper.
The public component never exposes Studio IDs or document types. This package is private while
copyright ownership, Apache-2.0 relicensing and repository metadata are confirmed. Local tarballs

View File

@ -1,6 +1,7 @@
import {
advanceAvatarPlayback,
createAvatarPlaybackState,
MAX_BODY_NODES,
playAvatarAnimation,
pauseAvatarPlayback,
renderAvatarDefinition,
@ -8,6 +9,7 @@ import {
resolveAnimation,
resolveExpression,
resumeAvatarPlayback,
sampleAvatarFrame,
validateAvatarDefinition,
type AnimationKey,
type AvatarDefinition,
@ -19,19 +21,28 @@ import {
useEffect,
useId,
useImperativeHandle,
useLayoutEffect,
useRef,
useState,
type CSSProperties,
type PointerEvent as ReactPointerEvent,
type ReactElement,
type Ref,
} from 'react'
import { createPortal } from 'react-dom'
import './styles.css'
const validatedDefinitions = new WeakSet<object>()
const controlledExpressionTransitionMs = 420
const bodyPathSlots = MAX_BODY_NODES + 2
const runtimeEnvironment = () => ({
random: Math.random,
reduceMotion: window.matchMedia('(prefers-reduced-motion: reduce)').matches,
})
export const markAvatarDefinitionValidated = (definition: object) => {
validatedDefinitions.add(definition)
}
const assertValidDefinition = (definition: AvatarDefinition) => {
if (validatedDefinitions.has(definition)) return
@ -65,35 +76,24 @@ export type AvatarController = {
getState(): AvatarPlaybackState
}
export type AvatarPosition = { x: number; y: number }
export type FloatingInitialPosition =
AvatarPosition | { top?: number; right?: number; bottom?: number; left?: number }
export type AvatarProps = {
definition: AvatarDefinition
ref?: Ref<AvatarController>
/** Controlled animation timeline. Mutually exclusive with `expression`. */
animation?: AnimationKey
/** Controlled expression target. Mutually exclusive with `animation`. */
expression?: ExpressionKey
/** Uncontrolled initial animation. Mutually exclusive with `defaultExpression`. */
defaultAnimation?: AnimationKey
/** Uncontrolled initial expression. Mutually exclusive with `defaultAnimation`. */
defaultExpression?: ExpressionKey
autoplay?: boolean
size?: number | string
className?: string
style?: CSSProperties
mode?: 'embedded' | 'floating'
portalContainer?: HTMLElement
draggable?: boolean
constrainTo?: 'none' | 'viewport' | 'parent'
position?: AvatarPosition
initialPosition?: FloatingInitialPosition
zIndex?: number
ariaLabel?: string
onPositionPreview?: (position: AvatarPosition) => void
onPositionCommit?: (position: AvatarPosition) => void
/** Suggested clamped position when controlled bounds change. */
onPositionChange?: (position: AvatarPosition) => void
onDragStart?: () => void
onDragEnd?: (position: AvatarPosition) => void
/** Receives invalid animation or expression targets supplied through props. */
onError?: (error: AvatarRuntimeError) => void
onAnimationEnd?: (animation: AnimationKey) => void
onExpressionChange?: (expression: ExpressionKey) => void
}
@ -109,26 +109,54 @@ const samePlayback = (left: CorePlaybackState, right: CorePlaybackState) =>
left.transitionFrom === right.transitionFrom &&
left.blinkDueAt === right.blinkDueAt &&
left.blinkStartedAt === right.blinkStartedAt &&
left.transitionSnapshot === right.transitionSnapshot &&
left.directTransition?.from === right.directTransition?.from &&
left.directTransition?.startedAt === right.directTransition?.startedAt &&
left.directTransition?.durationMs === right.directTransition?.durationMs &&
left.directTransition?.transition === right.directTransition?.transition
const samePosition = (left: AvatarPosition, right: AvatarPosition) =>
left.x === right.x && left.y === right.y
const assertPlaybackProps = ({
animation,
expression,
defaultAnimation,
defaultExpression,
}: Pick<AvatarProps, 'animation' | 'expression' | 'defaultAnimation' | 'defaultExpression'>) => {
if (animation !== undefined && expression !== undefined) {
throw new Error(
'Avatar accepts either animation or expression, not both. Animation controls a timeline; expression controls a single target.'
)
}
if (defaultAnimation !== undefined && defaultExpression !== undefined) {
throw new Error(
'Avatar accepts either defaultAnimation or defaultExpression, not both. Choose one uncontrolled initial target.'
)
}
}
const sizeInPixels = (size: number | string | undefined) => (typeof size === 'number' ? size : 240)
const initialPoint = (
value: FloatingInitialPosition | undefined,
width: number,
height: number
): AvatarPosition => {
if (!value) return { x: 32, y: 32 }
if ('x' in value && 'y' in value) return { x: value.x, y: value.y }
const x = value.left ?? Math.max(0, width - (value.right ?? 32))
const y = value.top ?? Math.max(0, height - (value.bottom ?? 32))
return { x, y }
const createInitialPlayback = (
definition: AvatarDefinition,
animation: AnimationKey | undefined,
expression: ExpressionKey | undefined,
defaultAnimation: AnimationKey | undefined,
defaultExpression: ExpressionKey | undefined
): CorePlaybackState => {
const animationKey = animation ?? (expression === undefined ? defaultAnimation : undefined)
if (animationKey) {
const result = resolveAnimation(definition, animationKey)
if (result.ok) {
return {
...createAvatarPlaybackState(),
activeExpression: result.value.steps[0]?.expression ?? 'neutral',
}
}
}
const expressionKey =
expression ?? (animation === undefined ? defaultExpression : undefined) ?? 'neutral'
const resolved = resolveExpression(definition, expressionKey)
return {
...createAvatarPlaybackState(),
activeExpression: resolved.ok ? expressionKey : 'neutral',
}
}
export function Avatar({
@ -142,29 +170,15 @@ export function Avatar({
size = 240,
className,
style,
mode = 'embedded',
portalContainer,
draggable = false,
constrainTo,
position,
initialPosition,
zIndex = 1000,
ariaLabel = 'Procedural avatar',
onPositionPreview,
onPositionCommit,
onPositionChange,
onDragStart,
onDragEnd,
onError,
onAnimationEnd,
onExpressionChange,
}: AvatarProps): ReactElement {
if (animation !== undefined && expression !== undefined) {
throw new Error('Avatar accepts either animation or expression, not both.')
}
assertPlaybackProps({ animation, expression, defaultAnimation, defaultExpression })
assertValidDefinition(definition)
const clipId = `${useId().replaceAll(':', '')}-head`
const wrapperRef = useRef<HTMLDivElement>(null)
const clipPathRef = useRef<SVGPathElement>(null)
const headPathRef = useRef<SVGPathElement>(null)
const leftPathRef = useRef<SVGPathElement>(null)
@ -172,101 +186,83 @@ export function Avatar({
const backPathRefs = useRef<(SVGPathElement | null)[]>([])
const frontPathRefs = useRef<(SVGPathElement | null)[]>([])
const defaultPlaybackStarted = useRef(false)
const floatingPositionInitialized = useRef(false)
const previewFrame = useRef<number | undefined>(undefined)
const completedAnimation = useRef<AnimationKey | undefined>(undefined)
const playbackRef = useRef<CorePlaybackState>(createAvatarPlaybackState())
const drag = useRef<
| {
pointerId: number
pointer: AvatarPosition
origin: AvatarPosition
current: AvatarPosition
}
| undefined
>(undefined)
const [mounted, setMounted] = useState(false)
const [internalPosition, setInternalPosition] = useState<AvatarPosition>({ x: 0, y: 0 })
const [playback, setPlayback] = useState<CorePlaybackState>(() => {
const key = animation ?? defaultAnimation
if (key) {
const result = resolveAnimation(definition, key)
if (result.ok) {
return {
...createAvatarPlaybackState(),
activeExpression: result.value.steps[0]?.expression ?? 'neutral',
}
}
}
const keyExpression = expression ?? defaultExpression ?? 'neutral'
return { ...createAvatarPlaybackState(), activeExpression: keyExpression }
const playbackRef = useRef<CorePlaybackState | null>(null)
const paintedFrameRef = useRef<ReturnType<typeof sampleAvatarFrame> | null>(null)
const previousDefinitionRef = useRef(definition)
const [playback, setPlayback] = useState<CorePlaybackState>(() =>
createInitialPlayback(definition, animation, expression, defaultAnimation, defaultExpression)
)
const paintScene = (frameScene: ReturnType<typeof renderAvatarFrame>) => {
headPathRef.current?.setAttribute('d', frameScene.geometry.headPath)
clipPathRef.current?.setAttribute('d', frameScene.geometry.headPath)
headPathRef.current?.setAttribute('fill', frameScene.colors.body)
leftPathRef.current?.setAttribute('d', frameScene.geometry.leftPath)
leftPathRef.current?.setAttribute('fill', frameScene.colors.eyes)
leftPathRef.current?.setAttribute('opacity', frameScene.geometry.leftVisible ? '1' : '0')
rightPathRef.current?.setAttribute('d', frameScene.geometry.rightPath)
rightPathRef.current?.setAttribute('fill', frameScene.colors.eyes)
rightPathRef.current?.setAttribute('opacity', frameScene.geometry.rightVisible ? '1' : '0')
backPathRefs.current.forEach((element, index) => {
element?.setAttribute('d', frameScene.geometry.backPaths[index] ?? '')
element?.setAttribute('fill', frameScene.colors.body)
})
frontPathRefs.current.forEach((element, index) => {
element?.setAttribute('d', frameScene.geometry.frontPaths[index] ?? '')
element?.setAttribute('fill', frameScene.colors.body)
})
}
const renderPlaybackFrame = (
current: Readonly<CorePlaybackState>,
now: number,
environment: ReturnType<typeof runtimeEnvironment>
) => {
paintedFrameRef.current = sampleAvatarFrame(definition, current, now, environment)
return renderAvatarFrame(definition, current, now, environment)
}
useLayoutEffect(() => {
const current = playbackRef.current ?? playback
const now = performance.now()
const environment = runtimeEnvironment()
paintScene(renderPlaybackFrame(current, now, environment))
})
playbackRef.current = playback
const authoritativePosition = position ?? internalPosition
const effectiveConstraint = constrainTo ?? (mode === 'floating' ? 'viewport' : 'none')
const applyTransform = (point: AvatarPosition) => {
if (wrapperRef.current) {
wrapperRef.current.style.transform = `translate3d(${point.x}px, ${point.y}px, 0)`
}
}
const clampPosition = (point: AvatarPosition): AvatarPosition => {
const element = wrapperRef.current
if (!element || effectiveConstraint === 'none') return point
const width = element.offsetWidth
const height = element.offsetHeight
if (effectiveConstraint === 'viewport') {
return {
x: Math.min(Math.max(point.x, 0), Math.max(window.innerWidth - width, 0)),
y: Math.min(Math.max(point.y, 0), Math.max(window.innerHeight - height, 0)),
}
}
const parent = element.parentElement
if (!parent) return point
return {
x: Math.min(Math.max(point.x, 0), Math.max(parent.clientWidth - width, 0)),
y: Math.min(Math.max(point.y, 0), Math.max(parent.clientHeight - height, 0)),
}
}
const commitPosition = (next: AvatarPosition) => {
const clamped = clampPosition(next)
applyTransform(position ?? clamped)
if (!position) setInternalPosition(clamped)
onPositionCommit?.(clamped)
return clamped
}
useEffect(() => {
setMounted(true)
}, [])
playbackRef.current = playback
}, [playback])
useEffect(() => {
if (previousDefinitionRef.current === definition) return
previousDefinitionRef.current = definition
paintedFrameRef.current = null
defaultPlaybackStarted.current = false
completedAnimation.current = undefined
const key = animation ?? defaultAnimation
if (key) {
const result = resolveAnimation(definition, key)
if (result.ok) {
const next = {
...createAvatarPlaybackState(),
activeExpression: result.value.steps[0]?.expression ?? 'neutral',
}
playbackRef.current = next
setPlayback(next)
return
}
}
const keyExpression = expression ?? defaultExpression ?? 'neutral'
const resolved = resolveExpression(definition, keyExpression)
const next = {
...createAvatarPlaybackState(),
activeExpression: resolved.ok ? keyExpression : 'neutral',
}
const next = createInitialPlayback(
definition,
animation,
expression,
defaultAnimation,
defaultExpression
)
playbackRef.current = next
setPlayback(next)
}, [definition])
}, [animation, defaultAnimation, defaultExpression, definition, expression])
useEffect(() => {
if (animation !== undefined || expression !== undefined) return
const result = defaultAnimation
? resolveAnimation(definition, defaultAnimation)
: defaultExpression
? resolveExpression(definition, defaultExpression)
: null
if (result && !result.ok) {
if (onError) onError(result.error)
else console.error(`[Avatar] ${result.error.message}`)
}
}, [animation, defaultAnimation, defaultExpression, definition, expression, onError])
useEffect(() => {
if (
@ -280,64 +276,21 @@ export function Avatar({
}
defaultPlaybackStarted.current = true
const result = playAvatarAnimation(definition, defaultAnimation, performance.now())
if (result.ok) setPlayback(result.value)
if (result.ok) {
playbackRef.current = result.value
setPlayback(result.value)
}
}, [animation, autoplay, defaultAnimation, definition, expression])
useEffect(() => {
if (mode === 'floating') {
if (floatingPositionInitialized.current) {
applyTransform(position ?? internalPosition)
return
}
floatingPositionInitialized.current = true
const fallbackPixels = sizeInPixels(size)
const measuredWidth = wrapperRef.current?.offsetWidth || fallbackPixels
const measuredHeight = wrapperRef.current?.offsetHeight || fallbackPixels
const point = initialPoint(
initialPosition,
window.innerWidth - measuredWidth,
window.innerHeight - measuredHeight
)
const clamped = clampPosition(position ?? point)
if (!position) setInternalPosition(clamped)
applyTransform(position ?? clamped)
} else {
floatingPositionInitialized.current = false
applyTransform(position ?? internalPosition)
}
}, [mode, position, size, internalPosition])
useEffect(() => {
if (!mounted) return
const reClamp = () => {
const current = position ?? internalPosition
const clamped = clampPosition(current)
if (samePosition(current, clamped)) return
if (position) {
applyTransform(position)
onPositionChange?.(clamped)
return
}
applyTransform(clamped)
setInternalPosition(clamped)
}
const observer = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(reClamp)
if (wrapperRef.current) observer?.observe(wrapperRef.current)
if (effectiveConstraint === 'parent' && wrapperRef.current?.parentElement && observer) {
observer.observe(wrapperRef.current.parentElement)
}
window.addEventListener('resize', reClamp)
return () => {
observer?.disconnect()
window.removeEventListener('resize', reClamp)
}
}, [mounted, position, internalPosition, effectiveConstraint, onPositionChange])
useEffect(() => {
if (expression !== undefined) {
const resolved = resolveExpression(definition, expression)
if (resolved.ok) {
const current = playbackRef.current
const current = playbackRef.current ?? createAvatarPlaybackState()
const now = performance.now()
const from =
paintedFrameRef.current ??
sampleAvatarFrame(definition, current, now, runtimeEnvironment())
const next = {
...createAvatarPlaybackState(),
activeExpression: expression,
@ -346,8 +299,8 @@ export function Avatar({
: {
status: 'playing' as const,
directTransition: {
from: current.activeExpression,
startedAt: performance.now(),
from,
startedAt: now,
durationMs: controlledExpressionTransitionMs,
transition: 'smooth' as const,
},
@ -355,14 +308,23 @@ export function Avatar({
}
playbackRef.current = next
setPlayback(next)
}
} else if (onError) onError(resolved.error)
else console.error(`[Avatar] ${resolved.error.message}`)
return
}
if (animation !== undefined) {
const result = playAvatarAnimation(definition, animation, performance.now())
if (result.ok) setPlayback(result.value)
const current = playbackRef.current ?? createAvatarPlaybackState()
const now = performance.now()
const from =
paintedFrameRef.current ?? sampleAvatarFrame(definition, current, now, runtimeEnvironment())
const result = playAvatarAnimation(definition, animation, now, from)
if (result.ok) {
playbackRef.current = result.value
setPlayback(result.value)
} else if (onError) onError(result.error)
else console.error(`[Avatar] ${result.error.message}`)
}
}, [animation, definition, expression])
}, [animation, definition, expression, onError])
useEffect(() => {
onExpressionChange?.(playback.activeExpression)
@ -373,11 +335,9 @@ export function Avatar({
let frame = 0
const tick = (now: number) => {
const current = playbackRef.current
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
const next = advanceAvatarPlayback(definition, current, now, {
random: Math.random,
reduceMotion,
})
if (!current) return
const environment = runtimeEnvironment()
const next = advanceAvatarPlayback(definition, current, now, environment)
playbackRef.current = next
if (!samePlayback(current, next)) setPlayback(next)
if (
@ -389,27 +349,8 @@ export function Avatar({
completedAnimation.current = current.activeAnimation
onAnimationEnd?.(current.activeAnimation)
}
const frameScene = renderAvatarFrame(definition, next, now, {
random: Math.random,
reduceMotion,
})
headPathRef.current?.setAttribute('d', frameScene.geometry.headPath)
clipPathRef.current?.setAttribute('d', frameScene.geometry.headPath)
headPathRef.current?.setAttribute('fill', frameScene.colors.body)
leftPathRef.current?.setAttribute('d', frameScene.geometry.leftPath)
leftPathRef.current?.setAttribute('fill', frameScene.colors.eyes)
leftPathRef.current?.setAttribute('opacity', frameScene.geometry.leftVisible ? '1' : '0')
rightPathRef.current?.setAttribute('d', frameScene.geometry.rightPath)
rightPathRef.current?.setAttribute('fill', frameScene.colors.eyes)
rightPathRef.current?.setAttribute('opacity', frameScene.geometry.rightVisible ? '1' : '0')
frameScene.geometry.backPaths.forEach((path, index) => {
backPathRefs.current[index]?.setAttribute('d', path)
backPathRefs.current[index]?.setAttribute('fill', frameScene.colors.body)
})
frameScene.geometry.frontPaths.forEach((path, index) => {
frontPathRefs.current[index]?.setAttribute('d', path)
frontPathRefs.current[index]?.setAttribute('fill', frameScene.colors.body)
})
const frameScene = renderPlaybackFrame(next, now, environment)
paintScene(frameScene)
if (next.status === 'playing') frame = requestAnimationFrame(tick)
}
frame = requestAnimationFrame(tick)
@ -429,7 +370,7 @@ export function Avatar({
},
}
}
const current = playbackRef.current
const current = playbackRef.current ?? createAvatarPlaybackState()
if (
current.status === 'paused' &&
current.activeAnimation === key &&
@ -441,7 +382,10 @@ export function Avatar({
setPlayback(resumed)
return { ok: true }
}
const result = playAvatarAnimation(definition, key, performance.now())
const now = performance.now()
const from =
paintedFrameRef.current ?? sampleAvatarFrame(definition, current, now, runtimeEnvironment())
const result = playAvatarAnimation(definition, key, now, from)
if (!result.ok) return { ok: false, error: result.error }
completedAnimation.current = undefined
playbackRef.current = result.value
@ -461,14 +405,32 @@ export function Avatar({
}
const result = resolveExpression(definition, key)
if (!result.ok) return { ok: false, error: result.error }
const next = { ...createAvatarPlaybackState(), activeExpression: key }
const current = playbackRef.current ?? createAvatarPlaybackState()
const now = performance.now()
const from =
paintedFrameRef.current ?? sampleAvatarFrame(definition, current, now, runtimeEnvironment())
const next = {
...createAvatarPlaybackState(),
activeExpression: key,
...(current.activeExpression === key
? {}
: {
status: 'playing' as const,
directTransition: {
from,
startedAt: now,
durationMs: controlledExpressionTransitionMs,
transition: 'smooth' as const,
},
}),
}
playbackRef.current = next
setPlayback(next)
return { ok: true }
},
pause() {
const current = playbackRef.current
if (current.status !== 'playing') return
if (!current || current.status !== 'playing') return
const next = pauseAvatarPlayback(current, performance.now())
playbackRef.current = next
setPlayback(next)
@ -481,7 +443,7 @@ export function Avatar({
}
},
getState() {
const current = playbackRef.current
const current = playbackRef.current ?? createAvatarPlaybackState()
return {
...(current.activeAnimation ? { activeAnimation: current.activeAnimation } : {}),
activeExpression: current.activeExpression,
@ -490,124 +452,30 @@ export function Avatar({
},
}))
const startDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
if (!draggable || event.button !== 0) return
if (
mode === 'floating' &&
!(event.target instanceof Element && event.target.closest('.bs-avatar__drag-grip'))
) {
return
}
event.currentTarget.setPointerCapture(event.pointerId)
drag.current = {
pointerId: event.pointerId,
pointer: { x: event.clientX, y: event.clientY },
origin: authoritativePosition,
current: authoritativePosition,
}
event.currentTarget.classList.add('bs-avatar--dragging')
onDragStart?.()
}
const moveDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
const active = drag.current
if (!active || active.pointerId !== event.pointerId) return
const next = clampPosition({
x: active.origin.x + event.clientX - active.pointer.x,
y: active.origin.y + event.clientY - active.pointer.y,
})
active.current = next
applyTransform(next)
if (previewFrame.current === undefined) {
previewFrame.current = requestAnimationFrame(() => {
previewFrame.current = undefined
if (drag.current) onPositionPreview?.(drag.current.current)
})
}
}
const endDrag = (cancelled: boolean) => {
const active = drag.current
if (!active) return
if (previewFrame.current !== undefined) cancelAnimationFrame(previewFrame.current)
previewFrame.current = undefined
const finalPosition = commitPosition(cancelled ? active.origin : active.current)
wrapperRef.current?.classList.remove('bs-avatar--dragging')
drag.current = undefined
if (
wrapperRef.current?.hasPointerCapture?.(active.pointerId) &&
wrapperRef.current.releasePointerCapture
) {
wrapperRef.current.releasePointerCapture(active.pointerId)
}
onDragEnd?.(finalPosition)
}
const moveByKeyboard = (x: number, y: number) => {
const next = commitPosition({ x: authoritativePosition.x + x, y: authoritativePosition.y + y })
onPositionPreview?.(next)
}
const scene = renderAvatarDefinition(
definition,
mode === 'floating' && !mounted ? 'neutral' : playback.activeExpression
)
const wrapper = (
const scene = renderAvatarDefinition(definition)
return (
<div
ref={wrapperRef}
className={[
'bs-avatar',
`bs-avatar--${mode}`,
draggable ? 'bs-avatar--draggable' : '',
className ?? '',
]
.filter(Boolean)
.join(' ')}
className={['bs-avatar', className ?? ''].filter(Boolean).join(' ')}
style={{
...style,
width: size,
height: size,
zIndex: mode === 'floating' ? zIndex : style?.zIndex,
transform: `translate3d(${authoritativePosition.x}px, ${authoritativePosition.y}px, 0)`,
}}
role={draggable ? 'group' : 'img'}
role="img"
aria-label={ariaLabel}
aria-description={
draggable
? 'Use the drag handle, arrow keys or move controls to reposition the avatar.'
: undefined
}
tabIndex={draggable ? 0 : undefined}
onPointerDown={startDrag}
onPointerMove={moveDrag}
onPointerUp={() => endDrag(false)}
onPointerCancel={() => endDrag(true)}
onLostPointerCapture={() => endDrag(false)}
onKeyDown={event => {
if (!draggable) return
const amount = event.shiftKey ? 1 : 10
if (event.key === 'ArrowLeft') moveByKeyboard(-amount, 0)
else if (event.key === 'ArrowRight') moveByKeyboard(amount, 0)
else if (event.key === 'ArrowUp') moveByKeyboard(0, -amount)
else if (event.key === 'ArrowDown') moveByKeyboard(0, amount)
else if (event.key === 'Escape') endDrag(true)
else return
event.preventDefault()
}}
>
{draggable && <div className="bs-avatar__drag-grip" aria-hidden="true" title="Drag avatar" />}
<svg className="bs-avatar__svg" viewBox="-150 -150 300 300" aria-hidden="true">
<defs>
<clipPath id={clipId}>
<path ref={clipPathRef} d={scene.geometry.headPath} />
</clipPath>
</defs>
{scene.geometry.backPaths.map((path, index) => (
{Array.from({ length: bodyPathSlots }, (_, index) => (
<path
ref={element => {
backPathRefs.current[index] = element
}}
d={path}
d={scene.geometry.backPaths[index] ?? ''}
fill={scene.colors.body}
key={`back-${index}`}
/>
@ -625,53 +493,17 @@ export function Avatar({
opacity={scene.geometry.rightVisible ? 1 : 0}
/>
</g>
{scene.geometry.frontPaths.map((path, index) => (
{Array.from({ length: bodyPathSlots }, (_, index) => (
<path
ref={element => {
frontPathRefs.current[index] = element
}}
d={path}
d={scene.geometry.frontPaths[index] ?? ''}
fill={scene.colors.body}
key={`front-${index}`}
/>
))}
</svg>
{draggable && (
<div className="bs-avatar__move-controls">
{(
[
['Move avatar left', -10, 0, '\u2190'],
['Move avatar right', 10, 0, '\u2192'],
['Move avatar up', 0, -10, '\u2191'],
['Move avatar down', 0, 10, '\u2193'],
] as const
).map(([label, x, y, symbol]) => (
<button
type="button"
aria-label={label}
onPointerDown={event => event.stopPropagation()}
onClick={() => moveByKeyboard(x, y)}
key={label}
>
<span aria-hidden="true">{symbol}</span>
</button>
))}
<button
className="bs-avatar__reset"
type="button"
aria-label="Reset avatar position"
onPointerDown={event => event.stopPropagation()}
onClick={() => commitPosition({ x: 0, y: 0 })}
>
<span aria-hidden="true">\u21ba</span>
</button>
</div>
)}
</div>
)
if (mode === 'floating' && mounted) {
return createPortal(wrapper, portalContainer ?? document.body)
}
return wrapper
}

View File

@ -1,24 +1,12 @@
// @vitest-environment jsdom
import { renderAvatarDefinition, type AvatarDefinition } from '@bible-strong/avatar-core'
import { type AvatarDefinition } from '@bible-strong/avatar-core'
import { act, createRef, Profiler, StrictMode } from 'react'
import { hydrateRoot } from 'react-dom/client'
import { renderToString } from 'react-dom/server'
import { fireEvent, render } from '@testing-library/react'
import { render } from '@testing-library/react'
import { vi } from 'vitest'
import { Avatar, type AvatarController } from '../Avatar'
let resizeCallbacks: ResizeObserverCallback[] = []
class TestResizeObserver implements ResizeObserver {
constructor(callback: ResizeObserverCallback) {
resizeCallbacks.push(callback)
}
observe() {}
unobserve() {}
disconnect() {}
}
import { createAvatar } from '../createAvatar'
const expression = {
head: { x: 0, y: 0, z: 0 },
@ -67,37 +55,42 @@ const definition: AvatarDefinition = {
},
},
animationOrder: ['greet', 'wave-once'],
standardAnimationSet: 1,
}
beforeAll(() => {
HTMLElement.prototype.setPointerCapture = () => undefined
HTMLElement.prototype.hasPointerCapture = () => true
HTMLElement.prototype.releasePointerCapture = () => undefined
window.matchMedia = () =>
({
matches: false,
addEventListener: () => undefined,
removeEventListener: () => undefined,
}) as unknown as MediaQueryList
globalThis.ResizeObserver = TestResizeObserver
})
beforeEach(() => {
resizeCallbacks = []
Object.defineProperties(window, {
innerWidth: { configurable: true, value: 1_024 },
innerHeight: { configurable: true, value: 768 },
})
})
describe('@bible-strong/avatar-react', () => {
it('renders semantic SVG geometry in embedded mode', () => {
it('creates a validated concrete component from a definition', () => {
const ConcreteAvatar = createAvatar(definition)
const view = render(<ConcreteAvatar animation="greet" ariaLabel="Concrete avatar" />)
expect(view.getByRole('img', { name: 'Concrete avatar' })).toBeTruthy()
})
it('rejects invalid definitions before creating a component', () => {
expect(() => createAvatar({})).toThrow('Invalid avatar definition')
})
it('renders semantic SVG geometry', () => {
const view = render(<Avatar definition={definition} ariaLabel="Assistant avatar" />)
const avatar = view.getByRole('img', { name: 'Assistant avatar' })
expect(avatar.classList.contains('bs-avatar--embedded')).toBe(true)
expect(avatar.classList.contains('bs-avatar')).toBe(true)
expect(avatar.className).toBe('bs-avatar')
expect(avatar.querySelector('svg path')).not.toBeNull()
expect(document.body.querySelector('.bs-avatar--floating')).toBeNull()
})
it('keeps stable SVG layer slots for nodes moving in front of or behind the head', () => {
const view = render(<Avatar definition={definition} ariaLabel="Layered avatar" />)
const svg = view.getByRole('img', { name: 'Layered avatar' }).querySelector('svg')
expect(svg?.querySelectorAll(':scope > path')).toHaveLength(37)
})
it('exposes semantic imperative controls without Studio identifiers', () => {
@ -111,7 +104,7 @@ describe('@bible-strong/avatar-react', () => {
expect(result).toEqual({ ok: true })
expect(controller.current?.getState()).toMatchObject({
activeExpression: 'smile',
status: 'stopped',
status: 'playing',
})
expect(controller.current?.play('missing')).toMatchObject({
ok: false,
@ -149,6 +142,49 @@ describe('@bible-strong/avatar-react', () => {
})
})
it('retargets from the currently painted SVG frame without a target-frame flash', () => {
let nextFrame = 0
const frames = new Map<number, FrameRequestCallback>()
const request = vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
frames.set(++nextFrame, callback)
return nextFrame
})
const cancel = vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(id => {
frames.delete(id)
})
const clock = vi.spyOn(performance, 'now').mockReturnValue(1_000)
const controller = createRef<AvatarController>()
const view = render(<Avatar definition={definition} ref={controller} />)
const eye = view.container.querySelector<SVGPathElement>('.bs-avatar__svg g path')
const neutralPath = eye?.getAttribute('d')
act(() => controller.current?.setExpression('smile'))
expect(eye?.getAttribute('d')).toBe(neutralPath)
act(() => {
const callback = [...frames.values()].at(-1)
frames.clear()
callback?.(1_200)
})
const inFlightPath = eye?.getAttribute('d')
expect(inFlightPath).not.toBe(neutralPath)
clock.mockReturnValue(1_200)
act(() => controller.current?.setExpression('neutral'))
expect(eye?.getAttribute('d')).toBe(inFlightPath)
act(() => {
const callback = [...frames.values()].at(-1)
frames.clear()
callback?.(1_200)
})
expect(eye?.getAttribute('d')).toBe(inFlightPath)
clock.mockRestore()
request.mockRestore()
cancel.mockRestore()
})
it('rejects simultaneous controlled animation and expression props', () => {
const errors = vi.spyOn(console, 'error').mockImplementation(() => undefined)
expect(() =>
@ -157,6 +193,51 @@ describe('@bible-strong/avatar-react', () => {
errors.mockRestore()
})
it('rejects simultaneous uncontrolled animation and expression defaults', () => {
const errors = vi.spyOn(console, 'error').mockImplementation(() => undefined)
expect(() =>
render(
<Avatar definition={definition} defaultAnimation="greet" defaultExpression="neutral" />
)
).toThrow('Avatar accepts either defaultAnimation or defaultExpression, not both.')
errors.mockRestore()
})
it('reports unknown controlled and default targets through onError', () => {
const onError = vi.fn()
const controlled = render(
<Avatar definition={definition} animation="missing-animation" onError={onError} />
)
expect(onError).toHaveBeenCalledWith(
expect.objectContaining({ code: 'unknown_animation', key: 'missing-animation' })
)
onError.mockClear()
controlled.rerender(
<Avatar definition={definition} defaultExpression="missing-expression" onError={onError} />
)
expect(onError).toHaveBeenCalledWith(
expect.objectContaining({ code: 'unknown_expression', key: 'missing-expression' })
)
})
it('keeps a controlled expression above an uncontrolled animation default', () => {
const controller = createRef<AvatarController>()
render(
<Avatar
definition={definition}
expression="neutral"
defaultAnimation="greet"
ref={controller}
/>
)
expect(controller.current?.getState()).toEqual({
activeExpression: 'neutral',
status: 'stopped',
})
})
it('honors uncontrolled defaults without autoplay when requested', () => {
const controller = createRef<AvatarController>()
render(
@ -168,333 +249,6 @@ describe('@bible-strong/avatar-react', () => {
})
})
it('portals floating mode to the document body after mount', () => {
const host = document.createElement('section')
document.body.append(host)
const view = render(<Avatar definition={definition} mode="floating" />, { container: host })
expect(document.body.querySelector('.bs-avatar--floating')).not.toBeNull()
view.unmount()
host.remove()
})
it('normalizes an uncontrolled floating initial position only once', () => {
const view = render(
<Avatar definition={definition} mode="floating" initialPosition={{ x: 10, y: 12 }} />
)
expect(view.getByRole('img').style.transform).toBe('translate3d(10px, 12px, 0)')
view.rerender(
<Avatar definition={definition} mode="floating" initialPosition={{ x: 80, y: 90 }} />
)
expect(view.getByRole('img').style.transform).toBe('translate3d(10px, 12px, 0)')
})
it('server-renders a neutral floating placeholder before portal handoff', () => {
const markup = renderToString(
<Avatar definition={definition} mode="floating" defaultExpression="smile" size={180} />
)
const neutral = renderAvatarDefinition(definition, 'neutral')
expect(markup).toContain('bs-avatar--floating')
expect(markup).toContain('width:180px;height:180px')
expect(markup).toContain(`d="${neutral.geometry.leftPath}"`)
})
it('hydrates the floating placeholder before moving it to a portal without warnings', async () => {
const host = document.createElement('section')
const markup = renderToString(<Avatar definition={definition} mode="floating" size={180} />)
host.innerHTML = markup
document.body.append(host)
const errors = vi.spyOn(console, 'error').mockImplementation(() => undefined)
let root: ReturnType<typeof hydrateRoot> | undefined
await act(async () => {
root = hydrateRoot(host, <Avatar definition={definition} mode="floating" size={180} />)
await Promise.resolve()
})
expect(errors).not.toHaveBeenCalled()
expect(host.querySelector('.bs-avatar--floating')).toBeNull()
expect(document.body.querySelector('.bs-avatar--floating')).not.toBeNull()
act(() => root?.unmount())
errors.mockRestore()
host.remove()
})
it('moves by pointer without React state updates on every pointer move and commits once', () => {
const previews: { x: number; y: number }[] = []
const commits: { x: number; y: number }[] = []
const view = render(
<Avatar
definition={definition}
draggable
constrainTo="none"
onPositionPreview={point => previews.push(point)}
onPositionCommit={point => commits.push(point)}
/>
)
const avatar = view.getByRole('group')
fireEvent.pointerDown(avatar, { pointerId: 1, button: 0, clientX: 10, clientY: 10 })
fireEvent.pointerMove(avatar, { pointerId: 1, clientX: 35, clientY: 45 })
expect(avatar.style.transform).toBe('translate3d(25px, 35px, 0)')
fireEvent.pointerUp(avatar, { pointerId: 1, clientX: 35, clientY: 45 })
expect(commits).toEqual([{ x: 25, y: 35 }])
expect(previews.length).toBeLessThanOrEqual(1)
})
it('uses the visible grip as the floating avatar drag target', () => {
const view = render(
<Avatar
definition={definition}
mode="floating"
draggable
constrainTo="none"
initialPosition={{ x: 0, y: 0 }}
/>
)
const avatar = view.getByRole('group')
const grip = avatar.querySelector('.bs-avatar__drag-grip')
expect(grip).not.toBeNull()
fireEvent.pointerDown(avatar, { pointerId: 8, button: 0, clientX: 0, clientY: 0 })
fireEvent.pointerMove(avatar, { pointerId: 8, clientX: 20, clientY: 20 })
expect(avatar.style.transform).toBe('translate3d(0px, 0px, 0)')
fireEvent.pointerDown(grip!, { pointerId: 9, button: 0, clientX: 0, clientY: 0 })
fireEvent.pointerMove(avatar, { pointerId: 9, clientX: 20, clientY: 20 })
expect(avatar.style.transform).toBe('translate3d(20px, 20px, 0)')
})
it('limits drag previews to one callback per animation frame with the latest position', () => {
let nextFrame = 0
const frames = new Map<number, FrameRequestCallback>()
const request = vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
frames.set(++nextFrame, callback)
return nextFrame
})
const cancel = vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(id => {
frames.delete(id)
})
const previews: { x: number; y: number }[] = []
const view = render(
<Avatar
definition={definition}
draggable
constrainTo="none"
onPositionPreview={point => previews.push(point)}
/>
)
const avatar = view.getByRole('group')
fireEvent.pointerDown(avatar, { pointerId: 6, button: 0, clientX: 0, clientY: 0 })
fireEvent.pointerMove(avatar, { pointerId: 6, clientX: 10, clientY: 12 })
fireEvent.pointerMove(avatar, { pointerId: 6, clientX: 20, clientY: 24 })
expect(previews).toEqual([])
act(() => {
const callbacks = [...frames.values()]
frames.clear()
callbacks.forEach(callback => callback(16))
})
expect(previews).toEqual([{ x: 20, y: 24 }])
fireEvent.pointerUp(avatar, { pointerId: 6 })
request.mockRestore()
cancel.mockRestore()
})
it('does not render React once per pointer movement', () => {
let renders = 0
const view = render(
<Profiler id="avatar" onRender={() => renders++}>
<Avatar definition={definition} draggable constrainTo="none" />
</Profiler>
)
const avatar = view.getByRole('group')
const beforeMoves = renders
fireEvent.pointerDown(avatar, { pointerId: 2, button: 0, clientX: 0, clientY: 0 })
for (let index = 1; index <= 20; index++) {
fireEvent.pointerMove(avatar, { pointerId: 2, clientX: index, clientY: index })
}
expect(renders).toBe(beforeMoves)
})
it('restores the drag origin on pointer cancel', () => {
const commits: { x: number; y: number }[] = []
const view = render(
<Avatar
definition={definition}
draggable
constrainTo="none"
initialPosition={{ x: 5, y: 7 }}
onPositionCommit={point => commits.push(point)}
/>
)
const avatar = view.getByRole('group')
fireEvent.pointerDown(avatar, { pointerId: 3, button: 0, clientX: 0, clientY: 0 })
fireEvent.pointerMove(avatar, { pointerId: 3, clientX: 40, clientY: 50 })
fireEvent.pointerCancel(avatar, { pointerId: 3 })
expect(avatar.style.transform).toBe('translate3d(0px, 0px, 0)')
expect(commits.at(-1)).toEqual({ x: 0, y: 0 })
})
it('restores the drag origin when Escape cancels an active drag', () => {
const commits: { x: number; y: number }[] = []
const view = render(
<Avatar
definition={definition}
draggable
constrainTo="none"
onPositionCommit={point => commits.push(point)}
/>
)
const avatar = view.getByRole('group')
fireEvent.pointerDown(avatar, { pointerId: 7, button: 0, clientX: 0, clientY: 0 })
fireEvent.pointerMove(avatar, { pointerId: 7, clientX: 30, clientY: 40 })
fireEvent.keyDown(avatar, { key: 'Escape' })
expect(avatar.style.transform).toBe('translate3d(0px, 0px, 0)')
expect(commits).toEqual([{ x: 0, y: 0 }])
})
it('clamps pointer movement to an embedded parent', () => {
const view = render(<Avatar definition={definition} draggable constrainTo="parent" size={40} />)
const avatar = view.getByRole('group')
Object.defineProperties(avatar, {
offsetWidth: { configurable: true, value: 40 },
offsetHeight: { configurable: true, value: 40 },
})
Object.defineProperties(avatar.parentElement!, {
clientWidth: { configurable: true, value: 100 },
clientHeight: { configurable: true, value: 100 },
})
fireEvent.pointerDown(avatar, { pointerId: 4, button: 0, clientX: 0, clientY: 0 })
fireEvent.pointerMove(avatar, { pointerId: 4, clientX: 200, clientY: 200 })
fireEvent.pointerUp(avatar, { pointerId: 4 })
expect(avatar.style.transform).toBe('translate3d(60px, 60px, 0)')
})
it('keeps controlled position authoritative while reporting a constrained commit', () => {
const commits: { x: number; y: number }[] = []
const view = render(
<Avatar
definition={definition}
draggable
constrainTo="parent"
size={40}
position={{ x: 10, y: 12 }}
onPositionCommit={point => commits.push(point)}
/>
)
const avatar = view.getByRole('group')
Object.defineProperties(avatar, {
offsetWidth: { configurable: true, value: 40 },
offsetHeight: { configurable: true, value: 40 },
})
Object.defineProperties(avatar.parentElement!, {
clientWidth: { configurable: true, value: 100 },
clientHeight: { configurable: true, value: 100 },
})
fireEvent.pointerDown(avatar, { pointerId: 5, button: 0, clientX: 0, clientY: 0 })
fireEvent.pointerMove(avatar, { pointerId: 5, clientX: 200, clientY: 200 })
fireEvent.pointerUp(avatar, { pointerId: 5 })
expect(commits).toEqual([{ x: 60, y: 60 }])
expect(avatar.style.transform).toBe('translate3d(10px, 12px, 0)')
})
it('re-clamps on resize without emitting a movement commit', () => {
const changes: { x: number; y: number }[] = []
const commits: { x: number; y: number }[] = []
const view = render(
<Avatar
definition={definition}
mode="floating"
draggable
size={40}
position={{ x: 90, y: 90 }}
onPositionChange={point => changes.push(point)}
onPositionCommit={point => commits.push(point)}
/>
)
const avatar = view.getByRole('group')
Object.defineProperties(avatar, {
offsetWidth: { configurable: true, value: 40 },
offsetHeight: { configurable: true, value: 40 },
})
Object.defineProperties(window, {
innerWidth: { configurable: true, value: 100 },
innerHeight: { configurable: true, value: 100 },
})
act(() => resizeCallbacks.forEach(callback => callback([], {} as ResizeObserver)))
expect(changes.at(-1)).toEqual({ x: 60, y: 60 })
expect(commits).toEqual([])
expect(avatar.style.transform).toBe('translate3d(90px, 90px, 0)')
})
it('re-clamps an uncontrolled floating position without emitting a movement commit', () => {
const commits: { x: number; y: number }[] = []
const view = render(
<Avatar
definition={definition}
mode="floating"
draggable
size={40}
initialPosition={{ x: 90, y: 90 }}
onPositionCommit={point => commits.push(point)}
/>
)
const avatar = view.getByRole('group')
Object.defineProperties(avatar, {
offsetWidth: { configurable: true, value: 40 },
offsetHeight: { configurable: true, value: 40 },
})
Object.defineProperties(window, {
innerWidth: { configurable: true, value: 100 },
innerHeight: { configurable: true, value: 100 },
})
act(() => resizeCallbacks.forEach(callback => callback([], {} as ResizeObserver)))
expect(avatar.style.transform).toBe('translate3d(60px, 60px, 0)')
expect(commits).toEqual([])
})
it('re-clamps to the viewport on window resize without ResizeObserver support', () => {
const originalObserver = globalThis.ResizeObserver
// @ts-expect-error This test covers browsers without ResizeObserver.
globalThis.ResizeObserver = undefined
const view = render(
<Avatar
definition={definition}
mode="floating"
draggable
size={40}
initialPosition={{ x: 90, y: 90 }}
/>
)
const avatar = view.getByRole('group')
Object.defineProperties(avatar, {
offsetWidth: { configurable: true, value: 40 },
offsetHeight: { configurable: true, value: 40 },
})
Object.defineProperties(window, {
innerWidth: { configurable: true, value: 100 },
innerHeight: { configurable: true, value: 100 },
})
act(() => window.dispatchEvent(new Event('resize')))
expect(avatar.style.transform).toBe('translate3d(60px, 60px, 0)')
globalThis.ResizeObserver = originalObserver
})
it('provides keyboard movement and an accessible reset control', () => {
const commits: { x: number; y: number }[] = []
const view = render(
<Avatar
definition={definition}
draggable
constrainTo="none"
onPositionCommit={point => commits.push(point)}
/>
)
const avatar = view.getByRole('group')
fireEvent.keyDown(avatar, { key: 'ArrowRight' })
expect(commits.at(-1)).toEqual({ x: 10, y: 0 })
act(() => view.getByRole('button', { name: 'Move avatar down' }).click())
expect(commits.at(-1)).toEqual({ x: 10, y: 10 })
act(() => view.getByRole('button', { name: 'Reset avatar position' }).click())
expect(commits.at(-1)).toEqual({ x: 0, y: 0 })
})
it('resumes the current paused animation instead of restarting it', () => {
const controller = createRef<AvatarController>()
const clock = vi.spyOn(performance, 'now')

View File

@ -0,0 +1,74 @@
import {
validateAvatarDefinition,
type AnimationKey,
type AvatarDefinition,
type ExpressionKey,
} from '@bible-strong/avatar-core'
import type { ReactElement } from 'react'
import {
Avatar,
markAvatarDefinitionValidated,
type AvatarController,
type AvatarProps,
} from './Avatar'
type AvatarDefinitionInput = {
expressions: object
animations: object
}
type StringKey<T> = Extract<keyof T, string>
/** Props for a component created from one concrete avatar definition. */
export type CreatedAvatarProps<Definition extends AvatarDefinitionInput> = Omit<
AvatarProps,
'definition' | 'animation' | 'expression' | 'defaultAnimation' | 'defaultExpression'
> & {
animation?: StringKey<Definition['animations']>
defaultAnimation?: StringKey<Definition['animations']>
expression?: StringKey<Definition['expressions']>
defaultExpression?: StringKey<Definition['expressions']>
}
/** A concrete avatar component with animation and expression keys from its definition. */
export type CreatedAvatarComponent<Definition extends AvatarDefinitionInput> = (
props: CreatedAvatarProps<Definition>
) => ReactElement
const invalidDefinitionError = (errors: readonly { path: string; message: string }[]) => {
const first = errors[0]
return new Error(
first
? `Invalid avatar definition${first.path ? ` at ${first.path}` : ''}: ${first.message}`
: 'Invalid avatar definition.'
)
}
const buildAvatarComponent = (definition: Readonly<AvatarDefinition>) => {
const ConcreteAvatar = (props: CreatedAvatarProps<AvatarDefinition>): ReactElement => (
<Avatar {...props} definition={definition} />
)
ConcreteAvatar.displayName = 'CreatedAvatar'
return ConcreteAvatar
}
/**
* Validate a JSON-compatible definition once and create a concrete React component from it.
*
* When the input is a statically typed definition, the returned component narrows its animation
* and expression props to that definition's semantic keys. Values loaded at runtime are still
* validated, but necessarily expose the broad string-key API at compile time.
*/
export function createAvatar<const Definition extends AvatarDefinitionInput>(
definition: Definition
): CreatedAvatarComponent<Definition>
export function createAvatar(definition: unknown): CreatedAvatarComponent<AvatarDefinition>
export function createAvatar(definition: unknown): CreatedAvatarComponent<AvatarDefinition> {
const result = validateAvatarDefinition(definition)
if (!result.ok) throw invalidDefinitionError(result.errors)
markAvatarDefinitionValidated(result.value)
return buildAvatarComponent(result.value)
}
export type { AnimationKey, AvatarController, ExpressionKey }

View File

@ -1,10 +1,10 @@
export { Avatar } from './Avatar'
export { createAvatar } from './createAvatar'
export type {
AvatarCommandResult,
AvatarController,
AvatarPlaybackState,
AvatarPosition,
AvatarProps,
AvatarRuntimeError,
FloatingInitialPosition,
} from './Avatar'
export type { CreatedAvatarComponent, CreatedAvatarProps } from './createAvatar'

View File

@ -9,73 +9,6 @@
-webkit-user-select: none;
}
.bs-avatar--embedded {
position: relative;
}
.bs-avatar--floating {
position: fixed;
inset: 0 auto auto 0;
}
.bs-avatar--draggable {
cursor: grab;
touch-action: none;
}
.bs-avatar--dragging {
cursor: grabbing;
}
.bs-avatar--floating.bs-avatar--draggable {
cursor: default;
}
.bs-avatar__drag-grip {
position: absolute;
z-index: 1;
top: 4px;
left: 50%;
width: 44px;
height: 28px;
border: 1px solid rgb(15 23 42 / 18%);
border-radius: 999px;
background: rgb(255 255 255 / 92%);
box-shadow: 0 2px 6px rgb(15 23 42 / 14%);
cursor: grab;
opacity: 0;
pointer-events: none;
touch-action: none;
transform: translateX(-50%) translateY(-4px);
transition:
opacity 160ms ease,
transform 160ms ease;
}
.bs-avatar__drag-grip::before {
position: absolute;
top: 8px;
left: 13px;
width: 18px;
height: 12px;
content: '';
background-image: radial-gradient(circle, #475569 1.5px, transparent 1.7px);
background-size: 6px 6px;
}
.bs-avatar--dragging .bs-avatar__drag-grip {
cursor: grabbing;
}
.bs-avatar:hover .bs-avatar__drag-grip,
.bs-avatar:focus-visible .bs-avatar__drag-grip,
.bs-avatar:focus-within .bs-avatar__drag-grip,
.bs-avatar--dragging .bs-avatar__drag-grip {
opacity: 1;
pointer-events: auto;
transform: translateX(-50%);
}
.bs-avatar:focus-visible {
outline: 3px solid currentColor;
outline-offset: 3px;
@ -89,63 +22,6 @@
pointer-events: none;
}
.bs-avatar__move-controls {
position: absolute;
inset: 0 0 auto auto;
display: grid;
grid-template-columns: repeat(3, 44px);
opacity: 0;
}
.bs-avatar__move-controls button {
width: 44px;
height: 44px;
border: 0;
border-radius: 999px;
background: transparent;
color: currentColor;
cursor: pointer;
}
.bs-avatar__move-controls button:nth-child(1) {
grid-column: 1;
grid-row: 2;
}
.bs-avatar__move-controls button:nth-child(2) {
grid-column: 3;
grid-row: 2;
}
.bs-avatar__move-controls button:nth-child(3) {
grid-column: 2;
grid-row: 1;
}
.bs-avatar__move-controls button:nth-child(4) {
grid-column: 2;
grid-row: 3;
}
.bs-avatar__reset {
grid-column: 2;
grid-row: 2;
}
.bs-avatar:hover .bs-avatar__move-controls,
.bs-avatar:focus-within .bs-avatar__move-controls,
.bs-avatar__move-controls:focus-within {
opacity: 0.75;
}
@media (hover: none) {
.bs-avatar__drag-grip {
opacity: 1;
pointer-events: auto;
transform: translateX(-50%);
}
}
@media (prefers-reduced-motion: reduce) {
.bs-avatar,
.bs-avatar * {

View File

@ -0,0 +1,28 @@
# @bible-strong/avatar-web
DOM renderer for Bible Strong procedural avatars. It uses `@bible-strong/avatar-core` for schema
validation, playback and rendering, without requiring React.
```sh
pnpm add @bible-strong/avatar-web
```
```js
import { createAvatar } from '@bible-strong/avatar-web'
import definition from './cloudee.avatar.json'
const avatar = createAvatar('#avatar', {
definition,
defaultAnimation: 'idle',
})
avatar.play('happy')
avatar.pause()
avatar.stop()
```
For a browser project without a bundler, load an ESM build through an import map or CDN and fetch
the definition JSON before calling `createAvatar`.
This package is private while copyright ownership, Apache-2.0 relicensing and repository metadata
are confirmed. Local tarballs are for verification only and must not be published.

View File

@ -0,0 +1,34 @@
{
"name": "@bible-strong/avatar-web",
"version": "0.1.0",
"description": "Framework-independent DOM renderer for Bible Strong procedural avatars.",
"license": "AGPL-3.0-only",
"private": true,
"type": "module",
"sideEffects": false,
"files": [
"dist",
"README.md",
"LICENSE"
],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "vite build --config vite.config.ts && tsc -p tsconfig.build.json",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@bible-strong/avatar-core": "workspace:^"
},
"devDependencies": {
"typescript": "~6.0.3",
"vite": "^8.0.13"
},
"engines": {
"node": ">=22.12.0"
}
}

View File

@ -0,0 +1,49 @@
// @vitest-environment jsdom
import definitionJson from '../../../../examples/react-vite-consumer/src/strobi.avatar.json'
import { createAvatar } from '../index'
describe('@bible-strong/avatar-web', () => {
beforeEach(() => {
document.body.innerHTML = '<div id="avatar"></div>'
vi.stubGlobal('requestAnimationFrame', () => 1)
vi.stubGlobal('cancelAnimationFrame', vi.fn())
vi.stubGlobal('matchMedia', () => ({ matches: false }))
})
afterEach(() => vi.unstubAllGlobals())
it('mounts the shared avatar definition without React', () => {
const avatar = createAvatar('#avatar', {
definition: definitionJson,
defaultExpression: 'neutral',
size: 180,
})
expect(document.querySelector('#avatar svg')).not.toBeNull()
expect(document.querySelectorAll('#avatar svg > path')).toHaveLength(37)
expect(document.querySelector('[role="img"]')?.getAttribute('aria-label')).toBe(
'Procedural avatar'
)
expect(avatar.getState()).toMatchObject({
activeExpression: 'neutral',
status: 'stopped',
})
avatar.destroy()
expect(document.querySelector('#avatar svg')).toBeNull()
})
it('returns typed errors for unknown targets', () => {
const avatar = createAvatar('#avatar', { definition: definitionJson })
expect(avatar.play('missing')).toEqual({
ok: false,
error: expect.objectContaining({ code: 'unknown_animation', key: 'missing' }),
})
expect(avatar.setExpression('missing')).toEqual({
ok: false,
error: expect.objectContaining({ code: 'unknown_expression', key: 'missing' }),
})
})
})

View File

@ -0,0 +1,304 @@
import {
advanceAvatarPlayback,
createAvatarPlaybackState,
MAX_BODY_NODES,
pauseAvatarPlayback,
playAvatarAnimation,
renderAvatarDefinition,
renderAvatarFrame,
resolveAnimation,
resolveExpression,
resumeAvatarPlayback,
sampleAvatarFrame,
validateAvatarDefinition,
type AnimationKey,
type AvatarDefinition,
type AvatarPlaybackState as CorePlaybackState,
type AvatarRuntimeError,
type ExpressionKey,
} from '@bible-strong/avatar-core'
export type AvatarCommandResult = { ok: true } | { ok: false; error: AvatarRuntimeError }
export type AvatarPlaybackState = Pick<
CorePlaybackState,
'activeAnimation' | 'activeExpression' | 'status'
>
export type AvatarController = {
play(animation: AnimationKey): AvatarCommandResult
setExpression(expression: ExpressionKey): AvatarCommandResult
pause(): void
stop(): void
getState(): AvatarPlaybackState
destroy(): void
}
export type CreateAvatarOptions = {
definition: unknown
defaultAnimation?: AnimationKey
defaultExpression?: ExpressionKey
autoplay?: boolean
size?: number | string
ariaLabel?: string
className?: string
onError?: (error: AvatarRuntimeError) => void
onAnimationEnd?: (animation: AnimationKey) => void
onExpressionChange?: (expression: ExpressionKey) => void
}
const svgNamespace = 'http://www.w3.org/2000/svg'
const controlledExpressionTransitionMs = 420
const bodyPathSlots = MAX_BODY_NODES + 2
let avatarInstanceId = 0
const dimension = (size: number | string) => (typeof size === 'number' ? `${size}px` : size)
const invalidDefinitionError = (errors: readonly { path: string; message: string }[]) => {
const first = errors[0]
return new Error(
first
? `Invalid avatar definition${first.path ? ` at ${first.path}` : ''}: ${first.message}`
: 'Invalid avatar definition.'
)
}
const resolveTarget = (target: string | HTMLElement) => {
const element = typeof target === 'string' ? document.querySelector<HTMLElement>(target) : target
if (!element) throw new Error(`Avatar target '${target}' was not found.`)
return element
}
const createSvgElement = <Name extends keyof SVGElementTagNameMap>(name: Name) =>
document.createElementNS(svgNamespace, name)
const playbackSnapshot = (state: CorePlaybackState): AvatarPlaybackState => ({
...(state.activeAnimation ? { activeAnimation: state.activeAnimation } : {}),
activeExpression: state.activeExpression,
status: state.status,
})
const runtimeEnvironment = () => ({
random: Math.random,
reduceMotion: window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false,
})
export function createAvatar(
target: string | HTMLElement,
{
definition: input,
defaultAnimation,
defaultExpression,
autoplay = true,
size = 240,
ariaLabel = 'Procedural avatar',
className,
onError,
onAnimationEnd,
onExpressionChange,
}: CreateAvatarOptions
): AvatarController {
if (defaultAnimation !== undefined && defaultExpression !== undefined) {
throw new Error('Choose either defaultAnimation or defaultExpression, not both.')
}
const validated = validateAvatarDefinition(input)
if (!validated.ok) throw invalidDefinitionError(validated.errors)
const definition: Readonly<AvatarDefinition> = validated.value
const mount = resolveTarget(target)
const host = document.createElement('span')
host.className = ['bs-avatar', className ?? ''].filter(Boolean).join(' ')
host.style.display = 'inline-block'
host.style.width = dimension(size)
host.style.height = dimension(size)
host.setAttribute('role', 'img')
host.setAttribute('aria-label', ariaLabel)
const svg = createSvgElement('svg')
svg.setAttribute('viewBox', '-150 -150 300 300')
svg.setAttribute('aria-hidden', 'true')
svg.style.display = 'block'
svg.style.width = '100%'
svg.style.height = '100%'
const defs = createSvgElement('defs')
const clipPath = createSvgElement('clipPath')
const clipId = `bs-avatar-web-${++avatarInstanceId}`
clipPath.id = clipId
const clipHeadPath = createSvgElement('path')
clipPath.append(clipHeadPath)
defs.append(clipPath)
svg.append(defs)
const initialScene = renderAvatarDefinition(definition)
const backPaths = Array.from({ length: bodyPathSlots }, () => createSvgElement('path'))
const headPath = createSvgElement('path')
const eyeGroup = createSvgElement('g')
eyeGroup.setAttribute('clip-path', `url(#${clipId})`)
const leftPath = createSvgElement('path')
const rightPath = createSvgElement('path')
eyeGroup.append(leftPath, rightPath)
const frontPaths = Array.from({ length: bodyPathSlots }, () => createSvgElement('path'))
svg.append(...backPaths, headPath, eyeGroup, ...frontPaths)
host.append(svg)
mount.append(host)
const reportError = (error: AvatarRuntimeError) => {
if (onError) onError(error)
else console.error(`[Avatar] ${error.message}`)
}
const paint = (scene: ReturnType<typeof renderAvatarDefinition>) => {
clipHeadPath.setAttribute('d', scene.geometry.headPath)
headPath.setAttribute('d', scene.geometry.headPath)
headPath.setAttribute('fill', scene.colors.body)
leftPath.setAttribute('d', scene.geometry.leftPath)
leftPath.setAttribute('fill', scene.colors.eyes)
leftPath.setAttribute('opacity', scene.geometry.leftVisible ? '1' : '0')
rightPath.setAttribute('d', scene.geometry.rightPath)
rightPath.setAttribute('fill', scene.colors.eyes)
rightPath.setAttribute('opacity', scene.geometry.rightVisible ? '1' : '0')
backPaths.forEach((element, index) => {
element.setAttribute('d', scene.geometry.backPaths[index] ?? '')
element.setAttribute('fill', scene.colors.body)
})
frontPaths.forEach((element, index) => {
element.setAttribute('d', scene.geometry.frontPaths[index] ?? '')
element.setAttribute('fill', scene.colors.body)
})
}
let playback = createAvatarPlaybackState()
let frameRequest: number | null = null
let destroyed = false
let completedAnimation: AnimationKey | undefined
let lastExpression: ExpressionKey | undefined
let paintedFrame: ReturnType<typeof sampleAvatarFrame> | undefined
const notifyExpression = () => {
if (lastExpression === playback.activeExpression) return
lastExpression = playback.activeExpression
onExpressionChange?.(playback.activeExpression)
}
const renderCurrent = (now: number) => {
const environment = runtimeEnvironment()
paintedFrame = sampleAvatarFrame(definition, playback, now, environment)
paint(renderAvatarFrame(definition, playback, now, environment))
notifyExpression()
}
const tick = (now: number) => {
frameRequest = null
if (destroyed) return
const currentAnimation = playback.activeAnimation
const wasPlaying = playback.status === 'playing'
playback = advanceAvatarPlayback(definition, playback, now, {
random: Math.random,
reduceMotion: window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false,
})
renderCurrent(now)
if (wasPlaying && playback.status === 'stopped' && currentAnimation) {
if (completedAnimation !== currentAnimation) onAnimationEnd?.(currentAnimation)
completedAnimation = currentAnimation
}
if (playback.status === 'playing') frameRequest = requestAnimationFrame(tick)
}
const schedule = () => {
if (frameRequest === null && !destroyed) frameRequest = requestAnimationFrame(tick)
}
const controller: AvatarController = {
play(animation) {
if (
playback.status === 'paused' &&
playback.activeAnimation === animation &&
playback.pausedAt !== undefined
) {
playback = resumeAvatarPlayback(playback, performance.now())
schedule()
return { ok: true }
}
const now = performance.now()
const from =
paintedFrame ?? sampleAvatarFrame(definition, playback, now, runtimeEnvironment())
const result = playAvatarAnimation(definition, animation, now, from)
if (!result.ok) return { ok: false, error: result.error }
completedAnimation = undefined
playback = result.value
renderCurrent(performance.now())
schedule()
return { ok: true }
},
setExpression(expression) {
const result = resolveExpression(definition, expression)
if (!result.ok) return { ok: false, error: result.error }
const now = performance.now()
const from =
paintedFrame ?? sampleAvatarFrame(definition, playback, now, runtimeEnvironment())
playback = {
...createAvatarPlaybackState(),
activeExpression: expression,
...(playback.activeExpression === expression
? {}
: {
status: 'playing' as const,
directTransition: {
from,
startedAt: now,
durationMs: controlledExpressionTransitionMs,
transition: 'smooth' as const,
},
}),
}
renderCurrent(performance.now())
if (playback.status === 'playing') schedule()
return { ok: true }
},
pause() {
if (playback.status !== 'playing') return
playback = pauseAvatarPlayback(playback, performance.now())
if (frameRequest !== null) cancelAnimationFrame(frameRequest)
frameRequest = null
},
stop() {
playback = createAvatarPlaybackState()
if (frameRequest !== null) cancelAnimationFrame(frameRequest)
frameRequest = null
paint(renderAvatarDefinition(definition))
notifyExpression()
},
getState() {
return playbackSnapshot(playback)
},
destroy() {
destroyed = true
if (frameRequest !== null) cancelAnimationFrame(frameRequest)
frameRequest = null
host.remove()
},
}
if (defaultAnimation !== undefined) {
const resolved = resolveAnimation(definition, defaultAnimation)
if (!resolved.ok) reportError(resolved.error)
else if (autoplay) controller.play(defaultAnimation)
else {
playback = {
...createAvatarPlaybackState(),
activeExpression: resolved.value.steps[0]?.expression ?? 'neutral',
}
renderCurrent(performance.now())
}
} else if (defaultExpression !== undefined) {
const resolved = resolveExpression(definition, defaultExpression)
if (!resolved.ok) reportError(resolved.error)
else {
playback = { ...createAvatarPlaybackState(), activeExpression: defaultExpression }
renderCurrent(performance.now())
}
} else {
paint(initialScene)
paintedFrame = sampleAvatarFrame(definition, playback, performance.now(), runtimeEnvironment())
notifyExpression()
}
return controller
}
export type { AnimationKey, AvatarDefinition, AvatarRuntimeError, ExpressionKey }

View File

@ -0,0 +1,12 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"emitDeclarationOnly": true,
"declaration": true,
"declarationMap": true,
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"]
}

View File

@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"isolatedModules": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["src", "vite.config.ts"],
"exclude": ["src/**/__tests__/**"]
}

View File

@ -0,0 +1,17 @@
import { fileURLToPath } from 'node:url'
import { defineConfig } from 'vite'
export default defineConfig({
build: {
lib: {
entry: fileURLToPath(new URL('./src/index.ts', import.meta.url)),
formats: ['es'],
fileName: 'index',
},
sourcemap: true,
rollupOptions: {
external: ['@bible-strong/avatar-core'],
},
},
})

26
pnpm-lock.yaml generated
View File

@ -134,6 +134,19 @@ importers:
specifier: ^19.2.3
version: 19.2.4(@types/react@19.2.18)
examples/web-vite-consumer:
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)
packages/avatar-core:
dependencies:
ajv:
@ -172,6 +185,19 @@ importers:
specifier: ^8.0.13
version: 8.2.1(@types/node@24.13.3)(jiti@2.7.0)
packages/avatar-web:
dependencies:
'@bible-strong/avatar-core':
specifier: workspace:^
version: link:../avatar-core
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)
packages:
'@asamuzakjp/css-color@6.0.7':

View File

@ -29,6 +29,7 @@ const pack = packageName => {
}
const coreTarball = pack('avatar-core')
const reactTarball = pack('avatar-react')
const webTarball = pack('avatar-web')
await cp(path.join(root, 'examples/react-vite-consumer'), consumer, {
recursive: true,
@ -44,4 +45,29 @@ await writeFile(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`)
run(['install', '--no-frozen-lockfile'], consumer)
run(['build'], consumer)
process.stdout.write(`Tarball consumer verified at ${consumer}\n`)
const webConsumer = path.join(workspace, 'web-consumer')
await cp(path.join(root, 'examples/web-vite-consumer'), webConsumer, {
recursive: true,
filter: source =>
!source.includes(`${path.sep}node_modules`) && !source.includes(`${path.sep}dist`),
})
await cp(
path.join(root, 'examples/react-vite-consumer/src/strobi.avatar.json'),
path.join(webConsumer, 'src/strobi.avatar.json')
)
const webMainPath = path.join(webConsumer, 'src/main.ts')
const webMain = await readFile(webMainPath, 'utf8')
await writeFile(
webMainPath,
webMain.replace('../../react-vite-consumer/src/strobi.avatar.json', './strobi.avatar.json')
)
const webPackagePath = path.join(webConsumer, 'package.json')
const webPackageJson = JSON.parse(await readFile(webPackagePath, 'utf8'))
webPackageJson.dependencies['@bible-strong/avatar-web'] = `file:${webTarball}`
webPackageJson.pnpm = { overrides: { '@bible-strong/avatar-core': `file:${coreTarball}` } }
await writeFile(webPackagePath, `${JSON.stringify(webPackageJson, null, 2)}\n`)
run(['install', '--no-frozen-lockfile'], webConsumer)
run(['build'], webConsumer)
process.stdout.write(`Tarball consumers verified at ${consumer} and ${webConsumer}\n`)

View File

@ -389,41 +389,29 @@ input {
font-weight: 500;
}
.runtime-readiness {
display: grid;
gap: 10px;
border-color: color-mix(in srgb, var(--destructive) 32%, var(--border));
.runtime-export-error {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px;
border: 1px solid color-mix(in srgb, var(--destructive) 32%, var(--border));
border-radius: 10px;
background: color-mix(in srgb, var(--destructive) 5%, var(--card));
}
.runtime-readiness[data-ready='true'] {
border-color: #b9d8c6;
background: #f1faf5;
}
.runtime-readiness-heading {
.runtime-export-error-heading {
display: flex;
align-items: flex-start;
gap: 10px;
align-items: center;
gap: 8px;
}
.runtime-readiness-heading > svg {
.runtime-export-error-heading > svg {
width: 18px;
height: 18px;
flex: none;
color: var(--destructive);
}
.runtime-readiness[data-ready='true'] .runtime-readiness-heading > svg {
color: #278453;
}
.runtime-readiness-heading > div {
display: grid;
gap: 2px;
}
.runtime-readiness-heading strong {
.runtime-export-error-heading strong {
font-size: 12px;
}
.runtime-readiness-heading small {
color: var(--muted-foreground);
font-size: 10px;
}
.runtime-error-list {
display: grid;
gap: 5px;
@ -433,6 +421,17 @@ input {
font-size: 11px;
line-height: 1.45;
}
.runtime-export-error-help {
margin: 0;
color: var(--muted-foreground);
font-size: 11px;
line-height: 1.45;
}
.runtime-export-error-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.export-avatar-summary strong {
overflow: hidden;
@ -477,25 +476,41 @@ input {
}
.export-format-grid strong,
.export-format-grid small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.3;
text-wrap: pretty;
}
.export-format-grid strong {
font-size: 11px;
}
.export-format-grid small {
font-size: 9px;
font-size: 10px;
}
.export-animation-header {
align-items: center;
margin-bottom: 0;
}
.export-animation-header > button {
color: #4168d5;
font-size: 11px;
}
.export-animation-header > button > svg {
width: 15px;
height: 15px;
}
.export-animation-picker {
display: grid;
gap: 10px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--border);
}
.export-animation-select-all {
justify-self: end;
color: #4168d5;
font-size: 11px;
}
.export-animation-grid {
align-items: flex-start;
@ -523,36 +538,42 @@ input {
.runtime-copy-status[role='alert'] {
color: var(--destructive);
}
.runtime-quickstart {
.runtime-example-card {
display: grid;
gap: 12px;
}
.runtime-quickstart > button {
min-height: 44px;
.runtime-export-description {
margin: 0;
color: var(--muted-foreground);
font-size: 11px;
line-height: 1.5;
}
.runtime-code-example {
.runtime-example-heading {
display: flex;
align-items: stretch;
flex-direction: column;
justify-content: space-between;
gap: 12px;
}
.runtime-example-heading > div:first-child {
display: grid;
min-width: 0;
gap: 5px;
gap: 2px;
}
.runtime-code-example > small {
.runtime-example-heading small {
color: var(--muted-foreground);
font-size: 10px;
font-weight: 700;
}
.runtime-code-example pre {
max-width: 100%;
margin: 0;
overflow-x: auto;
padding: 10px;
border: 1px solid var(--border);
border-radius: 9px;
background: #17191d;
color: #d8dee9;
.runtime-example-heading strong {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 9px;
line-height: 1.55;
white-space: pre;
font-size: 11px;
overflow-wrap: anywhere;
}
.runtime-example-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-start;
gap: 6px;
}
.runtime-token-comment {
color: #a6adbb;
@ -1074,7 +1095,9 @@ input {
}
@media (prefers-reduced-motion: reduce) {
.studio-workspace,
.body-workspace {
.body-workspace,
.dialog-backdrop,
.dialog-content {
animation: none;
}
}
@ -1631,6 +1654,410 @@ p {
background: transparent;
animation: drawer-backdrop-in 180ms ease-out;
}
.dialog-backdrop {
position: fixed;
z-index: 50;
inset: 0;
background: rgb(18 22 30 / 48%);
backdrop-filter: blur(8px);
animation: dialog-backdrop-in 180ms ease-out;
}
.dialog-content {
position: fixed;
z-index: 51;
top: 50%;
left: 50%;
display: flex;
width: min(760px, calc(100vw - 32px));
max-height: min(86dvh, 820px);
flex-direction: column;
overflow: hidden;
transform: translate(-50%, -50%);
border: 1px solid #d9dce3;
border-radius: 24px;
outline: 0;
background: #f5f6f8;
box-shadow: 0 28px 90px rgb(12 16 24 / 28%);
animation: dialog-content-in 220ms cubic-bezier(0.22, 1, 0.36, 1);
}
.dialog-header {
position: relative;
display: grid;
gap: 5px;
padding: 24px 28px 18px;
border-bottom: 1px solid #dfe2e8;
background: rgb(255 255 255 / 76%);
}
.dialog-title {
margin: 0;
color: #17191d;
font-size: 24px;
font-weight: 820;
letter-spacing: -0.045em;
}
.dialog-description {
max-width: 620px;
margin: 0;
color: #7c838e;
font-size: 12px;
line-height: 1.5;
}
.avatar-guide-dialog .avatar-guide-close {
position: absolute;
top: 18px;
right: 18px;
}
.runtime-preview-dialog {
width: min(1080px, calc(100vw - 32px));
max-height: min(90dvh, 880px);
}
.runtime-preview-dialog .runtime-preview-close {
position: absolute;
top: 18px;
right: 18px;
}
.runtime-preview-body {
display: grid;
min-height: 0;
grid-template-columns: minmax(0, 1.08fr) minmax(300px, 0.92fr);
gap: 14px;
overflow-y: auto;
padding: 18px 20px 22px;
}
.runtime-preview-stage-card,
.runtime-preview-controls > section {
border: 1px solid #dfe2e8;
border-radius: 18px;
background: rgb(255 255 255 / 82%);
}
.runtime-preview-stage-card {
display: grid;
align-content: start;
gap: 14px;
padding: 16px;
}
.runtime-preview-stage-heading,
.runtime-preview-control-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.runtime-preview-stage-heading > div,
.runtime-preview-control-heading > div {
display: grid;
gap: 2px;
}
.runtime-preview-stage-heading small,
.runtime-preview-control-heading small,
.runtime-preview-current small {
color: var(--muted-foreground);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.runtime-preview-stage-heading strong {
font-size: 15px;
}
.runtime-preview-status {
display: inline-flex;
align-items: center;
gap: 7px;
min-height: 28px;
padding: 0 10px;
border: 1px solid #dce1ea;
border-radius: 999px;
color: #687180;
background: #f8f9fb;
font-size: 10px;
font-weight: 750;
}
.runtime-preview-status > span {
width: 6px;
height: 6px;
border-radius: 50%;
background: #7d8796;
}
.runtime-preview-status[data-status='playing'] > span {
background: #45b878;
box-shadow: 0 0 0 4px rgb(69 184 120 / 11%);
}
.runtime-preview-status[data-status='paused'] > span {
background: #d99b35;
box-shadow: 0 0 0 4px rgb(217 155 53 / 11%);
}
.runtime-preview-stage {
display: grid;
min-height: 360px;
place-items: center;
overflow: hidden;
border: 1px solid #dce1e9;
border-radius: 16px;
background:
linear-gradient(rgb(92 113 160 / 7%) 1px, transparent 1px),
linear-gradient(90deg, rgb(92 113 160 / 7%) 1px, transparent 1px), #f4f6fa;
background-size: 20px 20px;
}
.runtime-preview-current {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.runtime-preview-current > div {
display: grid;
min-width: 0;
gap: 3px;
padding: 10px 12px;
border: 1px solid #e0e4eb;
border-radius: 11px;
background: #f9fafc;
}
.runtime-preview-current strong {
overflow: hidden;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.runtime-preview-transport {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 7px;
}
.runtime-preview-transport > button {
min-width: 0;
}
.runtime-preview-error {
margin: 0;
color: var(--destructive);
font-size: 11px;
}
.runtime-preview-controls {
display: grid;
align-content: start;
gap: 12px;
}
.runtime-preview-controls > section {
display: grid;
gap: 12px;
padding: 14px;
}
.runtime-preview-control-heading h3 {
margin: 0;
font-size: 14px;
}
.runtime-preview-control-heading > span {
color: var(--muted-foreground);
font-size: 10px;
}
.runtime-preview-option-grid {
display: grid;
max-height: 224px;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 7px;
overflow-y: auto;
padding: 2px;
}
.runtime-preview-option-grid > button {
justify-content: flex-start;
min-width: 0;
padding-inline: 10px;
}
.runtime-preview-option-grid > button > span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.runtime-preview-option-grid > button[aria-pressed='true'] {
border-color: #7894e7;
background: #edf2ff;
box-shadow: 0 0 0 2px rgb(91 127 229 / 12%);
color: #3159cb;
}
.runtime-preview-expression-grid {
max-height: 250px;
}
.runtime-preview-empty {
margin: 0;
padding: 18px;
color: var(--muted-foreground);
border: 1px dashed #d8dde6;
border-radius: 11px;
font-size: 11px;
text-align: center;
}
.avatar-guide-kicker {
display: flex;
align-items: center;
gap: 6px;
color: #4168d5;
font-size: 10px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.avatar-guide-kicker > svg {
width: 14px;
height: 14px;
}
.avatar-guide-body {
display: grid;
gap: 12px;
overflow-y: auto;
padding: 18px 28px 24px;
}
.avatar-guide-section {
display: grid;
gap: 12px;
padding: 16px;
border: 1px solid #dfe2e8;
border-radius: 16px;
background: rgb(255 255 255 / 76%);
}
.avatar-guide-section--accent {
border-color: #c5d2fb;
background: linear-gradient(135deg, #f0f3ff, #ffffff);
}
.avatar-guide-section--api {
gap: 16px;
background: #fbfcfe;
}
.avatar-guide-section-heading {
display: flex;
align-items: flex-start;
gap: 10px;
}
.avatar-guide-section-heading > svg {
width: 18px;
height: 18px;
flex: none;
margin-top: 1px;
color: #4168d5;
}
.avatar-guide-section-heading h3 {
margin: 0;
color: #262a31;
font-size: 13px;
font-weight: 800;
letter-spacing: -0.02em;
}
.avatar-guide-section-heading p {
margin: 4px 0 0;
color: #717985;
font-size: 11px;
line-height: 1.55;
}
.avatar-guide-section-heading code {
padding: 1px 4px;
border-radius: 4px;
background: #edf1ff;
color: #3159cb;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 10px;
}
.avatar-guide-notice {
margin: 0;
color: #687281;
font-size: 11px;
line-height: 1.5;
text-wrap: pretty;
}
.avatar-guide-section-heading--nested {
margin-top: 2px;
padding-top: 14px;
border-top: 1px solid #e6e9ef;
}
.avatar-guide-prop-group {
display: grid;
gap: 6px;
}
.avatar-guide-prop-group > strong {
color: #4d5665;
font-size: 10px;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.avatar-guide-prop-group dl {
display: grid;
margin: 0;
overflow: hidden;
border: 1px solid #e0e4eb;
border-radius: 11px;
background: #ffffff;
}
.avatar-guide-prop {
display: grid;
grid-template-columns: minmax(150px, 0.42fr) minmax(0, 1fr);
gap: 14px;
padding: 9px 11px;
border-bottom: 1px solid #edf0f4;
}
.avatar-guide-prop:last-child {
border-bottom: 0;
}
.avatar-guide-prop dt,
.avatar-guide-prop dd {
min-width: 0;
margin: 0;
}
.avatar-guide-prop dt {
display: grid;
align-content: start;
gap: 3px;
}
.avatar-guide-prop dt code {
color: #3159cb;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 10px;
font-weight: 700;
overflow-wrap: anywhere;
}
.avatar-guide-prop-type {
color: #8a93a1;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 10px;
line-height: 1.35;
overflow-wrap: anywhere;
}
.avatar-guide-prop dd {
color: #687281;
font-size: 11px;
line-height: 1.45;
}
.avatar-guide-code {
max-width: 100%;
margin: 0;
overflow-x: auto;
padding: 12px 14px;
border: 1px solid #2d323b;
border-radius: 11px;
background: #17191d;
color: #d8dee9;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11px;
line-height: 1.6;
white-space: pre;
}
.avatar-guide-footer {
display: flex;
justify-content: flex-end;
padding: 14px 28px 20px;
border-top: 1px solid #dfe2e8;
background: rgb(255 255 255 / 76%);
}
@keyframes dialog-backdrop-in {
from {
opacity: 0;
}
}
@keyframes dialog-content-in {
from {
opacity: 0;
transform: translate(-50%, calc(-50% + 12px)) scale(0.98);
}
}
.drawer-viewport {
position: fixed;
z-index: 41;
@ -1810,6 +2237,14 @@ p {
.render-style-field > [data-slot='select-trigger'] {
width: 150px;
}
.render-style-disabled {
display: grid;
gap: 12px;
}
.render-style-status {
display: flex;
justify-content: flex-start;
}
.pixel-render-options {
display: grid;
margin-top: 14px;
@ -2326,7 +2761,7 @@ p {
padding: 2px 4px;
border-radius: 5px;
background: rgb(255 255 255 / 78%);
font-size: 8px;
font-size: 9px;
font-weight: 850;
}
.runtime-key-missing {
@ -2698,6 +3133,55 @@ p {
}
@media (max-width: 600px) {
.dialog-content {
width: calc(100vw - 20px);
max-height: 90dvh;
border-radius: 20px;
}
.dialog-header {
padding: 20px 18px 16px;
}
.dialog-title {
max-width: calc(100% - 32px);
font-size: 20px;
}
.avatar-guide-body {
padding: 14px 14px 18px;
}
.avatar-guide-section {
padding: 13px;
}
.avatar-guide-prop {
grid-template-columns: minmax(0, 1fr);
gap: 3px;
}
.avatar-guide-footer {
padding: 12px 18px 16px;
}
.runtime-preview-dialog {
width: min(100vw - 20px, 620px);
max-height: calc(100dvh - 20px);
}
.runtime-preview-body {
grid-template-columns: minmax(0, 1fr);
padding: 14px;
}
.runtime-preview-stage {
min-height: min(64vw, 300px);
}
.runtime-preview-transport {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.runtime-preview-option-grid {
max-height: none;
}
.runtime-example-heading {
align-items: flex-start;
flex-direction: column;
}
.runtime-example-actions {
justify-content: flex-start;
}
html,
body,
#root {

View File

@ -0,0 +1,37 @@
import { Dialog as DialogPrimitive } from '@base-ui/react/dialog'
import type * as React from 'react'
import { cn } from '@/lib/utils'
function Dialog(props: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root {...props} />
}
function DialogClose(props: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close {...props} />
}
function DialogContent({ className, children, ...props }: DialogPrimitive.Popup.Props) {
return (
<DialogPrimitive.Portal>
<DialogPrimitive.Backdrop className="dialog-backdrop" />
<DialogPrimitive.Popup className={cn('dialog-content', className)} {...props}>
{children}
</DialogPrimitive.Popup>
</DialogPrimitive.Portal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
return <div className={cn('dialog-header', className)} {...props} />
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return <DialogPrimitive.Title className={cn('dialog-title', className)} {...props} />
}
function DialogDescription({ className, ...props }: DialogPrimitive.Description.Props) {
return <DialogPrimitive.Description className={cn('dialog-description', className)} {...props} />
}
export { Dialog, DialogClose, DialogContent, DialogDescription, DialogHeader, DialogTitle }

View File

@ -2,10 +2,7 @@ import {
AVATAR_DEFINITION_MAX_BYTES,
avatarDefinitionFileName,
createAvatarDefinition,
getStandardAnimationAvailabilityV1,
parseAvatarDefinition,
STANDARD_ANIMATIONS_V1,
STANDARD_ANIMATION_ORDER_V1,
validateAvatarDefinition,
type AvatarDefinition,
} from '@/features/avatar/avatarDefinition'
@ -117,8 +114,8 @@ describe('avatar definition validation', () => {
expect(result.value).not.toBe(input)
expect(Object.isFrozen(result.value)).toBe(true)
expect(Object.isFrozen(result.value.expressions.neutral.eyes.left)).toBe(true)
expect(JSON.parse(JSON.stringify(result.value))).toEqual(input)
expect(validateAvatarDefinition(JSON.parse(JSON.stringify(result.value))).ok).toBe(true)
expect(structuredClone(result.value)).toEqual(input)
expect(validateAvatarDefinition(structuredClone(result.value)).ok).toBe(true)
})
it('rejects unsupported versions, unknown fields, malformed keys, and missing neutral', () => {
@ -491,12 +488,23 @@ describe('Studio to avatar definition conversion', () => {
'upward-side-glance',
'curious-left',
])
expect(getStandardAnimationAvailabilityV1(result.value.expressions).available).toEqual(
STANDARD_ANIMATION_ORDER_V1
)
expect(JSON.stringify(result.value)).not.toContain('expression-00')
})
it('exports a valid expression-only definition when no animation is selected', () => {
const behavior = behaviorFixture()
const result = createAvatarDefinition({
avatar: avatarFixture(),
behavior: { ...behavior, sequences: [] },
})
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.value.expressionOrder).toEqual(['neutral', 'happy-smile'])
expect(result.value.animations).toEqual({})
expect(result.value.animationOrder).toEqual([])
})
it('rejects newly created custom content until semantic keys are supplied', () => {
const expression = { ...initialExpressions[0], id: 'expression-custom', semanticKey: undefined }
const sequence = createSequence(expression.id)
@ -614,101 +622,7 @@ describe('Studio to avatar definition conversion', () => {
})
})
describe('standard animation catalogue v1', () => {
it('commits the approved keys, order, steps, timing, and blink settings', () => {
expect(STANDARD_ANIMATION_ORDER_V1).toEqual([
'idle',
'happy',
'sad',
'thinking',
'excited',
'celebrate',
])
expect(STANDARD_ANIMATIONS_V1.idle).toEqual({
requiredExpressions: ['upward-side-glance', 'curious-left'],
playbackMode: 'loop',
steps: [
{
expression: 'upward-side-glance',
holdMs: 5200,
transitionMs: 500,
transition: 'smooth',
},
{
expression: 'curious-left',
holdMs: 5200,
transitionMs: 500,
transition: 'smooth',
},
],
blink: {
enabled: true,
initialDelayMs: 2600,
minIntervalMs: 3400,
maxIntervalMs: 6200,
durationMs: 280,
},
})
expect(Object.values(STANDARD_ANIMATIONS_V1).every(animation => animation.steps.length)).toBe(
true
)
expect(
Object.values(STANDARD_ANIMATIONS_V1).every(animation =>
animation.steps.every(
step =>
step.transitionMs === 500 &&
step.transition === 'smooth' &&
animation.requiredExpressions.includes(step.expression)
)
)
).toBe(true)
expect(
Object.fromEntries(
STANDARD_ANIMATION_ORDER_V1.map(key => [
key,
STANDARD_ANIMATIONS_V1[key].steps.map(step => step.expression),
])
)
).toEqual({
idle: ['upward-side-glance', 'curious-left'],
happy: ['joyful-down-right', 'joyful-wide', 'playful-right', 'gentle-downward-gaze'],
sad: ['sleepy-squint', 'eyes-closed', 'drowsy-closed'],
thinking: [
'curious-left',
'angry-left',
'skeptical-left',
'playful-right',
'skeptical-right',
],
excited: [
'joyful-down-right',
'playful-right',
'surprised-wide-left',
'surprised-left',
'joyful-wide',
],
celebrate: ['joyful-down-right', 'curious-left', 'playful-right'],
})
})
it('reports available standards and exact missing semantic expressions', () => {
const definition = definitionFixture()
const availability = getStandardAnimationAvailabilityV1(definition.expressions)
expect(availability.available).toEqual([])
expect(availability.unavailable.find(item => item.key === 'idle')).toEqual({
key: 'idle',
missingExpressions: ['upward-side-glance', 'curious-left'],
})
const idleExpressions = {
...definition.expressions,
'upward-side-glance': definition.expressions.neutral,
'curious-left': definition.expressions.neutral,
}
expect(getStandardAnimationAvailabilityV1(idleExpressions).available).toEqual(['idle'])
})
describe('runtime definition filenames', () => {
it('creates a sanitized runtime-definition filename', () => {
expect(avatarDefinitionFileName(' Éric Avatar! ')).toBe('eric-avatar.avatar.json')
expect(avatarDefinitionFileName('***')).toBe('avatar.avatar.json')

View File

@ -52,20 +52,14 @@ describe('avatar render style', () => {
expect(parseAvatarRenderStyle(undefined)).toEqual({ type: 'vector' })
})
it('sanitizes pixel settings', () => {
it('falls back to vector rendering while pixel mode is disabled', () => {
expect(
parseAvatarRenderStyle({
type: 'pixel',
resolution: 500,
})
).toEqual({
type: 'pixel',
resolution: 192,
})
expect(parseAvatarRenderStyle({ type: 'pixel', resolution: 1 })).toEqual({
type: 'pixel',
resolution: 8,
})
).toEqual({ type: 'vector' })
expect(parseAvatarRenderStyle({ type: 'pixel', resolution: 1 })).toEqual({ type: 'vector' })
})
})

View File

@ -186,7 +186,6 @@ export const createAvatarDefinition = ({
],
animations,
animationOrder: behavior.sequences.map(sequence => sequence.semanticKey!),
standardAnimationSet: 1,
}
return validateAvatarDefinition(definition)
}

View File

@ -25,6 +25,7 @@ export type StudioAvatar = {
}
export type AvatarColors = { body: string; eyes: string }
export const PIXEL_RENDERING_ENABLED = false
export type PixelRenderStyle = {
type: 'pixel'
resolution: number
@ -85,7 +86,9 @@ const finiteBounded = (value: unknown, fallback: number, min: number, max: numbe
export const parseAvatarRenderStyle = (value: unknown): AvatarRenderStyle => {
const candidate = value as Partial<PixelRenderStyle> | null
if (candidate?.type !== 'pixel') return { ...defaultAvatarRenderStyle }
if (!PIXEL_RENDERING_ENABLED || candidate?.type !== 'pixel') {
return { ...defaultAvatarRenderStyle }
}
return {
type: 'pixel',
resolution: Math.round(

View File

@ -5,10 +5,15 @@ import {
generateJavaScriptAvatarHtml,
generateJavaScriptAvatarModule,
generateJavaScriptAvatarPackage,
generateJavaScriptEsmHtml,
generateJavaScriptEsmPackage,
generateReactAvatarComponent,
generateReactAvatarPackage,
generateReactAvatarRuntime,
} from '@/features/export/exporter'
import { createAvatarDefinition } from '@/features/avatar/avatarDefinition'
import { resolveAvatarBehavior } from '@/features/avatar/avatars'
import { loadStudioDocument } from '@/features/studio/studioDocument'
import { initialExpressions } from '@/features/avatar/presets'
import { createInitialSequences } from '@/features/animation/sequences'
@ -122,6 +127,36 @@ describe('avatar export', () => {
expect(contents).toContain('avatar.js')
})
it('generates a lightweight ESM integration backed by avatar-web', async () => {
const document = loadStudioDocument({ getItem: () => null })
const studioAvatar = document.library.avatars[0]
const definition = createAvatarDefinition({
avatar: studioAvatar,
behavior: resolveAvatarBehavior(studioAvatar, {
expressions: document.expressions,
sequences: document.sequences,
}),
})
expect(definition.ok).toBe(true)
if (!definition.ok) return
const source = generateJavaScriptEsmHtml('strobi.avatar.json', 'Strobi')
expect(source).toContain("from 'https://esm.sh/@bible-strong/avatar-web@0.1.0'")
expect(source).toContain("fetch('./strobi.avatar.json')")
expect(source).not.toContain('AvatarProceduralEngine')
const archive = new Uint8Array(
await generateJavaScriptEsmPackage(definition.value, 'Strobi').arrayBuffer()
)
const contents = new TextDecoder().decode(archive)
expect(contents).toContain('strobi.avatar.json')
expect(contents).toContain('index.html')
expect(contents).not.toContain('avatar.js')
expect(contents).toContain('README.md')
expect(contents).toContain('esm.sh/@bible-strong/avatar-web@0.1.0')
expect(contents).not.toContain('AvatarProceduralEngine')
})
it('generates a typed React component backed by the local runtime', () => {
const source = generateReactAvatarComponent(payload)

View File

@ -1,4 +1,5 @@
import { applyAvatarEyeDefaults, type StudioAvatar } from '../avatar/avatars'
import { avatarDefinitionFileName, type AvatarDefinition } from '@bible-strong/avatar-core'
import type { Expression } from '../avatar/geometry'
import { translateStudioText, type StudioLanguage } from '../../i18n'
import { proceduralBrowserRuntime } from './proceduralBrowserRuntime'
@ -357,6 +358,120 @@ export const generateJavaScriptAvatarPackage = (
])
}
export const generateJavaScriptEsmHtml = (
definitionFileName: string,
avatarName: string
) => `<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>${escapedHtml(avatarName)} · Avatar demo</title>
<style>
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; padding: 32px; color: #f7f8fa; background: #0d1117; font-family: Inter, system-ui, sans-serif; }
main { width: min(980px, 100%); margin: auto; }
h1 { margin: 0 0 24px; }
.demo { display: grid; grid-template-columns: minmax(280px, 1fr) minmax(280px, .8fr); gap: 20px; }
.stage, .controls { border: 1px solid #293140; border-radius: 22px; background: #141a22; }
.stage { display: grid; min-height: 560px; place-items: center; }
#avatar { width: min(80%, 460px); aspect-ratio: 1; }
.controls { padding: 20px; }
h2 { margin: 0 0 12px; font-size: 15px; }
.grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin-bottom: 22px; }
button { min-width: 0; padding: 10px; overflow: hidden; color: #dbe3ef; border: 1px solid #354052; border-radius: 10px; background: #1b2330; font: inherit; text-overflow: ellipsis; cursor: pointer; }
button:hover, button[aria-pressed="true"] { color: #fff; border-color: #7392ed; background: #263657; }
@media (max-width: 760px) { body { padding: 16px; } .demo { grid-template-columns: 1fr; } .stage { min-height: 420px; } }
</style>
</head>
<body>
<main>
<h1>${escapedHtml(avatarName)}</h1>
<div class="demo">
<section class="stage"><div id="avatar"></div></section>
<aside class="controls">
<h2>Animations</h2>
<div class="grid" id="animations"></div>
<h2>Expressions</h2>
<div class="grid" id="expressions"></div>
</aside>
</div>
</main>
<script type="module">
import { createAvatar } from 'https://esm.sh/@bible-strong/avatar-web@0.1.0'
const definition = await fetch('./${definitionFileName}').then(response => response.json())
const avatar = createAvatar('#avatar', { definition, size: '100%' })
const addButtons = (containerId, keys, activate) => {
const container = document.querySelector(containerId)
keys.forEach(key => {
const button = document.createElement('button')
button.textContent = key
button.addEventListener('click', () => {
container.querySelectorAll('button').forEach(item => item.setAttribute('aria-pressed', 'false'))
button.setAttribute('aria-pressed', 'true')
activate(key)
})
container.append(button)
})
}
addButtons('#animations', definition.animationOrder, key => avatar.play(key))
addButtons('#expressions', definition.expressionOrder, key => avatar.setExpression(key))
if (definition.animationOrder[0]) avatar.play(definition.animationOrder[0])
</script>
</body>
</html>
`
export const generateJavaScriptEsmReadme = (
definitionFileName: string
) => `# Avatar JavaScript / ESM
This export uses the same .avatar.json definition as @bible-strong/avatar-react. The rendering and
playback engine is provided by @bible-strong/avatar-web instead of being copied into this export.
## Run the demo
\`\`\`sh
npx serve .
\`\`\`
Then open the local URL displayed by the command. index.html loads avatar-web from esm.sh and the
exported ${definitionFileName} file from this folder.
## Use in your application
\`\`\`js
import { createAvatar } from '@bible-strong/avatar-web'
import definition from './${definitionFileName}'
const avatar = createAvatar('#avatar', {
definition,
defaultAnimation: 'idle',
})
avatar.play('happy')
avatar.pause()
avatar.stop()
\`\`\`
The JSON import is intended for a modern ESM build tool such as Vite.
`
export const generateJavaScriptEsmPackage = (
definition: Readonly<AvatarDefinition>,
avatarName: string
) => {
const definitionFileName = avatarDefinitionFileName(avatarName)
return createStoredZip([
{ name: definitionFileName, content: JSON.stringify(definition, null, 2) },
{ name: 'index.html', content: generateJavaScriptEsmHtml(definitionFileName, avatarName) },
{ name: 'README.md', content: generateJavaScriptEsmReadme(definitionFileName) },
])
}
const avatarExportSlug = (name: string) =>
name
.normalize('NFD')

View File

@ -0,0 +1,63 @@
// @vitest-environment jsdom
import { fireEvent, render, screen } from '@testing-library/react'
import { validateAvatarDefinition, type AvatarDefinition } from '@bible-strong/avatar-core'
import definitionJson from '../../../../examples/react-vite-consumer/src/strobi.avatar.json'
import { RuntimePreviewDialog } from '@/features/studio/components/RuntimePreviewDialog'
import { StudioLanguageProvider } from '@/i18n'
const definitionResult = validateAvatarDefinition(definitionJson)
if (!definitionResult.ok) throw new Error('Invalid preview fixture')
const definition = definitionResult.value
const renderPreview = (avatarDefinition: AvatarDefinition, initialAnimation?: string) =>
render(
<StudioLanguageProvider>
<RuntimePreviewDialog
definition={avatarDefinition}
initialAnimation={initialAnimation}
open
onOpenChange={vi.fn()}
/>
</StudioLanguageProvider>
)
describe('runtime export preview', () => {
beforeEach(() => {
vi.stubGlobal('requestAnimationFrame', () => 1)
vi.stubGlobal('cancelAnimationFrame', vi.fn())
vi.stubGlobal('matchMedia', () => ({ matches: false }))
})
afterEach(() => vi.unstubAllGlobals())
it('exposes the exported animations, expressions and playback controls', () => {
renderPreview(definition, 'sleeping')
expect(screen.getByRole('heading', { name: 'Exported definition preview' })).toBeTruthy()
expect(screen.getByRole('heading', { name: 'Exported animations' })).toBeTruthy()
expect(screen.getByRole('heading', { name: 'Exported expressions' })).toBeTruthy()
expect(screen.getByRole('button', { name: 'Play' })).toBeTruthy()
expect(screen.getByRole('button', { name: 'Pause' })).toBeTruthy()
expect(screen.getByRole('button', { name: 'Stop' })).toBeTruthy()
expect(screen.getByRole('button', { name: 'Restart' })).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'idle' }))
expect(screen.getByRole('button', { name: 'idle' }).getAttribute('aria-pressed')).toBe('true')
})
it('previews an expression-only definition and disables Play', () => {
const expressionOnly = {
...definition,
animations: {},
animationOrder: [],
} as AvatarDefinition
renderPreview(expressionOnly)
expect(screen.getByText('No exported animation')).toBeTruthy()
expect((screen.getByRole('button', { name: 'Play' }) as HTMLButtonElement).disabled).toBe(true)
expect(screen.getAllByText('neutral').length).toBeGreaterThan(0)
})
})

View File

@ -0,0 +1,604 @@
import { BookOpen, Code2, MousePointer2, Play, SlidersHorizontal, X } from 'lucide-react'
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { useStudioLanguage } from '@/i18n'
const runtimeInstallExample = 'npm install @bible-strong/avatar-react react react-dom'
const webInstallExample = 'npm install @bible-strong/avatar-web'
const webAvatarExample = (
animationKey: string | undefined
) => `import { createAvatar } from '@bible-strong/avatar-web'
import definition from './avatar.avatar.json'
const avatar = createAvatar('#avatar', {
definition,
${animationKey ? `defaultAnimation: '${animationKey}',` : `defaultExpression: 'neutral',`}
})
avatar.play(${animationKey ? `'${animationKey}'` : `'idle'`})
avatar.pause()
avatar.setExpression('neutral')
avatar.stop()`
const nativeBrowserExample = `<!-- Resolve the package URL through your CDN or import map. -->
<div id="avatar"></div>
<script type="module">
import { createAvatar } from 'https://cdn.example.com/avatar-web.js'
const definition = await fetch('./avatar.avatar.json').then(response => response.json())
const avatar = createAvatar('#avatar', { definition, defaultExpression: 'neutral' })
</script>`
const createAvatarExample = (
animationKey: string | undefined
) => `import { createAvatar } from '@bible-strong/avatar-react'
import '@bible-strong/avatar-react/styles.css'
import avatarJson from './avatar.avatar.json'
const StrobiAvatar = createAvatar(avatarJson)
export function Strobi() {
return <StrobiAvatar ${
animationKey ? `defaultAnimation="${animationKey}"` : 'defaultExpression="neutral"'
} />
}`
const genericAvatarExample = `import { Avatar } from '@bible-strong/avatar-react'
import type { AvatarDefinition, ExpressionKey } from '@bible-strong/avatar-core'
import '@bible-strong/avatar-react/styles.css'
export function DynamicAvatar({
definition,
expression,
}: {
definition: AvatarDefinition
expression: ExpressionKey
}) {
return (
<Avatar
definition={definition}
expression={expression}
onError={error => console.error(error)}
/>
)
}`
const imperativeExample = (
animationKey: string | undefined
) => `import { createAvatar, type AvatarController } from '@bible-strong/avatar-react'
import { useRef } from 'react'
import avatarJson from './avatar.avatar.json'
const StrobiAvatar = createAvatar(avatarJson)
export function Controls() {
const avatar = useRef<AvatarController>(null)
return <>
<StrobiAvatar ref={avatar} ${
animationKey ? `defaultAnimation="${animationKey}"` : 'defaultExpression="neutral"'
} />${
animationKey
? `
<button onClick={() => avatar.current?.play('${animationKey}')}>Play animation</button>`
: ''
}
<button onClick={() => avatar.current?.pause()}>Pause</button>
<button onClick={() => avatar.current?.setExpression('neutral')}>Set expression</button>
<button onClick={() => avatar.current?.stop()}>Stop</button>
<button onClick={() => console.log(avatar.current?.getState())}>Read state</button>
</>
}`
const codeTokenPattern =
/(\/\/.*|'.*?'|".*?"|@[a-z0-9-/]+|<\/?[A-Z][A-Za-z]*|\b(?:npm|install|import|from|const|if|throw|new|export|function|return|onClick|ref|useRef)\b)/g
const highlightedCode = (source: string) =>
source.split(codeTokenPattern).map((token, index) => {
if (!token) return null
const kind = token.startsWith('//')
? 'comment'
: token.startsWith("'") || token.startsWith('"') || token.startsWith('@')
? 'string'
: token.startsWith('<')
? 'tag'
: /^(?:npm|install|import|from|const|if|throw|new|export|function|return|onClick|ref|useRef)$/.test(
token
)
? 'keyword'
: 'plain'
return (
<span className={`runtime-token runtime-token-${kind}`} key={`${index}-${token}`}>
{token}
</span>
)
})
function GuideCode({ children }: { children: string }) {
return (
<pre className="avatar-guide-code" tabIndex={0}>
<code>{highlightedCode(children)}</code>
</pre>
)
}
function PropRow({ name, type, description }: { name: string; type: string; description: string }) {
return (
<div className="avatar-guide-prop">
<dt>
<code>{name}</code>
<span className="avatar-guide-prop-type">{type}</span>
</dt>
<dd>{description}</dd>
</div>
)
}
export function RuntimeGuideDialog({
animationKey,
integration = 'react',
onOpenChange,
open,
}: {
animationKey?: string
integration?: 'react' | 'javascript'
onOpenChange: (open: boolean) => void
open: boolean
}) {
const { t } = useStudioLanguage()
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="avatar-guide-dialog">
<DialogHeader>
<div className="avatar-guide-kicker">
<BookOpen />
<span>{t('Guide d’utilisation')}</span>
</div>
<DialogTitle>
{t(
integration === 'react'
? 'Guide d’utilisation de l’avatar React'
: 'Guide d’utilisation de l’avatar JavaScript'
)}
</DialogTitle>
<DialogDescription>
{t(
integration === 'react'
? 'Installe le package, crée ton composant et choisis le niveau de contrôle adapté.'
: 'Installe le module ESM, charge la définition JSON et monte l’avatar dans un élément du DOM.'
)}
</DialogDescription>
<DialogClose
render={
<Button
variant="ghost"
size="icon"
className="avatar-guide-close"
aria-label={t('Fermer le guide')}
>
<X />
</Button>
}
/>
</DialogHeader>
<div className="avatar-guide-body">
{integration === 'react' ? (
<>
<section className="avatar-guide-section avatar-guide-section--accent">
<div className="avatar-guide-section-heading">
<Code2 />
<div>
<h3>{t('Installation')}</h3>
<p>{t('Ajoute le package React et ses dépendances.')}</p>
</div>
</div>
<GuideCode>{runtimeInstallExample}</GuideCode>
<p className="avatar-guide-notice">
{t(
'Les packages sont encore privés. Cette commande fonctionnera après leur publication ; utilise le workspace ou les tarballs pour les tests locaux.'
)}
</p>
</section>
<section className="avatar-guide-section">
<div className="avatar-guide-section-heading">
<Play />
<div>
<h3>{t('API recommandée : créer un avatar concret')}</h3>
<p>
{t(
'createAvatar valide le JSON et retourne un composant dédié dont les clés d’animations sont typées.'
)}
</p>
</div>
</div>
<GuideCode>{createAvatarExample(animationKey)}</GuideCode>
</section>
<section className="avatar-guide-section avatar-guide-section--api">
<div className="avatar-guide-section-heading">
<SlidersHorizontal />
<div>
<h3>{t('Props de l’avatar')}</h3>
<p>
{t(
'Référence complète : type, valeur par défaut, comportement et contraintes de chaque prop.'
)}
</p>
</div>
</div>
<div className="avatar-guide-prop-group">
<strong>{t('Cible et lecture')}</strong>
<dl>
<PropRow
name="definition"
type="AvatarDefinition"
description={t(
'Obligatoire. Objet AvatarDefinition validé contenant les expressions et les animations à afficher.'
)}
/>
<PropRow
name="animation"
type="AnimationKey | undefined"
description={t(
'Optionnelle. Contrôle une timeline par sa clé. Chaque étape choisit l’expression affichée. Mutuellement exclusive avec expression ; une cible contrôlée prend priorité sur les valeurs default.'
)}
/>
<PropRow
name="expression"
type="ExpressionKey | undefined"
description={t(
'Optionnelle. Contrôle directement une expression par sa clé. Mutuellement exclusive avec animation ; une cible contrôlée prend priorité sur les valeurs default.'
)}
/>
<PropRow
name="defaultAnimation"
type="AnimationKey | undefined"
description={t(
'Optionnelle. Définit la timeline initiale en mode non contrôlé. Lue au montage ; autoplay est activé par défaut. Mutuellement exclusive avec defaultExpression.'
)}
/>
<PropRow
name="defaultExpression"
type="ExpressionKey | undefined"
description={t(
'Optionnelle. Définit l’expression initiale en mode non contrôlé. Lue au montage, sans lancer de timeline. Mutuellement exclusive avec defaultAnimation.'
)}
/>
<PropRow
name="autoplay"
type="boolean | undefined"
description={t(
'Optionnelle, défaut true. Lance automatiquement defaultAnimation ; sans defaultAnimation, elle n’a aucun effet.'
)}
/>
<PropRow
name="ref"
type="Ref<AvatarController> | undefined"
description={t(
'Optionnelle. Donne accès à l’API impérative AvatarController.'
)}
/>
</dl>
</div>
<div className="avatar-guide-prop-group">
<strong>{t('Présentation')}</strong>
<dl>
<PropRow
name="size"
type="number | string | undefined"
description={t(
'Optionnelle, défaut 240. Nombre ou valeur CSS utilisée pour la largeur et la hauteur du conteneur.'
)}
/>
<PropRow
name="className"
type="string | undefined"
description={t('Optionnelle. Classe CSS ajoutée au conteneur externe.')}
/>
<PropRow
name="style"
type="CSSProperties | undefined"
description={t(
'Optionnelle. Styles inline du conteneur externe ; width et height viennent de size.'
)}
/>
<PropRow
name="ariaLabel"
type="string | undefined"
description={t(
'Optionnelle, défaut « Procedural avatar ». Nom accessible annoncé aux lecteurs d’écran.'
)}
/>
</dl>
</div>
<div className="avatar-guide-prop-group">
<strong>{t('Callbacks de lecture')}</strong>
<dl>
<PropRow
name="onAnimationEnd"
type="(animation: AnimationKey) => void"
description={t(
'Optionnelle. Reçoit la clé de l’animation once terminée naturellement.'
)}
/>
<PropRow
name="onExpressionChange"
type="(expression: ExpressionKey) => void"
description={t(
'Optionnelle. Reçoit la clé de l’expression chaque fois que l’expression sémantique affichée change.'
)}
/>
<PropRow
name="onError"
type="(error: AvatarRuntimeError) => void"
description={t(
'Optionnelle. Reçoit une erreur typée lorsqu’une prop animation, expression ou default référence une clé inconnue.'
)}
/>
</dl>
</div>
</section>
<section className="avatar-guide-section">
<div className="avatar-guide-section-heading">
<Code2 />
<div>
<h3>{t('Avatar générique')}</h3>
<p>
{t(
'Utilise Avatar directement lorsque la définition est chargée à l’exécution ou change entre plusieurs avatars.'
)}
</p>
</div>
</div>
<GuideCode>{genericAvatarExample}</GuideCode>
</section>
<section className="avatar-guide-section avatar-guide-section--api">
<div className="avatar-guide-section-heading">
<MousePointer2 />
<div>
<h3>{t('API impérative')}</h3>
<p>
{t('La ref expose les commandes de lecture et l’état courant de l’avatar.')}
</p>
<p>
{t(
'Les commandes de cible sont disponibles en mode non contrôlé ; sinon utilise les props.'
)}
</p>
</div>
</div>
<div className="avatar-guide-prop-group">
<dl>
<PropRow
name="play(animation)"
type="(animation: AnimationKey) => AvatarCommandResult"
description={t(
'Lance ou reprend une animation et retourne un résultat typé.'
)}
/>
<PropRow
name="pause()"
type="() => void"
description={t('Met en pause la timeline à sa position exacte.')}
/>
<PropRow
name="stop()"
type="() => void"
description={t(
'En mode non contrôlé, arrête la lecture et revient à neutral. En mode contrôlé, les props restent la source de vérité.'
)}
/>
<PropRow
name="setExpression(expression)"
type="(expression: ExpressionKey) => AvatarCommandResult"
description={t('Affiche directement une expression.')}
/>
<PropRow
name="getState()"
type="() => AvatarPlaybackState"
description={t('Retourne l’animation, l’expression et le statut actifs.')}
/>
</dl>
</div>
<GuideCode>{imperativeExample(animationKey)}</GuideCode>
</section>
</>
) : (
<>
<section className="avatar-guide-section avatar-guide-section--accent">
<div className="avatar-guide-section-heading">
<Code2 />
<div>
<h3>{t('Installation')}</h3>
<p>{t('Ajoute le renderer DOM, qui utilise automatiquement avatar-core.')}</p>
</div>
</div>
<GuideCode>{webInstallExample}</GuideCode>
<p className="avatar-guide-notice">
{t(
'Les packages sont encore privés. Cette commande fonctionnera après leur publication ; utilise le workspace ou les tarballs pour les tests locaux.'
)}
</p>
</section>
<section className="avatar-guide-section">
<div className="avatar-guide-section-heading">
<Play />
<div>
<h3>{t('Utilisation avec un bundler ESM')}</h3>
<p>
{t(
'Vite et les bundlers modernes résolvent le package et importent le même fichier .avatar.json que React.'
)}
</p>
</div>
</div>
<GuideCode>{webAvatarExample(animationKey)}</GuideCode>
</section>
<section className="avatar-guide-section avatar-guide-section--api">
<div className="avatar-guide-section-heading">
<SlidersHorizontal />
<div>
<h3>{t('Options de createAvatar')}</h3>
<p>{t('Référence des valeurs acceptées lors du montage dans le DOM.')}</p>
</div>
</div>
<div className="avatar-guide-prop-group">
<dl>
<PropRow
name="definition"
type="AvatarDefinition | unknown"
description={t(
'Obligatoire. Définition JSON validée avant la création des éléments SVG.'
)}
/>
<PropRow
name="defaultAnimation"
type="AnimationKey | undefined"
description={t(
'Optionnelle. Animation lancée au montage lorsque autoplay vaut true. Mutuellement exclusive avec defaultExpression.'
)}
/>
<PropRow
name="defaultExpression"
type="ExpressionKey | undefined"
description={t(
'Optionnelle. Expression initiale affichée sans lancer de timeline. Mutuellement exclusive avec defaultAnimation.'
)}
/>
<PropRow
name="autoplay"
type="boolean | undefined"
description={t(
'Optionnelle, défaut true. Contrôle uniquement le lancement automatique de defaultAnimation.'
)}
/>
<PropRow
name="size"
type="number | string | undefined"
description={t(
'Optionnelle, défaut 240. Largeur et hauteur CSS du conteneur rendu.'
)}
/>
<PropRow
name="className"
type="string | undefined"
description={t('Optionnelle. Classe CSS ajoutée au conteneur rendu.')}
/>
<PropRow
name="ariaLabel"
type="string | undefined"
description={t(
'Optionnelle, défaut « Procedural avatar ». Nom accessible du rendu.'
)}
/>
<PropRow
name="onError"
type="(error: AvatarRuntimeError) => void"
description={t(
'Optionnelle. Reçoit les erreurs de clé inconnue utilisées lors de l’initialisation.'
)}
/>
<PropRow
name="onAnimationEnd"
type="(animation: AnimationKey) => void"
description={t('Optionnelle. Appelée lorsqu’une animation once se termine.')}
/>
<PropRow
name="onExpressionChange"
type="(expression: ExpressionKey) => void"
description={t('Optionnelle. Appelée lorsque l’expression active change.')}
/>
</dl>
</div>
</section>
<section className="avatar-guide-section avatar-guide-section--api">
<div className="avatar-guide-section-heading">
<MousePointer2 />
<div>
<h3>{t('API du contrôleur DOM')}</h3>
<p>{t('createAvatar retourne immédiatement ces commandes impératives.')}</p>
</div>
</div>
<div className="avatar-guide-prop-group">
<dl>
<PropRow
name="play(animation)"
type="(animation: AnimationKey) => AvatarCommandResult"
description={t('Lance ou reprend une animation par sa clé.')}
/>
<PropRow
name="setExpression(expression)"
type="(expression: ExpressionKey) => AvatarCommandResult"
description={t('Affiche une expression avec une transition courte.')}
/>
<PropRow
name="pause()"
type="() => void"
description={t('Met en pause la timeline à sa position exacte.')}
/>
<PropRow
name="stop()"
type="() => void"
description={t('Arrête la lecture et revient à neutral.')}
/>
<PropRow
name="getState()"
type="() => AvatarPlaybackState"
description={t('Retourne l’animation, l’expression et le statut actifs.')}
/>
<PropRow
name="destroy()"
type="() => void"
description={t(
'Annule la frame planifiée et retire uniquement le conteneur créé par avatar-web.'
)}
/>
</dl>
</div>
</section>
<section className="avatar-guide-section">
<div className="avatar-guide-section-heading">
<Code2 />
<div>
<h3>{t('Navigateur sans bundler')}</h3>
<p>
{t(
'Utilise une URL ESM via un CDN ou une import map, puis charge la définition avec fetch.'
)}
</p>
</div>
</div>
<GuideCode>{nativeBrowserExample}</GuideCode>
</section>
</>
)}
</div>
<div className="avatar-guide-footer">
<DialogClose render={<Button variant="outline">{t('Fermer le guide')}</Button>} />
</div>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,257 @@
import { Eye, Pause, Play, RotateCcw, Square, X } from 'lucide-react'
import { useRef, useState } from 'react'
import { Avatar, type AvatarController, type AvatarRuntimeError } from '@bible-strong/avatar-react'
import type { AnimationKey, AvatarDefinition, ExpressionKey } from '@bible-strong/avatar-core'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { useStudioLanguage } from '@/i18n'
type PlaybackStatus = 'playing' | 'paused' | 'stopped'
type PreviewTarget =
{ type: 'animation'; key: AnimationKey } | { type: 'expression'; key: ExpressionKey }
function RuntimePreviewContent({
definition: inputDefinition,
initialAnimation,
}: {
definition: AvatarDefinition
initialAnimation?: AnimationKey
}) {
const { t } = useStudioLanguage()
const [definition] = useState(inputDefinition)
const controller = useRef<AvatarController>(null)
const firstAnimation = initialAnimation ?? definition.animationOrder[0]
const [target, setTarget] = useState<PreviewTarget>(
firstAnimation
? { type: 'animation', key: firstAnimation }
: { type: 'expression', key: 'neutral' }
)
const [activeAnimation, setActiveAnimation] = useState<AnimationKey | undefined>(firstAnimation)
const [activeExpression, setActiveExpression] = useState<ExpressionKey>(
firstAnimation
? (definition.animations[firstAnimation]?.steps[0]?.expression ?? 'neutral')
: 'neutral'
)
const [status, setStatus] = useState<PlaybackStatus>(firstAnimation ? 'playing' : 'stopped')
const [error, setError] = useState<AvatarRuntimeError | null>(null)
const reportResult = (result: ReturnType<AvatarController['play']>) => {
if (result.ok) setError(null)
else setError(result.error)
return result.ok
}
const playAnimation = (animation: AnimationKey) => {
if (!controller.current || !reportResult(controller.current.play(animation))) return
setTarget({ type: 'animation', key: animation })
setActiveAnimation(animation)
setStatus('playing')
}
const showExpression = (expression: ExpressionKey) => {
if (!controller.current || !reportResult(controller.current.setExpression(expression))) return
setTarget({ type: 'expression', key: expression })
setActiveAnimation(undefined)
setActiveExpression(expression)
setStatus('stopped')
}
const restart = () => {
if (target.type === 'animation') playAnimation(target.key)
else showExpression(target.key)
}
const pause = () => {
controller.current?.pause()
if (activeAnimation) setStatus('paused')
}
const stop = () => {
controller.current?.stop()
setActiveAnimation(undefined)
setActiveExpression('neutral')
setStatus('stopped')
}
const statusLabel =
status === 'playing' ? t('En lecture') : status === 'paused' ? t('En pause') : t('Arrêté')
return (
<div className="runtime-preview-body">
<section className="runtime-preview-stage-card">
<div className="runtime-preview-stage-heading">
<div>
<small>{t('Définition exportée')}</small>
<strong>{definition.name ?? t('Avatar')}</strong>
</div>
<span className="runtime-preview-status" data-status={status}>
<span aria-hidden="true" />
{statusLabel}
</span>
</div>
<div className="runtime-preview-stage">
<Avatar
ref={controller}
definition={definition}
defaultAnimation={firstAnimation}
defaultExpression={firstAnimation ? undefined : 'neutral'}
size="min(100%, 400px)"
ariaLabel={t('Aperçu runtime de l’avatar actif')}
onAnimationEnd={() => {
setActiveAnimation(undefined)
setStatus('stopped')
}}
onExpressionChange={setActiveExpression}
onError={setError}
/>
</div>
<div className="runtime-preview-current">
<div>
<small>{t('Animation active')}</small>
<strong>{activeAnimation ?? '—'}</strong>
</div>
<div>
<small>{t('Expression active')}</small>
<strong>{activeExpression}</strong>
</div>
</div>
<div className="runtime-preview-transport" aria-label={t('Contrôles de lecture')}>
<Button
type="button"
variant="outline"
disabled={!definition.animationOrder.length}
onClick={() =>
playAnimation(target.type === 'animation' ? target.key : firstAnimation!)
}
>
<Play />
{t('Lire')}
</Button>
<Button type="button" variant="outline" disabled={!activeAnimation} onClick={pause}>
<Pause />
{t('Pause')}
</Button>
<Button type="button" variant="outline" onClick={stop}>
<Square />
{t('Arrêter')}
</Button>
<Button type="button" variant="outline" onClick={restart}>
<RotateCcw />
{t('Relancer')}
</Button>
</div>
{error && (
<p className="runtime-preview-error" role="alert">
{error.message}
</p>
)}
</section>
<aside className="runtime-preview-controls">
<section>
<div className="runtime-preview-control-heading">
<div>
<small>{definition.animationOrder.length}</small>
<h3>{t('Animations exportées')}</h3>
</div>
<span>{t('Clique pour lancer')}</span>
</div>
{definition.animationOrder.length ? (
<div className="runtime-preview-option-grid">
{definition.animationOrder.map(animation => (
<Button
type="button"
variant="outline"
key={animation}
aria-pressed={target.type === 'animation' && target.key === animation}
onClick={() => playAnimation(animation)}
>
<Play />
<span>{definition.animations[animation]?.metadata?.label ?? animation}</span>
</Button>
))}
</div>
) : (
<p className="runtime-preview-empty">{t('Aucune animation exportée')}</p>
)}
</section>
<section>
<div className="runtime-preview-control-heading">
<div>
<small>{definition.expressionOrder.length}</small>
<h3>{t('Expressions exportées')}</h3>
</div>
<span>{t('Clique pour afficher')}</span>
</div>
<div className="runtime-preview-option-grid runtime-preview-expression-grid">
{definition.expressionOrder.map(expression => (
<Button
type="button"
variant="outline"
key={expression}
aria-pressed={target.type === 'expression' && target.key === expression}
onClick={() => showExpression(expression)}
>
<Eye />
<span>{expression}</span>
</Button>
))}
</div>
</section>
</aside>
</div>
)
}
export function RuntimePreviewDialog({
definition,
initialAnimation,
onOpenChange,
open,
}: {
definition: AvatarDefinition | null
initialAnimation?: AnimationKey
onOpenChange: (open: boolean) => void
open: boolean
}) {
const { t } = useStudioLanguage()
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="runtime-preview-dialog">
<DialogHeader>
<div className="avatar-guide-kicker">
<Eye />
<span>{t('Preview')}</span>
</div>
<DialogTitle>{t('Preview de la définition exportée')}</DialogTitle>
<DialogDescription>
{t(
'Teste les animations et expressions réellement présentes dans le fichier .avatar.json.'
)}
</DialogDescription>
<DialogClose
render={
<Button
variant="ghost"
size="icon"
className="runtime-preview-close"
aria-label={t('Fermer la preview')}
>
<X />
</Button>
}
/>
</DialogHeader>
{open && definition && (
<RuntimePreviewContent definition={definition} initialAnimation={initialAnimation} />
)}
</DialogContent>
</Dialog>
)
}

View File

@ -1,6 +1,6 @@
import {
ArrowLeft,
CircleCheck,
BookOpen,
ChevronDown,
ChevronUp,
Copy,
@ -19,11 +19,6 @@ import {
import { AnimatePresence, animate, motion, useMotionValue, useTransform } from 'motion/react'
import { type CSSProperties, useLayoutEffect, useRef, useState } from 'react'
import {
Avatar as RuntimeAvatar,
type AvatarController as RuntimeAvatarController,
} from '@bible-strong/avatar-react'
import { Accordion } from '@/components/ui/accordion'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
@ -59,11 +54,7 @@ import { ColorField, LinkButton, NumericField } from '@/app/components/controls'
import { formatSeconds, scaleSurface, type Side, type SnapshotFormat } from '@/app/studio-utils'
import { SequenceWorkspace } from '@/features/animation/components/SequenceWorkspace'
import { findExpressionIndex, groupSequences } from '@/features/animation/sequences'
import {
defaultAvatarEyes,
defaultPixelRenderStyle,
type AvatarRenderStyle,
} from '@/features/avatar/avatars'
import { defaultAvatarEyes } from '@/features/avatar/avatars'
import { bodyPrimitiveTypes, MAX_BODY_NODES } from '@/features/avatar/body'
import {
ExpressionCard,
@ -75,49 +66,15 @@ import { defaultExpression } from '@/features/avatar/presets'
import { surfaceLabels, surfacePresets } from '@/features/avatar/surfaces'
import { type SnapshotBackground } from '@/features/export/snapshotExporter'
import { AvatarPage } from '@/features/studio/components/AvatarDrawer'
import { RuntimeGuideDialog } from '@/features/studio/components/RuntimeGuideDialog'
import { RuntimePreviewDialog } from '@/features/studio/components/RuntimePreviewDialog'
import { StudioIdentity } from '@/features/studio/components/StudioIdentity'
import type { StudioController } from '@/features/studio/useStudioController'
const runtimeInstallExample =
'npm install @bible-strong/avatar-core @bible-strong/avatar-react react react-dom'
const runtimeReactExample = `import { validateAvatarDefinition } from '@bible-strong/avatar-core'
import { Avatar } from '@bible-strong/avatar-react'
import '@bible-strong/avatar-react/styles.css'
import avatarJson from './strobi.avatar.json'
const avatar = validateAvatarDefinition(avatarJson)
if (!avatar.ok) throw new Error(avatar.errors[0].message)
export function Strobi() {
return <Avatar definition={avatar.value} defaultAnimation="idle" />
}`
const codeTokenPattern =
/(\/\/.*|'.*?'|".*?"|@[a-z0-9-/]+|<\/?[A-Z][A-Za-z]*|\b(?:npm|install|import|from|const|if|throw|new|export|function|return)\b)/g
const highlightedCode = (source: string) =>
source.split(codeTokenPattern).map((token, index) => {
if (!token) return null
const kind = token.startsWith('//')
? 'comment'
: token.startsWith("'") || token.startsWith('"') || token.startsWith('@')
? 'string'
: token.startsWith('<')
? 'tag'
: /^(?:npm|install|import|from|const|if|throw|new|export|function|return)$/.test(token)
? 'keyword'
: 'plain'
return (
<span className={`runtime-token runtime-token-${kind}`} key={`${index}-${token}`}>
{token}
</span>
)
})
export function StudioInspector({ controller }: { controller: StudioController }) {
const [runtimeExampleOpen, setRuntimeExampleOpen] = useState(false)
const runtimeExampleRef = useRef<RuntimeAvatarController>(null)
const [runtimePreviewOpen, setRuntimePreviewOpen] = useState(false)
const [guideOpen, setGuideOpen] = useState(false)
const [exportAnimationsOpen, setExportAnimationsOpen] = useState(false)
const {
activateAvatar,
activeAvatar,
@ -144,7 +101,6 @@ export function StudioInspector({ controller }: { controller: StudioController }
commitAvatarMove,
commitExpressionMove,
commitStateMove,
clearLocalStudioDocument,
copyAvatarRuntimeDefinition,
createNewAvatar,
deleteSelectedBodyNode,
@ -196,7 +152,6 @@ export function StudioInspector({ controller }: { controller: StudioController }
runtimeDefinitionResult,
runtimeCopyStatus,
runtimeExportErrors,
runtimeStandardAvailability,
saveAvatarEditing,
saveEditing,
saveSequenceEditing,
@ -249,7 +204,6 @@ export function StudioInspector({ controller }: { controller: StudioController }
toggleStatePlayback,
transitionToExpression,
updateAvatarColors,
updateAvatarRenderStyle,
updateAvatarEyeDimension,
updateAvatarEyePosition,
updateAvatarEyeSize,
@ -265,8 +219,9 @@ export function StudioInspector({ controller }: { controller: StudioController }
updateWireVisibility,
workspaceBackButtonRef,
} = controller
const pixelRenderStyle =
activeAvatar.renderStyle.type === 'pixel' ? activeAvatar.renderStyle : null
const runtimePreviewAnimation = runtimeDefinitionResult.ok
? runtimeDefinitionResult.value.animationOrder[0]
: undefined
const playbackFooterY = useMotionValue(0)
const playbackHandleY = useMotionValue(0)
const playbackHandleCounterY = useTransform(playbackHandleY, value => -value)
@ -860,58 +815,17 @@ export function StudioInspector({ controller }: { controller: StudioController }
</ControlSection>
<ControlSection
title="Rendu"
subtitle="Choisis la finition visuelle propre à cet avatar."
subtitle="Le rendu Pixel est temporairement désactivé."
>
<InspectorCard className="render-style-panel">
<InspectorCard className="render-style-panel render-style-disabled">
<PanelTitle
level={3}
title="Type de rendu"
subtitle="Pixel utilise une palette franche, sans lissage ni couleur intermédiaire."
subtitle="Le mode Vectoriel est utilisé pour l’instant."
/>
<Field className="render-style-field" orientation="horizontal">
<FieldTitle>{t('Style')}</FieldTitle>
<Select
value={activeAvatar.renderStyle.type}
items={[
{ value: 'vector', label: t('Vectoriel') },
{ value: 'pixel', label: t('Pixel') },
]}
onValueChange={next => {
if (!next) return
const renderStyle: AvatarRenderStyle =
next === 'pixel'
? { ...defaultPixelRenderStyle }
: { type: 'vector' }
updateAvatarRenderStyle(renderStyle)
}}
>
<SelectTrigger aria-label={t('Type de rendu')}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="vector">{t('Vectoriel')}</SelectItem>
<SelectItem value="pixel">{t('Pixel')}</SelectItem>
</SelectContent>
</Select>
</Field>
{pixelRenderStyle && (
<div className="pixel-render-options">
<NumericField
label="Définition de la grille"
value={pixelRenderStyle.resolution}
min={8}
max={192}
step={8}
unit="px"
onChange={resolution =>
updateAvatarRenderStyle({
...pixelRenderStyle,
resolution: Math.round(resolution),
})
}
/>
</div>
)}
<div className="render-style-status">
<Badge variant="secondary">{t('Vectoriel')}</Badge>
</div>
</InspectorCard>
</ControlSection>
<ControlSection
@ -1699,169 +1613,11 @@ export function StudioInspector({ controller }: { controller: StudioController }
)}
{!sequenceEditing && !editing && !bodyEditing && mode === 'export' && (
<Accordion className="export-panel" defaultValue={['runtime']}>
<ExportSection
value="runtime"
title="Exporter le JSON runtime"
subtitle="Exporte le fichier .avatar.json utilisé par les nouveaux packages npm."
badge="Nouveau"
>
<InspectorCard>
<div className="export-avatar-summary">
<ExpressionPreview
expression={expressions[0] ?? defaultExpression}
surface={activeAvatar.body.primary}
bodyNodes={activeAvatar.body.nodes}
colors={activeAvatar.colors}
avatarEyes={activeAvatarEyes}
renderStyle={activeAvatar.renderStyle}
id={`runtime-avatar-${activeAvatar.id}`}
/>
<div>
<small>{t('Définition runtime')}</small>
<strong>{activeAvatar.name}</strong>
<span>{t('Fichier .avatar.json portable')}</span>
</div>
</div>
</InspectorCard>
<InspectorCard
className="runtime-readiness"
data-ready={runtimeDefinitionResult.ok || undefined}
>
<div className="runtime-readiness-heading">
{runtimeDefinitionResult.ok ? <CircleCheck /> : <TriangleAlert />}
<div>
<strong>
{t(
runtimeDefinitionResult.ok
? 'Prêt pour l’export runtime'
: 'Export runtime incomplet'
)}
</strong>
{runtimeDefinitionResult.ok && runtimeStandardAvailability && (
<small>
{runtimeStandardAvailability.available.length}/6{' '}
{t('animations standard disponibles')}
</small>
)}
</div>
</div>
{runtimeExportErrors.length > 0 && (
<>
<ul className="runtime-error-list" role="alert">
{runtimeExportErrors.map((error, index) => (
<li key={`${index}-${error}`}>{error}</li>
))}
</ul>
<Button
type="button"
variant="destructive"
onClick={clearLocalStudioDocument}
>
<Trash2 />
{t('Effacer le projet local et recharger')}
</Button>
</>
)}
</InspectorCard>
<InspectorCard className="runtime-quickstart">
<PanelTitle
title="Démarrage rapide npm"
subtitle="Ces commandes fonctionneront après la publication des packages actuellement privés."
/>
<div className="runtime-code-example">
<small>{t('Installation')}</small>
<pre tabIndex={0}>
<code>{highlightedCode(runtimeInstallExample)}</code>
</pre>
</div>
<div className="runtime-code-example">
<small>{t('Exemple React minimal')}</small>
<pre tabIndex={0}>
<code>{highlightedCode(runtimeReactExample)}</code>
</pre>
</div>
<Button
type="button"
variant="outline"
disabled={!runtimeDefinitionResult.ok}
aria-expanded={runtimeExampleOpen}
onClick={() => setRuntimeExampleOpen(open => !open)}
>
<Play />
{t(runtimeExampleOpen ? 'Masquer l’aperçu' : 'Lancer l’exemple')}
</Button>
{runtimeExampleOpen && runtimeDefinitionResult.ok && (
<div className="runtime-live-example">
<div className="runtime-live-example-heading">
<div>
<small>{t('Aperçu avec le package React')}</small>
<strong>defaultAnimation=&quot;idle&quot;</strong>
</div>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => runtimeExampleRef.current?.play('idle')}
>
<RotateCcw />
{t('Relancer')}
</Button>
</div>
<div className="runtime-live-example-stage">
<RuntimeAvatar
ref={runtimeExampleRef}
definition={runtimeDefinitionResult.value}
defaultAnimation="idle"
size={150}
ariaLabel={t('Aperçu runtime de l’avatar actif')}
/>
</div>
</div>
)}
</InspectorCard>
<div className="runtime-export-actions">
<Button
className="export-download"
type="button"
disabled={!runtimeDefinitionResult.ok}
onClick={downloadAvatarRuntimeDefinition}
>
<Download />
{t('Télécharger la définition .avatar.json')}
</Button>
<Button
type="button"
variant="outline"
disabled={!runtimeDefinitionResult.ok}
onClick={() => void copyAvatarRuntimeDefinition()}
>
<Copy />
{t('Copier le JSON formaté')}
</Button>
</div>
{runtimeCopyStatus !== 'idle' && (
<p
className="runtime-copy-status"
role={runtimeCopyStatus === 'error' ? 'alert' : 'status'}
aria-live={runtimeCopyStatus === 'error' ? 'assertive' : 'polite'}
>
{t(
runtimeCopyStatus === 'success'
? 'JSON runtime copié dans le presse-papiers.'
: 'Impossible de copier le JSON runtime.'
)}
</p>
)}
</ExportSection>
<Accordion className="export-panel" defaultValue={['avatar']}>
<ExportSection
value="avatar"
title="Exporter l’avatar"
subtitle="Génère l’export ZIP autonome React ou JavaScript qui existait déjà."
subtitle="Choisis les animations puis utilise la même définition JSON avec React ou JavaScript."
>
<InspectorCard>
<div className="export-avatar-summary">
@ -1896,7 +1652,7 @@ export function StudioInspector({ controller }: { controller: StudioController }
<FileCode2 />
<span>
<strong>React / TypeScript</strong>
<small>{t('Package React local (.zip)')}</small>
<small>{t('JSON runtime + createAvatar')}</small>
</span>
</Button>
<Button
@ -1907,8 +1663,8 @@ export function StudioInspector({ controller }: { controller: StudioController }
>
<FileCode2 />
<span>
<strong>{t('Module JavaScript')}</strong>
<small>{t('Projet HTML + module JS (.zip)')}</small>
<strong>{t('JavaScript / ESM')}</strong>
<small>{t('JSON runtime + avatar-web')}</small>
</span>
</Button>
</div>
@ -1926,65 +1682,197 @@ export function StudioInspector({ controller }: { controller: StudioController }
variant="ghost"
size="sm"
type="button"
onClick={() =>
setExportAnimationIds(
selectedExportAnimations.length === sequences.length
? []
: sequences.map(animation => animation.id)
)
}
aria-expanded={exportAnimationsOpen}
onClick={() => setExportAnimationsOpen(open => !open)}
>
{t(
selectedExportAnimations.length === sequences.length
? 'Tout désélectionner'
: 'Tout sélectionner'
)}
{exportAnimationsOpen ? <ChevronUp /> : <ChevronDown />}
{t(exportAnimationsOpen ? 'Masquer la sélection' : 'Personnaliser')}
</Button>
</div>
<div className="state-buttons export-animation-grid">
{sequences.map(animation => {
const firstStep = animation.steps[0]
const firstExpression = firstStep
? expressionById.get(firstStep.expressionId)
: undefined
return (
<Button
className="expression-card state-card"
variant="outline"
type="button"
key={animation.id}
aria-pressed={exportAnimationIdSet.has(animation.id)}
onClick={() => toggleExportAnimation(animation.id)}
>
<ExpressionPreview
expression={firstExpression ?? expressions[0] ?? defaultExpression}
surface={surface}
bodyNodes={bodyNodes}
colors={activeAvatar.colors}
avatarEyes={activeAvatarEyes}
renderStyle={activeAvatar.renderStyle}
id={`export-animation-${animation.id}`}
/>
<span>{animation.builtIn ? t(animation.name) : animation.name}</span>
</Button>
)
})}
</div>
{exportAnimationsOpen && (
<div className="export-animation-picker">
<Button
className="export-animation-select-all"
variant="ghost"
size="sm"
type="button"
onClick={() =>
setExportAnimationIds(
selectedExportAnimations.length === sequences.length
? []
: sequences.map(animation => animation.id)
)
}
>
{t(
selectedExportAnimations.length === sequences.length
? 'Tout désélectionner'
: 'Tout sélectionner'
)}
</Button>
<div className="state-buttons export-animation-grid">
{sequences.map(animation => {
const firstStep = animation.steps[0]
const firstExpression = firstStep
? expressionById.get(firstStep.expressionId)
: undefined
return (
<Button
className="expression-card state-card"
variant="outline"
type="button"
key={animation.id}
aria-pressed={exportAnimationIdSet.has(animation.id)}
onClick={() => toggleExportAnimation(animation.id)}
>
<ExpressionPreview
expression={
firstExpression ?? expressions[0] ?? defaultExpression
}
surface={surface}
bodyNodes={bodyNodes}
colors={activeAvatar.colors}
avatarEyes={activeAvatarEyes}
renderStyle={activeAvatar.renderStyle}
id={`export-animation-${animation.id}`}
/>
<span>
{animation.builtIn ? t(animation.name) : animation.name}
</span>
</Button>
)
})}
</div>
</div>
)}
</InspectorCard>
<Button
className="export-download"
type="button"
disabled={!selectedExportAnimations.length}
onClick={downloadAvatarExport}
>
<Download />
{t(
exportFormat === 'react'
? 'Télécharger le package React'
: 'Télécharger le module'
{runtimeExportErrors.length > 0 && (
<InspectorCard>
<div className="runtime-export-error" role="alert">
<div className="runtime-export-error-heading">
<TriangleAlert />
<strong>{t('Export runtime incomplet')}</strong>
</div>
<ul className="runtime-error-list">
{runtimeExportErrors.map((error, index) => (
<li key={`${index}-${error}`}>{error}</li>
))}
</ul>
<p className="runtime-export-error-help">
{t(
'Corrige les clés signalées dans les éditeurs Expressions ou Animations.'
)}
</p>
<div className="runtime-export-error-actions">
<Button
type="button"
variant="outline"
onClick={() => setMode('expressions')}
>
{t('Expressions')}
</Button>
<Button type="button" variant="outline" onClick={() => setMode('states')}>
{t('Animations')}
</Button>
</div>
</div>
</InspectorCard>
)}
<InspectorCard className="runtime-example-card">
<div className="runtime-example-heading">
<div>
<small>{t('Définition prête à tester')}</small>
<strong>
{selectedExportAnimations.length} {t('animations')} ·{' '}
{runtimeDefinitionResult.ok
? runtimeDefinitionResult.value.expressionOrder.length
: 0}{' '}
{t('expressions')}
</strong>
</div>
<div className="runtime-example-actions">
<Button
type="button"
variant="outline"
disabled={!runtimeDefinitionResult.ok}
onClick={() => setRuntimePreviewOpen(true)}
>
<Play />
{t('Preview')}
</Button>
<Button type="button" variant="ghost" onClick={() => setGuideOpen(true)}>
<BookOpen />
{t('Guide d’utilisation')}
</Button>
</div>
</div>
{exportFormat === 'javascript' && (
<p className="runtime-export-description">
{t(
'Le ZIP contient le JSON exporté, une démo index.html et son README. La démo charge avatar-web depuis un CDN.'
)}
</p>
)}
</Button>
</InspectorCard>
<div className="runtime-export-actions">
<Button
className="export-download"
type="button"
disabled={!runtimeDefinitionResult.ok}
onClick={downloadAvatarRuntimeDefinition}
>
<Download />
{t('Télécharger la définition .avatar.json')}
</Button>
<Button
type="button"
variant="outline"
disabled={!runtimeDefinitionResult.ok}
onClick={() => void copyAvatarRuntimeDefinition()}
>
<Copy />
{t('Copier le JSON formaté')}
</Button>
{exportFormat === 'javascript' && (
<Button
type="button"
variant="outline"
disabled={!runtimeDefinitionResult.ok}
onClick={downloadAvatarExport}
>
<Download />
{t('Télécharger l’intégration ESM (.zip)')}
</Button>
)}
</div>
{runtimeCopyStatus !== 'idle' && (
<p
className="runtime-copy-status"
role={runtimeCopyStatus === 'error' ? 'alert' : 'status'}
aria-live={runtimeCopyStatus === 'error' ? 'assertive' : 'polite'}
>
{t(
runtimeCopyStatus === 'success'
? 'JSON runtime copié dans le presse-papiers.'
: 'Impossible de copier le JSON runtime.'
)}
</p>
)}
<RuntimePreviewDialog
definition={runtimeDefinitionResult.ok ? runtimeDefinitionResult.value : null}
initialAnimation={runtimePreviewAnimation}
open={runtimePreviewOpen}
onOpenChange={setRuntimePreviewOpen}
/>
<RuntimeGuideDialog
animationKey={runtimePreviewAnimation}
integration={exportFormat}
open={guideOpen}
onOpenChange={setGuideOpen}
/>
</ExportSection>
<ExportSection

View File

@ -49,7 +49,6 @@ import {
avatarDefinitionFileName,
createAvatarDefinition,
getSemanticKeyIssue,
getStandardAnimationAvailabilityV1,
type SemanticKeyIssueCode,
} from '@/features/avatar/avatarDefinition'
import {
@ -61,7 +60,6 @@ import {
type AvatarBehaviorLibrary,
type AvatarColors,
type AvatarEyeDefaults,
type AvatarRenderStyle,
type StudioAvatar,
} from '@/features/avatar/avatars'
import {
@ -85,12 +83,7 @@ import {
} from '@/features/avatar/geometry'
import { defaultExpression } from '@/features/avatar/presets'
import { type SurfaceConfig } from '@/features/avatar/surfaces'
import {
avatarExportFileName,
createAvatarExportPayload,
generateJavaScriptAvatarPackage,
generateReactAvatarPackage,
} from '@/features/export/exporter'
import { avatarExportFileName, generateJavaScriptEsmPackage } from '@/features/export/exporter'
import {
serializeAvatarSnapshot,
serializePixelSnapshot,
@ -114,7 +107,6 @@ import {
} from '@/features/rendering/renderedScene'
import { paintPixelAvatar } from '@/features/rendering/pixelRenderer'
import {
clearPersistedStudioDocument,
createStudioDocumentStore,
loadStudioDocument,
parseImportedStudioDocument,
@ -156,7 +148,10 @@ export function useStudioController() {
const [snapshotSize, setSnapshotSize] = useState('1024')
const [snapshotFormat, setSnapshotFormat] = useState<SnapshotFormat>('png')
const [photoFlash, setPhotoFlash] = useState(0)
const [runtimeCopyStatus, setRuntimeCopyStatus] = useState<'idle' | 'success' | 'error'>('idle')
const [runtimeCopyFeedback, setRuntimeCopyFeedback] = useState<{
status: 'idle' | 'success' | 'error'
source?: readonly unknown[]
}>({ status: 'idle' })
const initialStatePlayback = initialDocument.playback
const updateStudioLibrary = (library: typeof initialDocument.library) =>
documentStore.update({ library })
@ -758,10 +753,6 @@ export function useStudioController() {
setDisplayColors(resolveColors(expression, colors))
}
const updateAvatarRenderStyle = (renderStyle: AvatarRenderStyle) => {
updateActiveAvatar(avatar => ({ ...avatar, renderStyle }))
}
const updateAvatarEyes = (changes: Partial<AvatarEyeDefaults>) => {
const avatar = avatarsRef.current.find(item => item.id === activeAvatarIdRef.current)
if (!avatar) return
@ -1481,6 +1472,12 @@ export function useStudioController() {
})
}
const activeAvatar = avatars.find(avatar => avatar.id === activeAvatarId) ?? avatars[0]
const runtimeCopySource = [activeAvatar, exportAnimationIds, expressions, sequences] as const
const runtimeCopyStatus =
runtimeCopyFeedback.source?.length === runtimeCopySource.length &&
runtimeCopyFeedback.source.every((value, index) => value === runtimeCopySource[index])
? runtimeCopyFeedback.status
: 'idle'
const activeAvatarEyes = activeAvatar.eyes ?? defaultAvatarEyes
const activeSequence = sequences.find(sequence => sequence.id === activeState) ?? null
const activeSequenceLabel = activeSequence
@ -1523,49 +1520,47 @@ export function useStudioController() {
}
return null
}
const runtimeDefinitionResult = createAvatarDefinition({
avatar: activeAvatar,
behavior: { expressions, sequences },
})
const runtimeExportErrors = runtimeDefinitionResult.ok
? []
: [
...new Set(
runtimeDefinitionResult.errors
.filter(
error =>
error.code !== 'unresolved_expression_reference' ||
!runtimeDefinitionResult.errors.some(candidate =>
candidate.path.startsWith('/studio/expressions/')
)
)
.map(error => {
const expressionMatch = error.path.match(/^\/studio\/expressions\/(\d+)/)
if (expressionMatch) {
const index = Number(expressionMatch[1])
const item = expressions[index]
return `${t('Expression')} ${item?.semanticKey || String(index).padStart(2, '0')}: ${semanticKeyIssueMessage(error.code as SemanticKeyIssueCode | 'duplicate_semantic_key')}`
}
const animationMatch = error.path.match(/^\/studio\/animations\/(\d+)/)
if (animationMatch) {
const index = Number(animationMatch[1])
const item = sequences[index]
if (error.code === 'unresolved_expression_reference') {
return `${t('Animation')} ${item?.name ?? index}: ${t('Une étape référence une expression qui ne peut pas être exportée.')}`
}
return `${t('Animation')} ${item?.semanticKey || item?.name || index}: ${semanticKeyIssueMessage(error.code as SemanticKeyIssueCode | 'duplicate_semantic_key')}`
}
return `${t('Valeur incompatible avec le format runtime')} (${error.path || '/'})`
})
),
]
const runtimeStandardAvailability = runtimeDefinitionResult.ok
? getStandardAnimationAvailabilityV1(runtimeDefinitionResult.value.expressions)
: null
const exportAnimationIdSet = new Set(exportAnimationIds)
const selectedExportAnimations = sequences.filter(animation =>
exportAnimationIdSet.has(animation.id)
)
const runtimeDefinitionResult = createAvatarDefinition({
avatar: activeAvatar,
behavior: { expressions, sequences: selectedExportAnimations },
})
const runtimeExportErrors = runtimeDefinitionResult.ok
? []
: (() => {
const messages = new Set<string>()
const hasExpressionErrors = runtimeDefinitionResult.errors.some(error =>
error.path.startsWith('/studio/expressions/')
)
runtimeDefinitionResult.errors.forEach(error => {
if (error.code === 'unresolved_expression_reference' && hasExpressionErrors) return
const expressionMatch = error.path.match(/^\/studio\/expressions\/(\d+)/)
if (expressionMatch) {
const index = Number(expressionMatch[1])
const item = expressions[index]
messages.add(
`${t('Expression')} ${item?.semanticKey || String(index).padStart(2, '0')}: ${semanticKeyIssueMessage(error.code as SemanticKeyIssueCode | 'duplicate_semantic_key')}`
)
return
}
const animationMatch = error.path.match(/^\/studio\/animations\/(\d+)/)
if (animationMatch) {
const index = Number(animationMatch[1])
const item = selectedExportAnimations[index]
messages.add(
error.code === 'unresolved_expression_reference'
? `${t('Animation')} ${item?.name ?? index}: ${t('Une étape référence une expression qui ne peut pas être exportée.')}`
: `${t('Animation')} ${item?.semanticKey || item?.name || index}: ${semanticKeyIssueMessage(error.code as SemanticKeyIssueCode | 'duplicate_semantic_key')}`
)
return
}
messages.add(`${t('Valeur incompatible avec le format runtime')} (${error.path || '/'})`)
})
return [...messages]
})()
const toggleExportAnimation = (animationId: string) => {
setExportAnimationIds(current =>
current.includes(animationId)
@ -1574,14 +1569,11 @@ export function useStudioController() {
)
}
const downloadAvatarExport = () => {
if (!selectedExportAnimations.length) return
const payload = createAvatarExportPayload(activeAvatar, expressions, selectedExportAnimations)
const isReact = exportFormat === 'react'
const extension = 'zip'
const blob = isReact
? generateReactAvatarPackage(payload)
: generateJavaScriptAvatarPackage(payload, language)
downloadBlob(blob, avatarExportFileName(activeAvatar.name, extension))
if (!runtimeDefinitionResult.ok || exportFormat !== 'javascript') return
downloadBlob(
generateJavaScriptEsmPackage(runtimeDefinitionResult.value, activeAvatar.name),
avatarExportFileName(activeAvatar.name, 'zip')
)
}
const downloadAvatarRuntimeDefinition = () => {
if (!runtimeDefinitionResult.ok) return
@ -1594,16 +1586,16 @@ export function useStudioController() {
}
const copyAvatarRuntimeDefinition = async () => {
if (!runtimeDefinitionResult.ok) return
try {
if (!navigator.clipboard) throw new Error('Clipboard API unavailable')
await navigator.clipboard.writeText(JSON.stringify(runtimeDefinitionResult.value, null, 2))
setRuntimeCopyStatus('success')
} catch {
setRuntimeCopyStatus('error')
if (!navigator.clipboard) {
setRuntimeCopyFeedback({ status: 'error', source: runtimeCopySource })
return
}
try {
await navigator.clipboard.writeText(JSON.stringify(runtimeDefinitionResult.value, null, 2))
setRuntimeCopyFeedback({ status: 'success', source: runtimeCopySource })
} catch {
setRuntimeCopyFeedback({ status: 'error', source: runtimeCopySource })
}
}
const clearLocalStudioDocument = () => {
if (clearPersistedStudioDocument()) window.location.reload()
}
const currentStudioDocument = (): StudioDocument => ({
version: 2,
@ -1878,7 +1870,6 @@ export function useStudioController() {
commitBodyNode,
commitExpressionMove,
commitStateMove,
clearLocalStudioDocument,
confirmStudioProjectImport,
copyAvatarRuntimeDefinition,
createNewAvatar,
@ -1945,7 +1936,6 @@ export function useStudioController() {
runtimeDefinitionResult,
runtimeCopyStatus,
runtimeExportErrors,
runtimeStandardAvailability,
saveAvatarEditing,
saveEditing,
saveSequenceEditing,
@ -2003,7 +1993,6 @@ export function useStudioController() {
toggleStatePlayback,
transitionToExpression,
updateAvatarColors,
updateAvatarRenderStyle,
updateAvatarEyeDimension,
updateAvatarEyePosition,
updateAvatarEyeSize,

View File

@ -37,12 +37,72 @@ describe('avatar studio translations', () => {
expect(translateStudioText('Clé sémantique', 'zh-CN')).toBe('语义键')
expect(translateStudioText('Exporter le JSON runtime', 'en')).toBe('Export runtime JSON')
expect(translateStudioText('Exporter le JSON runtime', 'zh-CN')).toBe('导出运行时 JSON')
expect(translateStudioText('JSON runtime + createAvatar', 'en')).toBe(
'Runtime JSON + createAvatar'
)
expect(translateStudioText('JSON runtime + createAvatar', 'zh-CN')).toBe(
'运行时 JSON + createAvatar'
)
expect(translateStudioText('JavaScript / ESM', 'en')).toBe('JavaScript / ESM')
expect(translateStudioText('JSON runtime + avatar-web', 'zh-CN')).toBe(
'运行时 JSON + avatar-web'
)
expect(translateStudioText('Guide d’utilisation de l’avatar JavaScript', 'en')).toBe(
'JavaScript avatar usage guide'
)
expect(translateStudioText('Preview de la définition exportée', 'en')).toBe(
'Exported definition preview'
)
expect(translateStudioText('Animations exportées', 'zh-CN')).toBe('导出的动画')
expect(
translateStudioText(
'Choisis les animations puis exporte le JSON runtime ou un package autonome.',
'en'
)
).toBe('Choose animations, then export runtime JSON or a standalone package.')
expect(
translateStudioText(
'Choisis les animations puis exporte le JSON runtime ou un package autonome.',
'zh-CN'
)
).toBe('选择动画,然后导出运行时 JSON 或独立包。')
expect(translateStudioText('Nouveau', 'en')).toBe('New')
expect(translateStudioText('Nouveau', 'zh-CN')).toBe('新增')
expect(translateStudioText('Démarrage rapide npm', 'en')).toBe('npm quick start')
expect(translateStudioText('Démarrage rapide npm', 'zh-CN')).toBe('npm 快速开始')
expect(translateStudioText('Lancer l’exemple', 'en')).toBe('Run example')
expect(translateStudioText('Lancer l’exemple', 'zh-CN')).toBe('运行示例')
expect(translateStudioText('Guide d’utilisation', 'en')).toBe('Usage guide')
expect(translateStudioText('Guide d’utilisation', 'zh-CN')).toBe('使用指南')
expect(translateStudioText('Le rendu Pixel est temporairement désactivé.', 'en')).toBe(
'Pixel rendering is temporarily disabled.'
)
expect(translateStudioText('Le rendu Pixel est temporairement désactivé.', 'zh-CN')).toBe(
'像素渲染暂时已禁用。'
)
expect(translateStudioText('Props de l’avatar', 'en')).toBe('Avatar props')
expect(translateStudioText('Props de l’avatar', 'zh-CN')).toBe('头像 Props')
expect(translateStudioText('Cible et lecture', 'en')).toBe('Target and playback')
expect(translateStudioText('Présentation', 'zh-CN')).toBe('外观')
expect(translateStudioText('API impérative', 'en')).toBe('Imperative API')
expect(translateStudioText('API impérative', 'zh-CN')).toBe('命令式 API')
expect(
translateStudioText('Retourne l’animation, l’expression et le statut actifs.', 'en')
).toBe('Returns the active animation, expression and status.')
expect(
translateStudioText(
'Utilise Avatar directement lorsque la définition est chargée à l’exécution ou change entre plusieurs avatars.',
'en'
)
).toBe(
'Use Avatar directly when the definition is loaded at runtime or changes between multiple avatars.'
)
expect(
translateStudioText(
'createAvatar valide le JSON et retourne un composant dédié dont les clés d’animations sont typées.',
'en'
)
).toBe(
'createAvatar validates the JSON and returns a dedicated component with typed animation keys.'
)
expect(
translateStudioText(
'Exporte le fichier .avatar.json utilisé par les nouveaux packages npm.',
@ -59,12 +119,12 @@ describe('avatar studio translations', () => {
'Runtime export is incomplete'
)
expect(translateStudioText('Export runtime incomplet', 'zh-CN')).toBe('运行时导出不完整')
expect(translateStudioText('Effacer le projet local et recharger', 'en')).toBe(
'Clear local project and reload'
)
expect(translateStudioText('Effacer le projet local et recharger', 'zh-CN')).toBe(
'清除本地项目并重新加载'
)
expect(translateStudioText('Personnaliser', 'en')).toBe('Customize')
expect(translateStudioText('Personnaliser', 'zh-CN')).toBe('自定义')
expect(translateStudioText('Masquer la sélection', 'en')).toBe('Hide selection')
expect(translateStudioText('Masquer la sélection', 'zh-CN')).toBe('隐藏选择')
expect(translateStudioText('Expression de départ', 'en')).toBe('Starting expression')
expect(translateStudioText('Expression de départ', 'zh-CN')).toBe('起始表情')
expect(translateStudioText('Copier le JSON formaté', 'en')).toBe('Copy formatted JSON')
expect(translateStudioText('Copier le JSON formaté', 'zh-CN')).toBe('复制格式化的 JSON')
expect(translateStudioText('JSON runtime copié dans le presse-papiers.', 'en')).toBe(

View File

@ -85,6 +85,8 @@ const english: Record<string, string> = {
'Choisis la finition visuelle propre à cet avatar.':
'Choose the visual finish specific to this avatar.',
'Type de rendu': 'Rendering type',
'Le rendu Pixel est temporairement désactivé.': 'Pixel rendering is temporarily disabled.',
'Le mode Vectoriel est utilisé pour l’instant.': 'Vector rendering is currently used.',
'Pixel utilise une palette franche, sans lissage ni couleur intermédiaire.':
'Pixel uses a hard palette, with no smoothing or intermediate colors.',
Vectoriel: 'Vector',
@ -129,27 +131,145 @@ const english: Record<string, string> = {
Nouveau: 'New',
'Exporte le fichier .avatar.json utilisé par les nouveaux packages npm.':
'Export the .avatar.json file used by the new npm packages.',
'JSON runtime + createAvatar': 'Runtime JSON + createAvatar',
'Télécharge la définition portable complète de l’avatar actif.':
'Download the complete portable definition for the active avatar.',
'Définition runtime': 'Runtime definition',
'Fichier .avatar.json portable': 'Portable .avatar.json file',
'Prêt pour l’export runtime': 'Ready for runtime export',
'Export runtime incomplet': 'Runtime export is incomplete',
'Effacer le projet local et recharger': 'Clear local project and reload',
'animations standard disponibles': 'standard animations available',
'Corrige les clés signalées dans les éditeurs Expressions ou Animations.':
'Fix the highlighted keys in the Expressions or Animations editors.',
'Télécharger la définition .avatar.json': 'Download .avatar.json definition',
'Copier le JSON formaté': 'Copy formatted JSON',
'JSON runtime copié dans le presse-papiers.': 'Runtime JSON copied to the clipboard.',
'Impossible de copier le JSON runtime.': 'Could not copy the runtime JSON.',
'Démarrage rapide npm': 'npm quick start',
'Ces commandes fonctionneront après la publication des packages actuellement privés.':
'These commands will work after the currently private packages are published.',
Installation: 'Installation',
'Exemple React minimal': 'Minimal React example',
'Lancer l’exemple': 'Run example',
'Masquer l’aperçu': 'Hide preview',
'Aperçu avec le package React': 'Preview using the React package',
Preview: 'Preview',
'Définition prête à tester': 'Definition ready to test',
'Preview de la définition exportée': 'Exported definition preview',
'Teste les animations et expressions réellement présentes dans le fichier .avatar.json.':
'Test the animations and expressions actually included in the .avatar.json file.',
'Fermer la preview': 'Close preview',
'Définition exportée': 'Exported definition',
'Animation active': 'Active animation',
'Expression active': 'Active expression',
'Contrôles de lecture': 'Playback controls',
'Animations exportées': 'Exported animations',
'Expressions exportées': 'Exported expressions',
'Clique pour lancer': 'Click to play',
'Clique pour afficher': 'Click to display',
'Aucune animation exportée': 'No exported animation',
'Aperçu runtime de l’avatar actif': 'Runtime preview of the active avatar',
'Animation de départ': 'Starting animation',
'Expression de départ': 'Starting expression',
'Aucune animation sélectionnée': 'No animation selected',
'Guide d’utilisation': 'Usage guide',
'Guide d’utilisation de l’avatar React': 'React avatar usage guide',
'Guide d’utilisation de l’avatar JavaScript': 'JavaScript avatar usage guide',
'Installe le package, crée ton composant et choisis le niveau de contrôle adapté.':
'Install the package, create your component and choose the right control level.',
'Fermer le guide': 'Close guide',
'Ajoute le package React et ses dépendances.': 'Add the React package and its dependencies.',
'Installe le module ESM, charge la définition JSON et monte l’avatar dans un élément du DOM.':
'Install the ESM module, load the JSON definition and mount the avatar in a DOM element.',
'Ajoute le renderer DOM, qui utilise automatiquement avatar-core.':
'Add the DOM renderer, which automatically uses avatar-core.',
'Utilisation avec un bundler ESM': 'Using an ESM bundler',
'Vite et les bundlers modernes résolvent le package et importent le même fichier .avatar.json que React.':
'Vite and modern bundlers resolve the package and import the same .avatar.json file as React.',
'Options de createAvatar': 'createAvatar options',
'Référence des valeurs acceptées lors du montage dans le DOM.':
'Reference for values accepted when mounting into the DOM.',
'Obligatoire. Définition JSON validée avant la création des éléments SVG.':
'Required. JSON definition validated before the SVG elements are created.',
'Optionnelle. Animation lancée au montage lorsque autoplay vaut true. Mutuellement exclusive avec defaultExpression.':
'Optional. Animation started on mount when autoplay is true. Mutually exclusive with defaultExpression.',
'Optionnelle. Expression initiale affichée sans lancer de timeline. Mutuellement exclusive avec defaultAnimation.':
'Optional. Initial expression displayed without starting a timeline. Mutually exclusive with defaultAnimation.',
'Optionnelle, défaut true. Contrôle uniquement le lancement automatique de defaultAnimation.':
'Optional, defaults to true. Only controls whether defaultAnimation starts automatically.',
'Optionnelle, défaut 240. Largeur et hauteur CSS du conteneur rendu.':
'Optional, defaults to 240. CSS width and height of the rendered container.',
'Optionnelle. Classe CSS ajoutée au conteneur rendu.':
'Optional. CSS class added to the rendered container.',
'Optionnelle, défaut « Procedural avatar ». Nom accessible du rendu.':
'Optional, defaults to “Procedural avatar”. Accessible name for the rendered avatar.',
'Optionnelle. Reçoit les erreurs de clé inconnue utilisées lors de l’initialisation.':
'Optional. Receives unknown-key errors encountered during initialization.',
'Optionnelle. Appelée lorsqu’une animation once se termine.':
'Optional. Called when a once animation completes.',
'Optionnelle. Appelée lorsque l’expression active change.':
'Optional. Called when the active expression changes.',
'API du contrôleur DOM': 'DOM controller API',
'createAvatar retourne immédiatement ces commandes impératives.':
'createAvatar immediately returns these imperative commands.',
'Lance ou reprend une animation par sa clé.': 'Starts or resumes an animation by key.',
'Affiche une expression avec une transition courte.':
'Displays an expression with a short transition.',
'Arrête la lecture et revient à neutral.': 'Stops playback and returns to neutral.',
'Annule la frame planifiée et retire uniquement le conteneur créé par avatar-web.':
'Cancels the scheduled frame and removes only the container created by avatar-web.',
'Navigateur sans bundler': 'Browser without a bundler',
'Utilise une URL ESM via un CDN ou une import map, puis charge la définition avec fetch.':
'Use an ESM URL through a CDN or import map, then load the definition with fetch.',
'Les packages sont encore privés. Cette commande fonctionnera après leur publication ; utilise le workspace ou les tarballs pour les tests locaux.':
'The packages are still private. This command will work after publication; use the workspace or tarballs for local testing.',
'API recommandée : créer un avatar concret': 'Recommended API: create a concrete avatar',
'createAvatar valide le JSON et retourne un composant dédié dont les clés d’animations sont typées.':
'createAvatar validates the JSON and returns a dedicated component with typed animation keys.',
'Props de l’avatar': 'Avatar props',
'Référence complète : type, valeur par défaut, comportement et contraintes de chaque prop.':
'Complete reference: type, default value, behavior and constraints for every prop.',
'Cible et lecture': 'Target and playback',
'Obligatoire. Objet AvatarDefinition validé contenant les expressions et les animations à afficher.':
'Required. Validated AvatarDefinition object containing the expressions and animations to display.',
'Optionnelle. Contrôle une timeline par sa clé. Chaque étape choisit l’expression affichée. Mutuellement exclusive avec expression ; une cible contrôlée prend priorité sur les valeurs default.':
'Optional. Controls a timeline by key. Each step chooses the displayed expression. Mutually exclusive with expression; a controlled target takes priority over default values.',
'Optionnelle. Contrôle directement une expression par sa clé. Mutuellement exclusive avec animation ; une cible contrôlée prend priorité sur les valeurs default.':
'Optional. Directly controls an expression by key. Mutually exclusive with animation; a controlled target takes priority over default values.',
'Optionnelle. Définit la timeline initiale en mode non contrôlé. Lue au montage ; autoplay est activé par défaut. Mutuellement exclusive avec defaultExpression.':
'Optional. Defines the initial timeline in uncontrolled mode. Read on mount; autoplay is enabled by default. Mutually exclusive with defaultExpression.',
'Optionnelle. Définit l’expression initiale en mode non contrôlé. Lue au montage, sans lancer de timeline. Mutuellement exclusive avec defaultAnimation.':
'Optional. Defines the initial expression in uncontrolled mode. Read on mount without starting a timeline. Mutually exclusive with defaultAnimation.',
'Optionnelle, défaut true. Lance automatiquement defaultAnimation ; sans defaultAnimation, elle n’a aucun effet.':
'Optional, defaults to true. Automatically starts defaultAnimation; without defaultAnimation, it has no effect.',
'Optionnelle. Donne accès à l’API impérative AvatarController.':
'Optional. Provides access to the imperative AvatarController API.',
Présentation: 'Presentation',
'Optionnelle, défaut 240. Nombre ou valeur CSS utilisée pour la largeur et la hauteur du conteneur.':
'Optional, defaults to 240. Number or CSS value used for the container width and height.',
'Optionnelle. Classe CSS ajoutée au conteneur externe.':
'Optional. CSS class added to the outer container.',
'Optionnelle. Styles inline du conteneur externe ; width et height viennent de size.':
'Optional. Inline styles for the outer container; width and height come from size.',
'Optionnelle, défaut « Procedural avatar ». Nom accessible annoncé aux lecteurs d’écran.':
'Optional, defaults to “Procedural avatar”. Accessible name announced to screen readers.',
'Callbacks de lecture': 'Playback callbacks',
'Optionnelle. Reçoit la clé de l’animation once terminée naturellement.':
'Optional. Receives the key of a once animation when it completes naturally.',
'Optionnelle. Reçoit la clé de l’expression chaque fois que l’expression sémantique affichée change.':
'Optional. Receives the expression key whenever the displayed semantic expression changes.',
'Optionnelle. Reçoit une erreur typée lorsqu’une prop animation, expression ou default référence une clé inconnue.':
'Optional. Receives a typed error when an animation, expression or default prop references an unknown key.',
'Avatar générique': 'Generic Avatar',
'Utilise Avatar directement lorsque la définition est chargée à l’exécution ou change entre plusieurs avatars.':
'Use Avatar directly when the definition is loaded at runtime or changes between multiple avatars.',
'API impérative': 'Imperative API',
'La ref expose les commandes de lecture et l’état courant de l’avatar.':
'The ref exposes playback commands and the avatar’s current state.',
'Les commandes de cible sont disponibles en mode non contrôlé ; sinon utilise les props.':
'Target commands are available in uncontrolled mode; otherwise use props.',
'Lance ou reprend une animation et retourne un résultat typé.':
'Starts or resumes an animation and returns a typed result.',
'Met en pause la timeline à sa position exacte.': 'Pauses the timeline at its exact position.',
'En mode non contrôlé, arrête la lecture et revient à neutral. En mode contrôlé, les props restent la source de vérité.':
'In uncontrolled mode, stops playback and returns to neutral. In controlled mode, props remain the source of truth.',
'Affiche directement une expression.': 'Directly displays an expression.',
'Retourne l’animation, l’expression et le statut actifs.':
'Returns the active animation, expression and status.',
Expression: 'Expression',
Animation: 'Animation',
'Une étape référence une expression qui ne peut pas être exportée.':
@ -159,22 +279,33 @@ const english: Record<string, string> = {
'Download a standalone component with the animations you choose.',
'Génère l’export ZIP autonome React ou JavaScript qui existait déjà.':
'Generate the existing standalone React or JavaScript ZIP export.',
'Choisis les animations puis exporte le JSON runtime ou un package autonome.':
'Choose animations, then export runtime JSON or a standalone package.',
'Choisis les animations puis utilise la même définition JSON avec React ou JavaScript.':
'Choose animations, then use the same JSON definition with React or JavaScript.',
'Avatar sélectionné': 'Selected avatar',
Format: 'Format',
'Choisis l’intégration correspondant à ton projet.':
'Choose the integration that matches your project.',
'Composant TSX autonome': 'Standalone TSX component',
'Package React local (.zip)': 'Local React package (.zip)',
'Module JavaScript': 'JavaScript module',
'JavaScript / ESM': 'JavaScript / ESM',
'JSON runtime + avatar-web': 'Runtime JSON + avatar-web',
'Module ES autonome': 'Standalone ES module',
'Projet HTML + module JS (.zip)': 'HTML project + JS module (.zip)',
sélectionnées: 'selected',
'Animations à exporter': 'Animations to export',
'Tout sélectionner': 'Select all',
'Tout désélectionner': 'Deselect all',
Personnaliser: 'Customize',
'Masquer la sélection': 'Hide selection',
'Télécharger le composant TSX': 'Download TSX component',
'Télécharger le package React': 'Download React package',
'Télécharger le module': 'Download module',
'Intégration ESM avec le package avatar-web': 'ESM integration with the avatar-web package',
'Le ZIP contient le JSON exporté, une démo index.html et son README. La démo charge avatar-web depuis un CDN.':
'The ZIP contains the exported JSON, an index.html demo and its README. The demo loads avatar-web from a CDN.',
'Télécharger l’intégration ESM (.zip)': 'Download ESM integration (.zip)',
Snapshot: 'Snapshot',
'Mode photo': 'Photo Mode',
'Capture une image statique de l’avatar.': 'Capture a static image of the avatar.',

View File

@ -54,6 +54,8 @@ export const chinese: Record<string, string> = {
Rendu: '渲染',
'Choisis la finition visuelle propre à cet avatar.': '选择此头像专属的视觉效果。',
'Type de rendu': '渲染类型',
'Le rendu Pixel est temporairement désactivé.': '像素渲染暂时已禁用。',
'Le mode Vectoriel est utilisé pour l’instant.': '当前使用矢量渲染。',
'Pixel utilise une palette franche, sans lissage ni couleur intermédiaire.':
'像素渲染使用纯色调色板,不进行平滑处理,也不产生中间色。',
Vectoriel: '矢量',
@ -99,26 +101,137 @@ export const chinese: Record<string, string> = {
Nouveau: '新增',
'Exporte le fichier .avatar.json utilisé par les nouveaux packages npm.':
'导出新 npm 包使用的 .avatar.json 文件。',
'JSON runtime + createAvatar': '运行时 JSON + createAvatar',
'Télécharge la définition portable complète de l’avatar actif.': '下载当前头像的完整可移植定义。',
'Définition runtime': '运行时定义',
'Fichier .avatar.json portable': '可移植 .avatar.json 文件',
'Prêt pour l’export runtime': '可以导出运行时定义',
'Export runtime incomplet': '运行时导出不完整',
'Effacer le projet local et recharger': '清除本地项目并重新加载',
'animations standard disponibles': '个标准动画可用',
'Corrige les clés signalées dans les éditeurs Expressions ou Animations.':
'请在“表情”或“动画”编辑器中修正标记的键。',
'Télécharger la définition .avatar.json': '下载 .avatar.json 定义',
'Copier le JSON formaté': '复制格式化的 JSON',
'JSON runtime copié dans le presse-papiers.': '运行时 JSON 已复制到剪贴板。',
'Impossible de copier le JSON runtime.': '无法复制运行时 JSON。',
'Démarrage rapide npm': 'npm 快速开始',
'Ces commandes fonctionneront après la publication des packages actuellement privés.':
'这些命令将在当前私有包发布后可用。',
Installation: '安装',
'Exemple React minimal': '最简 React 示例',
'Lancer l’exemple': '运行示例',
'Masquer l’aperçu': '隐藏预览',
'Aperçu avec le package React': '使用 React 包预览',
Preview: '预览',
'Définition prête à tester': '定义已可测试',
'Preview de la définition exportée': '导出定义预览',
'Teste les animations et expressions réellement présentes dans le fichier .avatar.json.':
'测试 .avatar.json 文件中实际包含的动画和表情。',
'Fermer la preview': '关闭预览',
'Définition exportée': '导出的定义',
'Animation active': '当前动画',
'Expression active': '当前表情',
'Contrôles de lecture': '播放控制',
'Animations exportées': '导出的动画',
'Expressions exportées': '导出的表情',
'Clique pour lancer': '点击播放',
'Clique pour afficher': '点击显示',
'Aucune animation exportée': '没有导出的动画',
'Aperçu runtime de l’avatar actif': '当前头像的运行时预览',
'Animation de départ': '起始动画',
'Expression de départ': '起始表情',
'Aucune animation sélectionnée': '未选择动画',
'Guide d’utilisation': '使用指南',
'Guide d’utilisation de l’avatar React': 'React 头像使用指南',
'Guide d’utilisation de l’avatar JavaScript': 'JavaScript 头像使用指南',
'Installe le package, crée ton composant et choisis le niveau de contrôle adapté.':
'安装包,创建组件,并选择合适的控制方式。',
'Fermer le guide': '关闭指南',
'Ajoute le package React et ses dépendances.': '添加 React 包及其依赖项。',
'Installe le module ESM, charge la définition JSON et monte l’avatar dans un élément du DOM.':
'安装 ESM 模块,加载 JSON 定义,并将头像挂载到 DOM 元素中。',
'Ajoute le renderer DOM, qui utilise automatiquement avatar-core.':
'添加 DOM 渲染器,它会自动使用 avatar-core。',
'Utilisation avec un bundler ESM': '使用 ESM 打包工具',
'Vite et les bundlers modernes résolvent le package et importent le même fichier .avatar.json que React.':
'Vite 和现代打包工具会解析该包,并导入与 React 相同的 .avatar.json 文件。',
'Options de createAvatar': 'createAvatar 选项',
'Référence des valeurs acceptées lors du montage dans le DOM.': '挂载到 DOM 时可用值的参考。',
'Obligatoire. Définition JSON validée avant la création des éléments SVG.':
'必填。创建 SVG 元素之前会验证 JSON 定义。',
'Optionnelle. Animation lancée au montage lorsque autoplay vaut true. Mutuellement exclusive avec defaultExpression.':
'可选。当 autoplay 为 true 时在挂载时启动动画。不能与 defaultExpression 同时使用。',
'Optionnelle. Expression initiale affichée sans lancer de timeline. Mutuellement exclusive avec defaultAnimation.':
'可选。显示初始表情但不启动时间线。不能与 defaultAnimation 同时使用。',
'Optionnelle, défaut true. Contrôle uniquement le lancement automatique de defaultAnimation.':
'可选,默认 true。仅控制 defaultAnimation 是否自动启动。',
'Optionnelle, défaut 240. Largeur et hauteur CSS du conteneur rendu.':
'可选,默认 240。渲染容器的 CSS 宽度和高度。',
'Optionnelle. Classe CSS ajoutée au conteneur rendu.': '可选。添加到渲染容器的 CSS 类。',
'Optionnelle, défaut « Procedural avatar ». Nom accessible du rendu.':
'可选,默认 “Procedural avatar”。渲染头像的可访问名称。',
'Optionnelle. Reçoit les erreurs de clé inconnue utilisées lors de l’initialisation.':
'可选。接收初始化期间遇到的未知键错误。',
'Optionnelle. Appelée lorsqu’une animation once se termine.': '可选。在 once 动画结束时调用。',
'Optionnelle. Appelée lorsque l’expression active change.': '可选。在活动表情变化时调用。',
'API du contrôleur DOM': 'DOM 控制器 API',
'createAvatar retourne immédiatement ces commandes impératives.':
'createAvatar 会立即返回这些命令式控制方法。',
'Lance ou reprend une animation par sa clé.': '通过键启动或恢复动画。',
'Affiche une expression avec une transition courte.': '使用短暂过渡显示表情。',
'Arrête la lecture et revient à neutral.': '停止播放并返回 neutral。',
'Annule la frame planifiée et retire uniquement le conteneur créé par avatar-web.':
'取消已计划的帧,并仅移除 avatar-web 创建的容器。',
'Navigateur sans bundler': '不使用打包工具的浏览器',
'Utilise une URL ESM via un CDN ou une import map, puis charge la définition avec fetch.':
'通过 CDN 或 import map 使用 ESM URL,然后通过 fetch 加载定义。',
'Les packages sont encore privés. Cette commande fonctionnera après leur publication ; utilise le workspace ou les tarballs pour les tests locaux.':
'这些包目前仍为私有。发布后此命令才可使用;本地测试请使用 workspace 或 tarball。',
'API recommandée : créer un avatar concret': '推荐 API:创建具体头像',
'createAvatar valide le JSON et retourne un composant dédié dont les clés d’animations sont typées.':
'createAvatar 会验证 JSON,并返回一个动画键经过类型约束的专用组件。',
'Props de l’avatar': '头像 Props',
'Référence complète : type, valeur par défaut, comportement et contraintes de chaque prop.':
'完整参考:每个 prop 的类型、默认值、行为和限制。',
'Cible et lecture': '目标与播放',
'Obligatoire. Objet AvatarDefinition validé contenant les expressions et les animations à afficher.':
'必填。经过验证的 AvatarDefinition 对象,包含要显示的表情和动画。',
'Optionnelle. Contrôle une timeline par sa clé. Chaque étape choisit l’expression affichée. Mutuellement exclusive avec expression ; une cible contrôlée prend priorité sur les valeurs default.':
'可选。通过键控制时间线,每个步骤会选择要显示的表情。不能与 expression 同时使用;受控目标优先于默认值。',
'Optionnelle. Contrôle directement une expression par sa clé. Mutuellement exclusive avec animation ; une cible contrôlée prend priorité sur les valeurs default.':
'可选。通过键直接控制一个表情。不能与 animation 同时使用;受控目标优先于默认值。',
'Optionnelle. Définit la timeline initiale en mode non contrôlé. Lue au montage ; autoplay est activé par défaut. Mutuellement exclusive avec defaultExpression.':
'可选。定义非受控模式下的初始时间线;挂载时读取,默认启用 autoplay。不能与 defaultExpression 同时使用。',
'Optionnelle. Définit l’expression initiale en mode non contrôlé. Lue au montage, sans lancer de timeline. Mutuellement exclusive avec defaultAnimation.':
'可选。定义非受控模式下的初始表情;挂载时读取,不会启动时间线。不能与 defaultAnimation 同时使用。',
'Optionnelle, défaut true. Lance automatiquement defaultAnimation ; sans defaultAnimation, elle n’a aucun effet.':
'可选,默认 true。自动启动 defaultAnimation;没有 defaultAnimation 时无效。',
'Optionnelle. Donne accès à l’API impérative AvatarController.':
'可选。提供对命令式 AvatarController API 的访问。',
Présentation: '外观',
'Optionnelle, défaut 240. Nombre ou valeur CSS utilisée pour la largeur et la hauteur du conteneur.':
'可选,默认 240。用于容器宽高的数字或 CSS 值。',
'Optionnelle. Classe CSS ajoutée au conteneur externe.': '可选。添加到外部容器的 CSS 类。',
'Optionnelle. Styles inline du conteneur externe ; width et height viennent de size.':
'可选。外部容器的内联样式;width 和 height 来自 size。',
'Optionnelle, défaut « Procedural avatar ». Nom accessible annoncé aux lecteurs d’écran.':
'可选,默认 “Procedural avatar”。屏幕阅读器播报的可访问名称。',
'Callbacks de lecture': '播放回调',
'Optionnelle. Reçoit la clé de l’animation once terminée naturellement.':
'可选。在 once 动画自然完成时接收其键。',
'Optionnelle. Reçoit la clé de l’expression chaque fois que l’expression sémantique affichée change.':
'可选。每当显示的语义表情变化时接收表情键。',
'Optionnelle. Reçoit une erreur typée lorsqu’une prop animation, expression ou default référence une clé inconnue.':
'可选。当 animation、expression 或 default prop 引用了未知键时,接收一个类型化错误。',
'Avatar générique': '通用 Avatar',
'Utilise Avatar directement lorsque la définition est chargée à l’exécution ou change entre plusieurs avatars.':
'当定义在运行时加载或需要在多个头像之间切换时,直接使用 Avatar。',
'API impérative': '命令式 API',
'La ref expose les commandes de lecture et l’état courant de l’avatar.':
'ref 提供播放命令和头像当前状态。',
'Les commandes de cible sont disponibles en mode non contrôlé ; sinon utilise les props.':
'目标命令仅在非受控模式下可用;否则请使用 props。',
'Lance ou reprend une animation et retourne un résultat typé.':
'启动或恢复动画,并返回类型化结果。',
'Met en pause la timeline à sa position exacte.': '在精确位置暂停时间线。',
'En mode non contrôlé, arrête la lecture et revient à neutral. En mode contrôlé, les props restent la source de vérité.':
'在非受控模式下停止播放并回到 neutral;在受控模式下,props 仍是真实来源。',
'Affiche directement une expression.': '直接显示一个表情。',
'Retourne l’animation, l’expression et le statut actifs.': '返回当前动画、表情和状态。',
Expression: '表情',
Animation: '动画',
'Une étape référence une expression qui ne peut pas être exportée.':
@ -128,21 +241,32 @@ export const chinese: Record<string, string> = {
'下载包含所选动画的独立组件。',
'Génère l’export ZIP autonome React ou JavaScript qui existait déjà.':
'生成原有的 React 或 JavaScript 独立 ZIP 导出。',
'Choisis les animations puis exporte le JSON runtime ou un package autonome.':
'选择动画,然后导出运行时 JSON 或独立包。',
'Choisis les animations puis utilise la même définition JSON avec React ou JavaScript.':
'选择动画,然后在 React 或 JavaScript 中使用同一份 JSON 定义。',
'Avatar sélectionné': '已选头像',
Format: '格式',
'Choisis l’intégration correspondant à ton projet.': '选择适合项目的集成方式。',
'Composant TSX autonome': '独立 TSX 组件',
'Package React local (.zip)': '本地 React 包(.zip)',
'Module JavaScript': 'JavaScript 模块',
'JavaScript / ESM': 'JavaScript / ESM',
'JSON runtime + avatar-web': '运行时 JSON + avatar-web',
'Module ES autonome': '独立 ES 模块',
'Projet HTML + module JS (.zip)': 'HTML 项目 + JS 模块(.zip)',
sélectionnées: '已选择',
'Animations à exporter': '要导出的动画',
'Tout sélectionner': '全选',
'Tout désélectionner': '取消全选',
Personnaliser: '自定义',
'Masquer la sélection': '隐藏选择',
'Télécharger le composant TSX': '下载 TSX 组件',
'Télécharger le package React': '下载 React 包',
'Télécharger le module': '下载模块',
'Intégration ESM avec le package avatar-web': '使用 avatar-web 包的 ESM 集成',
'Le ZIP contient le JSON exporté, une démo index.html et son README. La démo charge avatar-web depuis un CDN.':
'ZIP 包含导出的 JSON、index.html 演示和 README。演示会从 CDN 加载 avatar-web。',
'Télécharger l’intégration ESM (.zip)': '下载 ESM 集成(.zip)',
Snapshot: '快照',
'Mode photo': '照片模式',
'Capture une image statique de l’avatar.': '捕获头像的静态图像。',