feat(export): add copyable AI usage guides

This commit is contained in:
smontlouis 2026-08-20 08:06:58 +02:00
parent fc7445002a
commit 175691ab32
9 changed files with 391 additions and 32 deletions

View File

@ -44,6 +44,7 @@ export type Highlight = 'head' | 'left' | 'right' | 'both' | null
export const RETARGET_BLEND_MS = 120 export const RETARGET_BLEND_MS = 120
export const INSPECTOR_FRAME_MS = 1000 / 24 export const INSPECTOR_FRAME_MS = 1000 / 24
export const AMBIENT_FRAME_MS = 1000 / 30 export const AMBIENT_FRAME_MS = 1000 / 30
export const COPY_FEEDBACK_DURATION_MS = 2000
export const createExpressionId = () => `expression-${crypto.randomUUID()}` export const createExpressionId = () => `expression-${crypto.randomUUID()}`
export const emptyBodyNodes: BodyNode[] = [] export const emptyBodyNodes: BodyNode[] = []
const previewGeometryCache = new WeakMap< const previewGeometryCache = new WeakMap<

View File

@ -631,15 +631,6 @@ input {
min-height: 44px; min-height: 44px;
box-shadow: 0 12px 30px rgb(23 25 29 / 18%); box-shadow: 0 12px 30px rgb(23 25 29 / 18%);
} }
.runtime-copy-status {
margin: 0;
color: var(--muted-foreground);
font-size: 11px;
text-align: center;
}
.runtime-copy-status[role='alert'] {
color: var(--destructive);
}
.runtime-quick-start-card, .runtime-quick-start-card,
.runtime-export-card { .runtime-export-card {
display: grid; display: grid;
@ -668,12 +659,17 @@ input {
font-size: 15px; font-size: 15px;
letter-spacing: -0.015em; letter-spacing: -0.015em;
} }
.runtime-quick-start-heading > button { .runtime-quick-start-actions {
flex: none; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 2px;
}
.runtime-quick-start-actions > button {
min-width: 0;
color: #4168d5; color: #4168d5;
font-size: 11px; font-size: 11px;
} }
.runtime-quick-start-heading > button > svg { .runtime-quick-start-actions > button > svg {
width: 14px; width: 14px;
height: 14px; height: 14px;
} }
@ -3664,7 +3660,10 @@ p {
align-items: flex-start; align-items: flex-start;
flex-direction: column; flex-direction: column;
} }
.runtime-quick-start-heading > button { .runtime-quick-start-actions {
justify-content: flex-start;
}
.runtime-quick-start-actions > button {
padding-inline: 0; padding-inline: 0;
} }
html, html,

View File

