From 2386b7881711bd3e42fc2ce2f1cbbbb0187d3624 Mon Sep 17 00:00:00 2001 From: smontlouis Date: Mon, 17 Aug 2026 17:27:20 +0200 Subject: [PATCH] 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. --- README.md | 18 +- examples/react-vite-consumer/src/main.tsx | 25 +- .../src/strobi.avatar.json | 3 +- examples/react-vite-consumer/src/styles.css | 9 + examples/web-vite-consumer/index.html | 21 + examples/web-vite-consumer/package.json | 16 + examples/web-vite-consumer/src/main.ts | 21 + examples/web-vite-consumer/src/styles.css | 41 ++ examples/web-vite-consumer/tsconfig.json | 13 + packages/avatar-core/README.md | 14 +- .../avatar-core/src/__tests__/core-test.ts | 134 +++- .../src/avatarDefinition.schema.json | 5 +- packages/avatar-core/src/avatarDefinition.ts | 138 +--- packages/avatar-core/src/geometry.ts | 15 +- packages/avatar-core/src/runtime.ts | 163 +++-- packages/avatar-react/README.md | 138 ++-- packages/avatar-react/src/Avatar.tsx | 554 ++++++---------- .../src/__tests__/avatar-test.tsx | 472 ++++---------- packages/avatar-react/src/createAvatar.tsx | 74 +++ packages/avatar-react/src/index.ts | 4 +- packages/avatar-react/src/styles.css | 124 ---- packages/avatar-web/README.md | 28 + packages/avatar-web/package.json | 34 + .../src/__tests__/avatar-web-test.ts | 49 ++ packages/avatar-web/src/index.ts | 304 +++++++++ packages/avatar-web/tsconfig.build.json | 12 + packages/avatar-web/tsconfig.json | 14 + packages/avatar-web/vite.config.ts | 17 + pnpm-lock.yaml | 26 + scripts/smoke-packages.mjs | 28 +- src/app/styles.css | 582 +++++++++++++++-- src/components/ui/dialog.tsx | 37 ++ .../__tests__/avatar-definition-test.ts | 120 +--- src/features/avatar/__tests__/avatars-test.ts | 12 +- src/features/avatar/avatarDefinition.ts | 1 - src/features/avatar/avatars.ts | 5 +- .../export/__tests__/exporter-test.ts | 35 + src/features/export/exporter.ts | 115 ++++ .../__tests__/runtime-preview-dialog-test.tsx | 63 ++ .../studio/components/RuntimeGuideDialog.tsx | 604 ++++++++++++++++++ .../components/RuntimePreviewDialog.tsx | 257 ++++++++ .../studio/components/StudioInspector.tsx | 524 ++++++--------- src/features/studio/useStudioController.ts | 135 ++-- src/i18n/__tests__/i18n-test.ts | 76 ++- src/i18n/index.ts | 147 ++++- src/i18n/zh.ts | 140 +++- 46 files changed, 3663 insertions(+), 1704 deletions(-) create mode 100644 examples/web-vite-consumer/index.html create mode 100644 examples/web-vite-consumer/package.json create mode 100644 examples/web-vite-consumer/src/main.ts create mode 100644 examples/web-vite-consumer/src/styles.css create mode 100644 examples/web-vite-consumer/tsconfig.json create mode 100644 packages/avatar-react/src/createAvatar.tsx create mode 100644 packages/avatar-web/README.md create mode 100644 packages/avatar-web/package.json create mode 100644 packages/avatar-web/src/__tests__/avatar-web-test.ts create mode 100644 packages/avatar-web/src/index.ts create mode 100644 packages/avatar-web/tsconfig.build.json create mode 100644 packages/avatar-web/tsconfig.json create mode 100644 packages/avatar-web/vite.config.ts create mode 100644 src/components/ui/dialog.tsx create mode 100644 src/features/studio/__tests__/runtime-preview-dialog-test.tsx create mode 100644 src/features/studio/components/RuntimeGuideDialog.tsx create mode 100644 src/features/studio/components/RuntimePreviewDialog.tsx diff --git a/README.md b/README.md index 4d7eb46..0af1f85 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/examples/react-vite-consumer/src/main.tsx b/examples/react-vite-consumer/src/main.tsx index d125c74..47d6797 100644 --- a/examples/react-vite-consumer/src/main.tsx +++ b/examples/react-vite-consumer/src/main.tsx @@ -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('neutral') return (
@@ -80,15 +82,14 @@ function Demo() { ))} - +
+ +
) } diff --git a/examples/react-vite-consumer/src/strobi.avatar.json b/examples/react-vite-consumer/src/strobi.avatar.json index a5f7a94..cb86f62 100644 --- a/examples/react-vite-consumer/src/strobi.avatar.json +++ b/examples/react-vite-consumer/src/strobi.avatar.json @@ -1728,6 +1728,5 @@ "scared", "playful", "celebrate" - ], - "standardAnimationSet": 1 + ] } diff --git a/examples/react-vite-consumer/src/styles.css b/examples/react-vite-consumer/src/styles.css index 0324c1e..3bd9c60 100644 --- a/examples/react-vite-consumer/src/styles.css +++ b/examples/react-vite-consumer/src/styles.css @@ -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); } diff --git a/examples/web-vite-consumer/index.html b/examples/web-vite-consumer/index.html new file mode 100644 index 0000000..2cbf660 --- /dev/null +++ b/examples/web-vite-consumer/index.html @@ -0,0 +1,21 @@ + + + + + + Avatar Web consumer + + +
+
+
+ + + + + +
+
+ + + diff --git a/examples/web-vite-consumer/package.json b/examples/web-vite-consumer/package.json new file mode 100644 index 0000000..c69387a --- /dev/null +++ b/examples/web-vite-consumer/package.json @@ -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" + } +} diff --git a/examples/web-vite-consumer/src/main.ts b/examples/web-vite-consumer/src/main.ts new file mode 100644 index 0000000..24f58ea --- /dev/null +++ b/examples/web-vite-consumer/src/main.ts @@ -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('[data-animation]').forEach(button => { + button.addEventListener('click', () => avatar.play(button.dataset.animation ?? 'idle')) +}) +document.querySelectorAll('[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()) diff --git a/examples/web-vite-consumer/src/styles.css b/examples/web-vite-consumer/src/styles.css new file mode 100644 index 0000000..834a25c --- /dev/null +++ b/examples/web-vite-consumer/src/styles.css @@ -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; +} diff --git a/examples/web-vite-consumer/tsconfig.json b/examples/web-vite-consumer/tsconfig.json new file mode 100644 index 0000000..a466ef4 --- /dev/null +++ b/examples/web-vite-consumer/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "strict": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "types": ["vite/client"], + "noEmit": true + }, + "include": ["src"] +} diff --git a/packages/avatar-core/README.md b/packages/avatar-core/README.md index 14b2730..f7108a4 100644 --- a/packages/avatar-core/README.md +++ b/packages/avatar-core/README.md @@ -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. diff --git a/packages/avatar-core/src/__tests__/core-test.ts b/packages/avatar-core/src/__tests__/core-test.ts index 1e491b8..6e8caa4 100644 --- a/packages/avatar-core/src/__tests__/core-test.ts +++ b/packages/avatar-core/src/__tests__/core-test.ts @@ -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: { diff --git a/packages/avatar-core/src/avatarDefinition.schema.json b/packages/avatar-core/src/avatarDefinition.schema.json index 36e8962..018a161 100644 --- a/packages/avatar-core/src/avatarDefinition.schema.json +++ b/packages/avatar-core/src/avatarDefinition.schema.json @@ -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": { diff --git a/packages/avatar-core/src/avatarDefinition.ts b/packages/avatar-core/src/avatarDefinition.ts index 9f7ce5c..8713c0e 100644 --- a/packages/avatar-core/src/avatarDefinition.ts +++ b/packages/avatar-core/src/avatarDefinition.ts @@ -107,139 +107,8 @@ export type AvatarDefinition = { expressionOrder: ExpressionKey[] animations: Record 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 - -export type StandardAnimationAvailabilityV1 = { - available: StandardAnimationKeyV1[] - unavailable: { key: StandardAnimationKeyV1; missingExpressions: ExpressionKey[] }[] -} - -export const getStandardAnimationAvailabilityV1 = ( - expressions: Readonly> -): 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', diff --git a/packages/avatar-core/src/geometry.ts b/packages/avatar-core/src/geometry.ts index 28c107e..a96aa5e 100644 --- a/packages/avatar-core/src/geometry.ts +++ b/packages/avatar-core/src/geometry.ts @@ -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, } } diff --git a/packages/avatar-core/src/runtime.ts b/packages/avatar-core/src/runtime.ts index 83f0354..65522c1 100644 --- a/packages/avatar-core/src/runtime.ts +++ b/packages/avatar-core/src/runtime.ts @@ -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> => { 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, key: AnimationKey, - now: number + now: number, + from?: AvatarFrameSnapshot ): AvatarCommandResult => { 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, + expression: Readonly +): 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, state: Readonly, @@ -240,54 +271,78 @@ export const blinkOpacityAt = ( return Math.abs(progress * 2 - 1) } -export const renderAvatarFrame = ( +export const sampleAvatarFrame = ( definition: Readonly, state: Readonly, 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, + state: Readonly, + 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) } diff --git a/packages/avatar-react/README.md b/packages/avatar-react/README.md index 979223b..2afd232 100644 --- a/packages/avatar-react/README.md +++ b/packages/avatar-react/README.md @@ -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 +} +``` + +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 \| 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 -
- -
-``` - -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 - -``` - -`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 diff --git a/packages/avatar-react/src/Avatar.tsx b/packages/avatar-react/src/Avatar.tsx index cd5bc53..9cbe0b0 100644 --- a/packages/avatar-react/src/Avatar.tsx +++ b/packages/avatar-react/src/Avatar.tsx @@ -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() 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 + /** 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) => { + 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(null) const clipPathRef = useRef(null) const headPathRef = useRef(null) const leftPathRef = useRef(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(undefined) const completedAnimation = useRef(undefined) - const playbackRef = useRef(createAvatarPlaybackState()) - const drag = useRef< - | { - pointerId: number - pointer: AvatarPosition - origin: AvatarPosition - current: AvatarPosition - } - | undefined - >(undefined) - const [mounted, setMounted] = useState(false) - const [internalPosition, setInternalPosition] = useState({ x: 0, y: 0 }) - const [playback, setPlayback] = useState(() => { - 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(null) + const paintedFrameRef = useRef | null>(null) + const previousDefinitionRef = useRef(definition) + const [playback, setPlayback] = useState(() => + createInitialPlayback(definition, animation, expression, defaultAnimation, defaultExpression) + ) + + const paintScene = (frameScene: ReturnType) => { + 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, + now: number, + environment: ReturnType + ) => { + 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) => { - 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) => { - 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 (
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 && ) - - if (mode === 'floating' && mounted) { - return createPortal(wrapper, portalContainer ?? document.body) - } - return wrapper } diff --git a/packages/avatar-react/src/__tests__/avatar-test.tsx b/packages/avatar-react/src/__tests__/avatar-test.tsx index 32c632f..4e92a33 100644 --- a/packages/avatar-react/src/__tests__/avatar-test.tsx +++ b/packages/avatar-react/src/__tests__/avatar-test.tsx @@ -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() + + 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() 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() + 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() + 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() + const view = render() + const eye = view.container.querySelector('.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( + + ) + ).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( + + ) + + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ code: 'unknown_animation', key: 'missing-animation' }) + ) + + onError.mockClear() + controlled.rerender( + + ) + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ code: 'unknown_expression', key: 'missing-expression' }) + ) + }) + + it('keeps a controlled expression above an uncontrolled animation default', () => { + const controller = createRef() + render( + + ) + expect(controller.current?.getState()).toEqual({ + activeExpression: 'neutral', + status: 'stopped', + }) + }) + it('honors uncontrolled defaults without autoplay when requested', () => { const controller = createRef() 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(, { 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( - - ) - expect(view.getByRole('img').style.transform).toBe('translate3d(10px, 12px, 0)') - view.rerender( - - ) - expect(view.getByRole('img').style.transform).toBe('translate3d(10px, 12px, 0)') - }) - - it('server-renders a neutral floating placeholder before portal handoff', () => { - const markup = renderToString( - - ) - 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() - host.innerHTML = markup - document.body.append(host) - const errors = vi.spyOn(console, 'error').mockImplementation(() => undefined) - let root: ReturnType | undefined - await act(async () => { - root = hydrateRoot(host, ) - 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( - 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( - - ) - 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() - 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( - 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( - renders++}> - - - ) - 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( - 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( - 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() - 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( - 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( - 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( - 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( - - ) - 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( - 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() const clock = vi.spyOn(performance, 'now') diff --git a/packages/avatar-react/src/createAvatar.tsx b/packages/avatar-react/src/createAvatar.tsx new file mode 100644 index 0000000..b575683 --- /dev/null +++ b/packages/avatar-react/src/createAvatar.tsx @@ -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 = Extract + +/** Props for a component created from one concrete avatar definition. */ +export type CreatedAvatarProps = Omit< + AvatarProps, + 'definition' | 'animation' | 'expression' | 'defaultAnimation' | 'defaultExpression' +> & { + animation?: StringKey + defaultAnimation?: StringKey + expression?: StringKey + defaultExpression?: StringKey +} + +/** A concrete avatar component with animation and expression keys from its definition. */ +export type CreatedAvatarComponent = ( + props: CreatedAvatarProps +) => 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) => { + const ConcreteAvatar = (props: CreatedAvatarProps): ReactElement => ( + + ) + 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( + definition: Definition +): CreatedAvatarComponent +export function createAvatar(definition: unknown): CreatedAvatarComponent +export function createAvatar(definition: unknown): CreatedAvatarComponent { + const result = validateAvatarDefinition(definition) + if (!result.ok) throw invalidDefinitionError(result.errors) + markAvatarDefinitionValidated(result.value) + return buildAvatarComponent(result.value) +} + +export type { AnimationKey, AvatarController, ExpressionKey } diff --git a/packages/avatar-react/src/index.ts b/packages/avatar-react/src/index.ts index 095d20e..93224c5 100644 --- a/packages/avatar-react/src/index.ts +++ b/packages/avatar-react/src/index.ts @@ -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' diff --git a/packages/avatar-react/src/styles.css b/packages/avatar-react/src/styles.css index efd3cbb..f57709c 100644 --- a/packages/avatar-react/src/styles.css +++ b/packages/avatar-react/src/styles.css @@ -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 * { diff --git a/packages/avatar-web/README.md b/packages/avatar-web/README.md new file mode 100644 index 0000000..2556663 --- /dev/null +++ b/packages/avatar-web/README.md @@ -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. diff --git a/packages/avatar-web/package.json b/packages/avatar-web/package.json new file mode 100644 index 0000000..76a9102 --- /dev/null +++ b/packages/avatar-web/package.json @@ -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" + } +} diff --git a/packages/avatar-web/src/__tests__/avatar-web-test.ts b/packages/avatar-web/src/__tests__/avatar-web-test.ts new file mode 100644 index 0000000..745fbde --- /dev/null +++ b/packages/avatar-web/src/__tests__/avatar-web-test.ts @@ -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 = '
' + 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' }), + }) + }) +}) diff --git a/packages/avatar-web/src/index.ts b/packages/avatar-web/src/index.ts new file mode 100644 index 0000000..d14ceed --- /dev/null +++ b/packages/avatar-web/src/index.ts @@ -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(target) : target + if (!element) throw new Error(`Avatar target '${target}' was not found.`) + return element +} + +const createSvgElement = (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 = 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) => { + 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 | 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 } diff --git a/packages/avatar-web/tsconfig.build.json b/packages/avatar-web/tsconfig.build.json new file mode 100644 index 0000000..738a64e --- /dev/null +++ b/packages/avatar-web/tsconfig.build.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true, + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src"] +} diff --git a/packages/avatar-web/tsconfig.json b/packages/avatar-web/tsconfig.json new file mode 100644 index 0000000..7f8a4f7 --- /dev/null +++ b/packages/avatar-web/tsconfig.json @@ -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__/**"] +} diff --git a/packages/avatar-web/vite.config.ts b/packages/avatar-web/vite.config.ts new file mode 100644 index 0000000..186cb13 --- /dev/null +++ b/packages/avatar-web/vite.config.ts @@ -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'], + }, + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index de38c0e..fcf958e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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': diff --git a/scripts/smoke-packages.mjs b/scripts/smoke-packages.mjs index 464955e..9d0a5fb 100644 --- a/scripts/smoke-packages.mjs +++ b/scripts/smoke-packages.mjs @@ -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`) diff --git a/src/app/styles.css b/src/app/styles.css index da014b4..0399269 100644 --- a/src/app/styles.css +++ b/src/app/styles.css @@ -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 { diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx new file mode 100644 index 0000000..3f17ea6 --- /dev/null +++ b/src/components/ui/dialog.tsx @@ -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 +} + +function DialogClose(props: DialogPrimitive.Close.Props) { + return +} + +function DialogContent({ className, children, ...props }: DialogPrimitive.Popup.Props) { + return ( + + + + {children} + + + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) { + return
+} + +function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) { + return +} + +function DialogDescription({ className, ...props }: DialogPrimitive.Description.Props) { + return +} + +export { Dialog, DialogClose, DialogContent, DialogDescription, DialogHeader, DialogTitle } diff --git a/src/features/avatar/__tests__/avatar-definition-test.ts b/src/features/avatar/__tests__/avatar-definition-test.ts index fd5dd6d..e0ff488 100644 --- a/src/features/avatar/__tests__/avatar-definition-test.ts +++ b/src/features/avatar/__tests__/avatar-definition-test.ts @@ -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') diff --git a/src/features/avatar/__tests__/avatars-test.ts b/src/features/avatar/__tests__/avatars-test.ts index 9fb380d..3ca19b3 100644 --- a/src/features/avatar/__tests__/avatars-test.ts +++ b/src/features/avatar/__tests__/avatars-test.ts @@ -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' }) }) }) diff --git a/src/features/avatar/avatarDefinition.ts b/src/features/avatar/avatarDefinition.ts index eef5aec..ecea833 100644 --- a/src/features/avatar/avatarDefinition.ts +++ b/src/features/avatar/avatarDefinition.ts @@ -186,7 +186,6 @@ export const createAvatarDefinition = ({ ], animations, animationOrder: behavior.sequences.map(sequence => sequence.semanticKey!), - standardAnimationSet: 1, } return validateAvatarDefinition(definition) } diff --git a/src/features/avatar/avatars.ts b/src/features/avatar/avatars.ts index 9dfc08b..5a9d491 100644 --- a/src/features/avatar/avatars.ts +++ b/src/features/avatar/avatars.ts @@ -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 | null - if (candidate?.type !== 'pixel') return { ...defaultAvatarRenderStyle } + if (!PIXEL_RENDERING_ENABLED || candidate?.type !== 'pixel') { + return { ...defaultAvatarRenderStyle } + } return { type: 'pixel', resolution: Math.round( diff --git a/src/features/export/__tests__/exporter-test.ts b/src/features/export/__tests__/exporter-test.ts index 931a3fa..eeaeda4 100644 --- a/src/features/export/__tests__/exporter-test.ts +++ b/src/features/export/__tests__/exporter-test.ts @@ -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) diff --git a/src/features/export/exporter.ts b/src/features/export/exporter.ts index 41f04a3..0b69d64 100644 --- a/src/features/export/exporter.ts +++ b/src/features/export/exporter.ts @@ -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 +) => ` + + + + + ${escapedHtml(avatarName)} · Avatar demo + + + +
+

${escapedHtml(avatarName)}

+
+
+ +
+
+ + + +` + +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, + 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') diff --git a/src/features/studio/__tests__/runtime-preview-dialog-test.tsx b/src/features/studio/__tests__/runtime-preview-dialog-test.tsx new file mode 100644 index 0000000..780bcaf --- /dev/null +++ b/src/features/studio/__tests__/runtime-preview-dialog-test.tsx @@ -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( + + + + ) + +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) + }) +}) diff --git a/src/features/studio/components/RuntimeGuideDialog.tsx b/src/features/studio/components/RuntimeGuideDialog.tsx new file mode 100644 index 0000000..cb9e103 --- /dev/null +++ b/src/features/studio/components/RuntimeGuideDialog.tsx @@ -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 = ` +
+` + +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 +}` + +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 ( + 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(null) + + return <> + ${ + animationKey + ? ` + ` + : '' + } + + + + + +}` + +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 ( + + {token} + + ) + }) + +function GuideCode({ children }: { children: string }) { + return ( +
+      {highlightedCode(children)}
+    
+ ) +} + +function PropRow({ name, type, description }: { name: string; type: string; description: string }) { + return ( +
+
+ {name} + {type} +
+
{description}
+
+ ) +} + +export function RuntimeGuideDialog({ + animationKey, + integration = 'react', + onOpenChange, + open, +}: { + animationKey?: string + integration?: 'react' | 'javascript' + onOpenChange: (open: boolean) => void + open: boolean +}) { + const { t } = useStudioLanguage() + + return ( + + + +
+ + {t('Guide d’utilisation')} +
+ + {t( + integration === 'react' + ? 'Guide d’utilisation de l’avatar React' + : 'Guide d’utilisation de l’avatar JavaScript' + )} + + + {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.' + )} + + + + + } + /> +
+ +
+ {integration === 'react' ? ( + <> +
+
+ +
+

{t('Installation')}

+

{t('Ajoute le package React et ses dépendances.')}

+
+
+ {runtimeInstallExample} +

+ {t( + 'Les packages sont encore privés. Cette commande fonctionnera après leur publication ; utilise le workspace ou les tarballs pour les tests locaux.' + )} +

+
+ +
+
+ +
+

{t('API recommandée : créer un avatar concret')}

+

+ {t( + 'createAvatar valide le JSON et retourne un composant dédié dont les clés d’animations sont typées.' + )} +

+
+
+ {createAvatarExample(animationKey)} +
+ +
+
+ +
+

{t('Props de l’avatar')}

+

+ {t( + 'Référence complète : type, valeur par défaut, comportement et contraintes de chaque prop.' + )} +

+
+
+ +
+ {t('Cible et lecture')} +
+ + + + + + + +
+
+ +
+ {t('Présentation')} +
+ + + + +
+
+ +
+ {t('Callbacks de lecture')} +
+ + + +
+
+
+ +
+
+ +
+

{t('Avatar générique')}

+

+ {t( + 'Utilise Avatar directement lorsque la définition est chargée à l’exécution ou change entre plusieurs avatars.' + )} +

+
+
+ {genericAvatarExample} +
+ +
+
+ +
+

{t('API impérative')}

+

+ {t('La ref expose les commandes de lecture et l’état courant de l’avatar.')} +

+

+ {t( + 'Les commandes de cible sont disponibles en mode non contrôlé ; sinon utilise les props.' + )} +

+
+
+
+
+ + + + + +
+
+ {imperativeExample(animationKey)} +
+ + ) : ( + <> +
+
+ +
+

{t('Installation')}

+

{t('Ajoute le renderer DOM, qui utilise automatiquement avatar-core.')}

+
+
+ {webInstallExample} +

+ {t( + 'Les packages sont encore privés. Cette commande fonctionnera après leur publication ; utilise le workspace ou les tarballs pour les tests locaux.' + )} +

+
+ +
+
+ +
+

{t('Utilisation avec un bundler ESM')}

+

+ {t( + 'Vite et les bundlers modernes résolvent le package et importent le même fichier .avatar.json que React.' + )} +

+
+
+ {webAvatarExample(animationKey)} +
+ +
+
+ +
+

{t('Options de createAvatar')}

+

{t('Référence des valeurs acceptées lors du montage dans le DOM.')}

+
+
+
+
+ + + + + + + + + + +
+
+
+ +
+
+ +
+

{t('API du contrôleur DOM')}

+

{t('createAvatar retourne immédiatement ces commandes impératives.')}

+
+
+
+
+ + + + + + +
+
+
+ +
+
+ +
+

{t('Navigateur sans bundler')}

+

+ {t( + 'Utilise une URL ESM via un CDN ou une import map, puis charge la définition avec fetch.' + )} +

+
+
+ {nativeBrowserExample} +
+ + )} +
+ +
+ {t('Fermer le guide')}} /> +
+
+
+ ) +} diff --git a/src/features/studio/components/RuntimePreviewDialog.tsx b/src/features/studio/components/RuntimePreviewDialog.tsx new file mode 100644 index 0000000..da0bb94 --- /dev/null +++ b/src/features/studio/components/RuntimePreviewDialog.tsx @@ -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(null) + const firstAnimation = initialAnimation ?? definition.animationOrder[0] + const [target, setTarget] = useState( + firstAnimation + ? { type: 'animation', key: firstAnimation } + : { type: 'expression', key: 'neutral' } + ) + const [activeAnimation, setActiveAnimation] = useState(firstAnimation) + const [activeExpression, setActiveExpression] = useState( + firstAnimation + ? (definition.animations[firstAnimation]?.steps[0]?.expression ?? 'neutral') + : 'neutral' + ) + const [status, setStatus] = useState(firstAnimation ? 'playing' : 'stopped') + const [error, setError] = useState(null) + + const reportResult = (result: ReturnType) => { + 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 ( +
+
+
+
+ {t('Définition exportée')} + {definition.name ?? t('Avatar')} +
+ + +
+
+ { + setActiveAnimation(undefined) + setStatus('stopped') + }} + onExpressionChange={setActiveExpression} + onError={setError} + /> +
+
+
+ {t('Animation active')} + {activeAnimation ?? '—'} +
+
+ {t('Expression active')} + {activeExpression} +
+
+
+ + + + +
+ {error && ( +

+ {error.message} +

+ )} +
+ + +
+ ) +} + +export function RuntimePreviewDialog({ + definition, + initialAnimation, + onOpenChange, + open, +}: { + definition: AvatarDefinition | null + initialAnimation?: AnimationKey + onOpenChange: (open: boolean) => void + open: boolean +}) { + const { t } = useStudioLanguage() + + return ( + + + +
+ + {t('Preview')} +
+ {t('Preview de la définition exportée')} + + {t( + 'Teste les animations et expressions réellement présentes dans le fichier .avatar.json.' + )} + + + + + } + /> +
+ {open && definition && ( + + )} +
+
+ ) +} diff --git a/src/features/studio/components/StudioInspector.tsx b/src/features/studio/components/StudioInspector.tsx index 7acad98..7668d3f 100644 --- a/src/features/studio/components/StudioInspector.tsx +++ b/src/features/studio/components/StudioInspector.tsx @@ -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 -}` - -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 ( - - {token} - - ) - }) - export function StudioInspector({ controller }: { controller: StudioController }) { - const [runtimeExampleOpen, setRuntimeExampleOpen] = useState(false) - const runtimeExampleRef = useRef(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 } - + - - {t('Style')} - - - {pixelRenderStyle && ( -
- - updateAvatarRenderStyle({ - ...pixelRenderStyle, - resolution: Math.round(resolution), - }) - } - /> -
- )} +
+ {t('Vectoriel')} +
- - -
- -
- {t('Définition runtime')} - {activeAvatar.name} - {t('Fichier .avatar.json portable')} -
-
-
- - -
- {runtimeDefinitionResult.ok ? : } -
- - {t( - runtimeDefinitionResult.ok - ? 'Prêt pour l’export runtime' - : 'Export runtime incomplet' - )} - - {runtimeDefinitionResult.ok && runtimeStandardAvailability && ( - - {runtimeStandardAvailability.available.length}/6{' '} - {t('animations standard disponibles')} - - )} -
-
- {runtimeExportErrors.length > 0 && ( - <> -
    - {runtimeExportErrors.map((error, index) => ( -
  • {error}
  • - ))} -
- - - )} -
- - - -
- {t('Installation')} -
-                        {highlightedCode(runtimeInstallExample)}
-                      
-
-
- {t('Exemple React minimal')} -
-                        {highlightedCode(runtimeReactExample)}
-                      
-
- - {runtimeExampleOpen && runtimeDefinitionResult.ok && ( -
-
-
- {t('Aperçu avec le package React')} - defaultAnimation="idle" -
- -
-
- -
-
- )} -
- -
- - -
- {runtimeCopyStatus !== 'idle' && ( -

- {t( - runtimeCopyStatus === 'success' - ? 'JSON runtime copié dans le presse-papiers.' - : 'Impossible de copier le JSON runtime.' - )} -

- )} -
- +
@@ -1896,7 +1652,7 @@ export function StudioInspector({ controller }: { controller: StudioController } React / TypeScript - {t('Package React local (.zip)')} + {t('JSON runtime + createAvatar')}
@@ -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 ? : } + {t(exportAnimationsOpen ? 'Masquer la sélection' : 'Personnaliser')}
-
- {sequences.map(animation => { - const firstStep = animation.steps[0] - const firstExpression = firstStep - ? expressionById.get(firstStep.expressionId) - : undefined - return ( - - ) - })} -
+ {exportAnimationsOpen && ( +
+ +
+ {sequences.map(animation => { + const firstStep = animation.steps[0] + const firstExpression = firstStep + ? expressionById.get(firstStep.expressionId) + : undefined + return ( + + ) + })} +
+
+ )} - + +
+ + + )} + + +
+
+ {t('Définition prête à tester')} + + {selectedExportAnimations.length} {t('animations')} ·{' '} + {runtimeDefinitionResult.ok + ? runtimeDefinitionResult.value.expressionOrder.length + : 0}{' '} + {t('expressions')} + +
+
+ + +
+
+ {exportFormat === 'javascript' && ( +

+ {t( + 'Le ZIP contient le JSON exporté, une démo index.html et son README. La démo charge avatar-web depuis un CDN.' + )} +

)} - +
+ +
+ + + {exportFormat === 'javascript' && ( + + )} +
+ {runtimeCopyStatus !== 'idle' && ( +

+ {t( + runtimeCopyStatus === 'success' + ? 'JSON runtime copié dans le presse-papiers.' + : 'Impossible de copier le JSON runtime.' + )} +

+ )} + + ('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) => { 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() + 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, diff --git a/src/i18n/__tests__/i18n-test.ts b/src/i18n/__tests__/i18n-test.ts index 078fa75..b4b34a5 100644 --- a/src/i18n/__tests__/i18n-test.ts +++ b/src/i18n/__tests__/i18n-test.ts @@ -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( diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 4909446..5a9497d 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -85,6 +85,8 @@ const english: Record = { '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 = { 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 = { '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.', diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 20af3de..e7221b2 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -54,6 +54,8 @@ export const chinese: Record = { 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 = { 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 = { '下载包含所选动画的独立组件。', '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.': '捕获头像的静态图像。',