@ -100,6 +100,260 @@ export function Controls() {
</> </>
}` }`
export type RuntimeGuideIntegration = 'react' | 'javascript'
type TranslateGuideText = (text: string) => string
type RuntimeGuideProp = readonly [name: string, type: string, description: string]
const markdownCode = (code: string, language = '') => `\`\`\`${language}\n${code}\n\`\`\``
const markdownProps = (props: readonly RuntimeGuideProp[], t: TranslateGuideText) =>
props
.map(([name, type, description]) => `- \`${name}\` — \`${type}\`: ${t(description)}`)
.join('\n')
export function buildRuntimeGuideText({
animationKey,
integration,
t,
}: {
animationKey?: string
integration: RuntimeGuideIntegration
t: TranslateGuideText
}) {
const privatePackageNotice = t(
'Les packages sont encore privés. Cette commande fonctionnera après leur publication ; utilise le workspace ou les tarballs pour les tests locaux.'
)
if (integration === 'javascript') {
const options: readonly RuntimeGuideProp[] = [
[
'definition',
'AvatarDefinition | unknown',
'Obligatoire. Définition JSON validée avant la création des éléments SVG.',
],
[
'defaultAnimation',
'AnimationKey | undefined',
'Optionnelle. Animation lancée au montage lorsque autoplay vaut true. Mutuellement exclusive avec defaultExpression.',
],
[
'defaultExpression',
'ExpressionKey | undefined',
'Optionnelle. Expression initiale affichée sans lancer de timeline. Mutuellement exclusive avec defaultAnimation.',
],
[
'autoplay',
'boolean | undefined',
'Optionnelle, défaut true. Contrôle uniquement le lancement automatique de defaultAnimation.',
],
[
'size',
'number | string | undefined',
'Optionnelle, défaut 240. Largeur et hauteur CSS du conteneur rendu.',
],
['className', 'string | undefined', 'Optionnelle. Classe CSS ajoutée au conteneur rendu.'],
[
'ariaLabel',
'string | undefined',
'Optionnelle, défaut « Procedural avatar ». Nom accessible du rendu.',
],
[
'onError',
'(error: AvatarRuntimeError) => void',
'Optionnelle. Reçoit les erreurs de clé inconnue utilisées lors de l’initialisation.',
],
[
'onAnimationEnd',
'(animation: AnimationKey) => void',
'Optionnelle. Appelée lorsqu’une animation once se termine.',
],
[
'onExpressionChange',
'(expression: ExpressionKey) => void',
'Optionnelle. Appelée lorsque l’expression active change.',
],
]
const controller: readonly RuntimeGuideProp[] = [
[
'play(animation)',
'(animation: AnimationKey) => AvatarCommandResult',
'Lance ou reprend une animation par sa clé.',
],
[
'setExpression(expression)',
'(expression: ExpressionKey) => AvatarCommandResult',
'Affiche une expression avec une transition courte.',
],
['pause()', '() => void', 'Met en pause la timeline à sa position exacte.'],
['stop()', '() => void', 'Arrête la lecture et revient à neutral.'],
[
'getState()',
'() => AvatarPlaybackState',
'Retourne l’animation, l’expression et le statut actifs.',
],
[
'destroy()',
'() => void',
'Annule la frame planifiée et retire uniquement le conteneur créé par avatar-web.',
],
]
return [
`# ${t('Guide d’utilisation de l’avatar JavaScript')}`,
t(
'Installe le module ESM, charge la définition JSON et monte l’avatar dans un élément du DOM.'
),
`## ${t('Installation')}`,
t('Ajoute le renderer DOM, qui utilise automatiquement avatar-core.'),
markdownCode(webInstallExample, 'bash'),
privatePackageNotice,
`## ${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.'
),
markdownCode(webAvatarExample(animationKey), 'js'),
`## ${t('Options de createAvatar')}`,
t('Référence des valeurs acceptées lors du montage dans le DOM.'),
markdownProps(options, t),
`## ${t('API du contrôleur DOM')}`,
t('createAvatar retourne immédiatement ces commandes impératives.'),
markdownProps(controller, t),
`## ${t('Navigateur sans bundler')}`,
t('Utilise une URL ESM via un CDN ou une import map, puis charge la définition avec fetch.'),
markdownCode(nativeBrowserExample, 'html'),
].join('\n\n')
}
const targetProps: readonly RuntimeGuideProp[] = [
[
'definition',
'AvatarDefinition',
'Obligatoire. Objet AvatarDefinition validé contenant les expressions et les animations à afficher.',
],
[
'animation',
'AnimationKey | undefined',
'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',
'ExpressionKey | undefined',
'Optionnelle. Contrôle directement une expression par sa clé. Mutuellement exclusive avec animation ; une cible contrôlée prend priorité sur les valeurs default.',
],
[
'defaultAnimation',
'AnimationKey | undefined',
'Optionnelle. Définit la timeline initiale en mode non contrôlé. Lue au montage ; autoplay est activé par défaut. Mutuellement exclusive avec defaultExpression.',
],
[
'defaultExpression',
'ExpressionKey | undefined',
'Optionnelle. Définit l’expression initiale en mode non contrôlé. Lue au montage, sans lancer de timeline. Mutuellement exclusive avec defaultAnimation.',
],
[
'autoplay',
'boolean | undefined',
'Optionnelle, défaut true. Lance automatiquement defaultAnimation ; sans defaultAnimation, elle n’a aucun effet.',
],
[
'ref',
'Ref<AvatarController> | undefined',
'Optionnelle. Donne accès à l’API impérative AvatarController.',
],
]
const presentationProps: readonly RuntimeGuideProp[] = [
[
'size',
'number | string | undefined',
'Optionnelle, défaut 240. Nombre ou valeur CSS utilisée pour la largeur et la hauteur du conteneur.',
],
['className', 'string | undefined', 'Optionnelle. Classe CSS ajoutée au conteneur externe.'],
[
'style',
'CSSProperties | undefined',
'Optionnelle. Styles inline du conteneur externe ; width et height viennent de size.',
],
[
'ariaLabel',
'string | undefined',
'Optionnelle, défaut « Procedural avatar ». Nom accessible annoncé aux lecteurs d’écran.',
],
]
const callbackProps: readonly RuntimeGuideProp[] = [
[
'onAnimationEnd',
'(animation: AnimationKey) => void',
'Optionnelle. Reçoit la clé de l’animation once terminée naturellement.',
],
[
'onExpressionChange',
'(expression: ExpressionKey) => void',
'Optionnelle. Reçoit la clé de l’expression chaque fois que l’expression sémantique affichée change.',
],
[
'onError',
'(error: AvatarRuntimeError) => void',
'Optionnelle. Reçoit une erreur typée lorsqu’une prop animation, expression ou default référence une clé inconnue.',
],
]
const controllerProps: readonly RuntimeGuideProp[] = [
[
'play(animation)',
'(animation: AnimationKey) => AvatarCommandResult',
'Lance ou reprend une animation et retourne un résultat typé.',
],
['pause()', '() => void', 'Met en pause la timeline à sa position exacte.'],
[
'stop()',
'() => void',
'En mode non contrôlé, arrête la lecture et revient à neutral. En mode contrôlé, les props restent la source de vérité.',
],
[
'setExpression(expression)',
'(expression: ExpressionKey) => AvatarCommandResult',
'Affiche directement une expression.',
],
[
'getState()',
'() => AvatarPlaybackState',
'Retourne l’animation, l’expression et le statut actifs.',
],
]
return [
`# ${t('Guide d’utilisation de l’avatar React')}`,
t('Installe le package, crée ton composant et choisis le niveau de contrôle adapté.'),
`## ${t('Installation')}`,
t('Ajoute le package React et ses dépendances.'),
markdownCode(runtimeInstallExample, 'bash'),
privatePackageNotice,
`## ${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.'
),
markdownCode(createAvatarExample(animationKey), 'tsx'),
`## ${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')}`,
markdownProps(targetProps, t),
`### ${t('Présentation')}`,
markdownProps(presentationProps, t),
`### ${t('Callbacks de lecture')}`,
markdownProps(callbackProps, t),
`## ${t('Avatar générique')}`,
t(
'Utilise Avatar directement lorsque la définition est chargée à l’exécution ou change entre plusieurs avatars.'
),
markdownCode(genericAvatarExample, 'tsx'),
`## ${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.'),
markdownProps(controllerProps, t),
markdownCode(imperativeExample(animationKey), 'tsx'),
].join('\n\n')
}
function GuideCode({ children }: { children: string }) { function GuideCode({ children }: { children: string }) {
return ( return (
<pre className="avatar-guide-code" tabIndex={0}> <pre className="avatar-guide-code" tabIndex={0}>
@ -129,7 +383,7 @@ export function RuntimeGuideDialog({
open, open,
}: { }: {
animationKey?: string animationKey?: string
integration?: 'react' | 'javascript' integration?: RuntimeGuideIntegration
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
open: boolean open: boolean
}) { }) {

View File

@ -2,6 +2,7 @@ import {
ArrowLeft, ArrowLeft,
ArrowRight, ArrowRight,
Camera, Camera,
Check,
ChevronDown, ChevronDown,
ChevronUp, ChevronUp,
Copy, Copy,
@ -20,7 +21,7 @@ import {
Upload, Upload,
} from 'lucide-react' } from 'lucide-react'
import { AnimatePresence, animate, motion, useMotionValue, useTransform } from 'motion/react' import { AnimatePresence, animate, motion, useMotionValue, useTransform } from 'motion/react'
import { type CSSProperties, useLayoutEffect, useRef, useState } from 'react' import { type CSSProperties, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { import {
Accordion, Accordion,
@ -58,7 +59,12 @@ import {
StatePlayer, StatePlayer,
} from '@/app/components/common' } from '@/app/components/common'
import { ColorField, LinkButton, NumericField } from '@/app/components/controls' import { ColorField, LinkButton, NumericField } from '@/app/components/controls'
import { formatSeconds, type Side, type SnapshotFormat } from '@/app/studio-utils' import {
COPY_FEEDBACK_DURATION_MS,
formatSeconds,
type Side,
type SnapshotFormat,
} from '@/app/studio-utils'
import { SequenceWorkspace } from '@/features/animation/components/SequenceWorkspace' import { SequenceWorkspace } from '@/features/animation/components/SequenceWorkspace'
import { findExpressionIndex, groupSequences } from '@/features/animation/sequences' import { findExpressionIndex, groupSequences } from '@/features/animation/sequences'
import { defaultAvatarEyes } from '@/features/avatar/avatars' import { defaultAvatarEyes } from '@/features/avatar/avatars'
@ -73,7 +79,10 @@ import { type SnapshotBackground } from '@/features/export/snapshotExporter'
import { AvatarPage } from '@/features/studio/components/AvatarDrawer' import { AvatarPage } from '@/features/studio/components/AvatarDrawer'
import { BodyConstructionAccordion } from '@/features/studio/components/BodyConstructionAccordion' import { BodyConstructionAccordion } from '@/features/studio/components/BodyConstructionAccordion'
import { HighlightedRuntimeCode } from '@/features/studio/components/HighlightedRuntimeCode' import { HighlightedRuntimeCode } from '@/features/studio/components/HighlightedRuntimeCode'
import { RuntimeGuideDialog } from '@/features/studio/components/RuntimeGuideDialog' import {
buildRuntimeGuideText,
RuntimeGuideDialog,
} from '@/features/studio/components/RuntimeGuideDialog'
import { RuntimePreviewDialog } from '@/features/studio/components/RuntimePreviewDialog' import { RuntimePreviewDialog } from '@/features/studio/components/RuntimePreviewDialog'
import { StudioIdentity } from '@/features/studio/components/StudioIdentity' import { StudioIdentity } from '@/features/studio/components/StudioIdentity'
import type { StudioController } from '@/features/studio/useStudioController' import type { StudioController } from '@/features/studio/useStudioController'
@ -398,6 +407,15 @@ function PoseControls({ controller }: { controller: StudioController }) {
export function StudioInspector({ controller }: { controller: StudioController }) { export function StudioInspector({ controller }: { controller: StudioController }) {
const [runtimePreviewOpen, setRuntimePreviewOpen] = useState(false) const [runtimePreviewOpen, setRuntimePreviewOpen] = useState(false)
const [guideOpen, setGuideOpen] = useState(false) const [guideOpen, setGuideOpen] = useState(false)
const [guideCopyFeedback, setGuideCopyFeedback] = useState<{
format: 'react' | 'javascript'
status: 'success' | 'error'
} | null>(null)
useEffect(() => {
if (!guideCopyFeedback) return
const timeout = window.setTimeout(() => setGuideCopyFeedback(null), COPY_FEEDBACK_DURATION_MS)
return () => window.clearTimeout(timeout)
}, [guideCopyFeedback])
const [exportAnimationsOpen, setExportAnimationsOpen] = useState(false) const [exportAnimationsOpen, setExportAnimationsOpen] = useState(false)
const { const {
activateAvatar, activateAvatar,
@ -541,9 +559,30 @@ export function StudioInspector({ controller }: { controller: StudioController }
updateWireVisibility, updateWireVisibility,
workspaceBackButtonRef, workspaceBackButtonRef,
} = controller } = controller
const copyRuntimeGuide = async () => {
if (!navigator.clipboard) {
setGuideCopyFeedback({ format: exportFormat, status: 'error' })
return
}
try {
await navigator.clipboard.writeText(
buildRuntimeGuideText({
animationKey: runtimePreviewAnimation,
integration: exportFormat,
t,
})
)
setGuideCopyFeedback({ format: exportFormat, status: 'success' })
} catch {
setGuideCopyFeedback({ format: exportFormat, status: 'error' })
}
}
const runtimePreviewAnimation = runtimeDefinitionResult.ok const runtimePreviewAnimation = runtimeDefinitionResult.ok
? runtimeDefinitionResult.value.animationOrder[0] ? runtimeDefinitionResult.value.animationOrder[0]
: undefined : undefined
const guideCopyStatus =
guideCopyFeedback?.format === exportFormat ? guideCopyFeedback.status : 'idle'
const updateSnapshotComposition = (patch: Partial<typeof snapshotComposition>) => const updateSnapshotComposition = (patch: Partial<typeof snapshotComposition>) =>
setSnapshotComposition(current => ({ ...current, ...patch })) setSnapshotComposition(current => ({ ...current, ...patch }))
const playbackFooterY = useMotionValue(0) const playbackFooterY = useMotionValue(0)
@ -1725,8 +1764,24 @@ export function StudioInspector({ controller }: { controller: StudioController }
<div className="runtime-quick-start-heading"> <div className="runtime-quick-start-heading">
<div> <div>
<p className="eyebrow">{t('Démarrage rapide')}</p> <p className="eyebrow">{t('Démarrage rapide')}</p>
<h2>{t('Utiliser cet avatar')}</h2>
</div> </div>
</div>
<div className="runtime-quick-start-actions">
<Button
type="button"
variant="ghost"
aria-label={
guideCopyStatus === 'success'
? t('Guide d’utilisation copié dans le presse-papiers.')
: guideCopyStatus === 'error'
? t('Impossible de copier le guide d’utilisation.')
: undefined
}
onClick={() => void copyRuntimeGuide()}
>
{guideCopyStatus === 'success' ? <Check /> : <Copy />}
{t('Copier les instructions pour l’IA')}
</Button>
<Button type="button" variant="ghost" onClick={() => setGuideOpen(true)}> <Button type="button" variant="ghost" onClick={() => setGuideOpen(true)}>
{t('Voir le guide complet')} {t('Voir le guide complet')}
<ArrowRight /> <ArrowRight />
@ -1814,27 +1869,21 @@ export function StudioInspector({ controller }: { controller: StudioController }
type="button" type="button"
variant="outline" variant="outline"
disabled={!runtimeDefinitionResult.ok} disabled={!runtimeDefinitionResult.ok}
aria-label={
runtimeCopyStatus === 'success'
? t('JSON runtime copié dans le presse-papiers.')
: runtimeCopyStatus === 'error'
? t('Impossible de copier le JSON runtime.')
: undefined
}
onClick={() => void copyAvatarRuntimeDefinition()} onClick={() => void copyAvatarRuntimeDefinition()}
> >
<Copy /> {runtimeCopyStatus === 'success' ? <Check /> : <Copy />}
{t('Copier le JSON')} {t('Copier le JSON')}
</Button> </Button>
</div> </div>
</div> </div>
</InspectorCard> </InspectorCard>
{runtimeCopyStatus !== 'idle' && (
<p
className="runtime-copy-status"
role={runtimeCopyStatus === 'error' ? 'alert' : 'status'}
aria-live={runtimeCopyStatus === 'error' ? 'assertive' : 'polite'}
>
{t(
runtimeCopyStatus === 'success'
? 'JSON runtime copié dans le presse-papiers.'
: 'Impossible de copier le JSON runtime.'
)}
</p>
)}
<RuntimePreviewDialog <RuntimePreviewDialog
definition={runtimeDefinitionResult.ok ? runtimeDefinitionResult.value : null} definition={runtimeDefinitionResult.ok ? runtimeDefinitionResult.value : null}
initialAnimation={runtimePreviewAnimation} initialAnimation={runtimePreviewAnimation}

View File

@ -0,0 +1,35 @@
import { buildRuntimeGuideText } from '@/features/studio/components/RuntimeGuideDialog'
describe('runtime usage guide copy', () => {
it('copies the complete React guide with the selected animation', () => {
const guide = buildRuntimeGuideText({
animationKey: 'friendly-wave',
integration: 'react',
t: text => text,
})
expect(guide).toContain('# Guide d’utilisation de l’avatar React')
expect(guide).toContain('npm install @bible-strong/avatar-react react react-dom')
expect(guide).toContain('defaultAnimation="friendly-wave"')
expect(guide).toContain('## Props de l’avatar')
expect(guide).toContain('`onAnimationEnd`')
expect(guide).toContain('## Avatar générique')
expect(guide).toContain('## API impérative')
expect(guide).toContain('`getState()`')
})
it('copies the complete JavaScript guide with browser and controller instructions', () => {
const guide = buildRuntimeGuideText({
integration: 'javascript',
t: text => text,
})
expect(guide).toContain('# Guide d’utilisation de l’avatar JavaScript')
expect(guide).toContain('npm install @bible-strong/avatar-web')
expect(guide).toContain("defaultExpression: 'neutral'")
expect(guide).toContain('## Options de createAvatar')
expect(guide).toContain('`destroy()`')
expect(guide).toContain('## Navigateur sans bundler')
expect(guide).toContain("await fetch('./avatar.avatar.json')")
})
})

View File

@ -6,6 +6,7 @@ import { useStudioLanguage } from '@/i18n'
import { import {
AMBIENT_FRAME_MS, AMBIENT_FRAME_MS,
bounded, bounded,
COPY_FEEDBACK_DURATION_MS,
createExpressionId, createExpressionId,
downloadBlob, downloadBlob,
INSPECTOR_FRAME_MS, INSPECTOR_FRAME_MS,
@ -169,6 +170,14 @@ export function useStudioController() {
status: 'idle' | 'success' | 'error' status: 'idle' | 'success' | 'error'
source?: readonly unknown[] source?: readonly unknown[]
}>({ status: 'idle' }) }>({ status: 'idle' })
useEffect(() => {
if (runtimeCopyFeedback.status === 'idle') return
const timeout = window.setTimeout(
() => setRuntimeCopyFeedback({ status: 'idle' }),
COPY_FEEDBACK_DURATION_MS
)
return () => window.clearTimeout(timeout)
}, [runtimeCopyFeedback])
const initialStatePlayback = initialDocument.playback const initialStatePlayback = initialDocument.playback
const updateStudioLibrary = (library: typeof initialDocument.library) => const updateStudioLibrary = (library: typeof initialDocument.library) =>
documentStore.update({ library }) documentStore.update({ library })

View File

@ -68,6 +68,12 @@ describe('avatar studio translations', () => {
expect(translateStudioText('Guide d’utilisation de l’avatar JavaScript', 'en')).toBe( expect(translateStudioText('Guide d’utilisation de l’avatar JavaScript', 'en')).toBe(
'JavaScript avatar usage guide' 'JavaScript avatar usage guide'
) )
expect(translateStudioText('Copier les instructions pour l’IA', 'en')).toBe(
'Copy instruction for AI'
)
expect(translateStudioText('Copier les instructions pour l’IA', 'zh-CN')).toBe(
'复制 AI 使用说明'
)
expect(translateStudioText('Preview de la définition exportée', 'en')).toBe( expect(translateStudioText('Preview de la définition exportée', 'en')).toBe(
'Exported definition preview' 'Exported definition preview'
) )

View File

@ -170,6 +170,9 @@ const english: Record<string, string> = {
'Aucune animation sélectionnée': 'No animation selected', 'Aucune animation sélectionnée': 'No animation selected',
'Guide d’utilisation': 'Usage guide', 'Guide d’utilisation': 'Usage guide',
'Voir le guide complet': 'View full usage guide', 'Voir le guide complet': 'View full usage guide',
'Copier les instructions pour l’IA': 'Copy instruction for AI',
'Guide d’utilisation copié dans le presse-papiers.': 'Usage guide copied to the clipboard.',
'Impossible de copier le guide d’utilisation.': 'Could not copy the usage guide.',
'Guide d’utilisation de l’avatar React': 'React avatar usage guide', 'Guide d’utilisation de l’avatar React': 'React avatar usage guide',
'Guide d’utilisation de l’avatar JavaScript': 'JavaScript 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é.': 'Installe le package, crée ton composant et choisis le niveau de contrôle adapté.':

View File

@ -139,6 +139,9 @@ export const chinese: Record<string, string> = {
'Aucune animation sélectionnée': '未选择动画', 'Aucune animation sélectionnée': '未选择动画',
'Guide d’utilisation': '使用指南', 'Guide d’utilisation': '使用指南',
'Voir le guide complet': '查看完整使用指南', 'Voir le guide complet': '查看完整使用指南',
'Copier les instructions pour l’IA': '复制 AI 使用说明',
'Guide d’utilisation copié dans le presse-papiers.': '使用指南已复制到剪贴板。',
'Impossible de copier le guide d’utilisation.': '无法复制使用指南。',
'Guide d’utilisation de l’avatar React': 'React 头像使用指南', 'Guide d’utilisation de l’avatar React': 'React 头像使用指南',
'Guide d’utilisation de l’avatar JavaScript': 'JavaScript 头像使用指南', 'Guide d’utilisation de l’avatar JavaScript': 'JavaScript 头像使用指南',
'Installe le package, crée ton composant et choisis le niveau de contrôle adapté.': 'Installe le package, crée ton composant et choisis le niveau de contrôle adapté.':