From 175691ab32cefe5faec7828af62f3d50210a8eb2 Mon Sep 17 00:00:00 2001 From: smontlouis Date: Thu, 20 Aug 2026 08:06:58 +0200 Subject: [PATCH] feat(export): add copyable AI usage guides --- src/app/studio-utils.ts | 1 + src/app/styles.css | 25 +- .../studio/components/RuntimeGuideDialog.tsx | 256 +++++++++++++++++- .../studio/components/StudioInspector.tsx | 85 ++++-- .../__tests__/runtime-guide-dialog-test.ts | 35 +++ src/features/studio/useStudioController.ts | 9 + src/i18n/__tests__/i18n-test.ts | 6 + src/i18n/index.ts | 3 + src/i18n/zh.ts | 3 + 9 files changed, 391 insertions(+), 32 deletions(-) create mode 100644 src/features/studio/components/__tests__/runtime-guide-dialog-test.ts diff --git a/src/app/studio-utils.ts b/src/app/studio-utils.ts index 30fc1c6..0a9c24a 100644 --- a/src/app/studio-utils.ts +++ b/src/app/studio-utils.ts @@ -44,6 +44,7 @@ export type Highlight = 'head' | 'left' | 'right' | 'both' | null export const RETARGET_BLEND_MS = 120 export const INSPECTOR_FRAME_MS = 1000 / 24 export const AMBIENT_FRAME_MS = 1000 / 30 +export const COPY_FEEDBACK_DURATION_MS = 2000 export const createExpressionId = () => `expression-${crypto.randomUUID()}` export const emptyBodyNodes: BodyNode[] = [] const previewGeometryCache = new WeakMap< diff --git a/src/app/styles.css b/src/app/styles.css index 4a83a9b..808fe80 100644 --- a/src/app/styles.css +++ b/src/app/styles.css @@ -631,15 +631,6 @@ input { min-height: 44px; 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-export-card { display: grid; @@ -668,12 +659,17 @@ input { font-size: 15px; letter-spacing: -0.015em; } -.runtime-quick-start-heading > button { - flex: none; +.runtime-quick-start-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 2px; +} +.runtime-quick-start-actions > button { + min-width: 0; color: #4168d5; font-size: 11px; } -.runtime-quick-start-heading > button > svg { +.runtime-quick-start-actions > button > svg { width: 14px; height: 14px; } @@ -3664,7 +3660,10 @@ p { align-items: flex-start; flex-direction: column; } - .runtime-quick-start-heading > button { + .runtime-quick-start-actions { + justify-content: flex-start; + } + .runtime-quick-start-actions > button { padding-inline: 0; } html, diff --git a/src/features/studio/components/RuntimeGuideDialog.tsx b/src/features/studio/components/RuntimeGuideDialog.tsx index 2e8a036..5d38e45 100644 --- a/src/features/studio/components/RuntimeGuideDialog.tsx +++ b/src/features/studio/components/RuntimeGuideDialog.tsx @@ -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 | 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 }) { return (
@@ -129,7 +383,7 @@ export function RuntimeGuideDialog({
   open,
 }: {
   animationKey?: string
-  integration?: 'react' | 'javascript'
+  integration?: RuntimeGuideIntegration
   onOpenChange: (open: boolean) => void
   open: boolean
 }) {
diff --git a/src/features/studio/components/StudioInspector.tsx b/src/features/studio/components/StudioInspector.tsx
index 09191e2..7a8cee3 100644
--- a/src/features/studio/components/StudioInspector.tsx
+++ b/src/features/studio/components/StudioInspector.tsx
@@ -2,6 +2,7 @@ import {
   ArrowLeft,
   ArrowRight,
   Camera,
+  Check,
   ChevronDown,
   ChevronUp,
   Copy,
@@ -20,7 +21,7 @@ import {
   Upload,
 } from 'lucide-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 {
   Accordion,
@@ -58,7 +59,12 @@ import {
   StatePlayer,
 } from '@/app/components/common'
 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 { findExpressionIndex, groupSequences } from '@/features/animation/sequences'
 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 { BodyConstructionAccordion } from '@/features/studio/components/BodyConstructionAccordion'
 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 { StudioIdentity } from '@/features/studio/components/StudioIdentity'
 import type { StudioController } from '@/features/studio/useStudioController'
@@ -398,6 +407,15 @@ function PoseControls({ controller }: { controller: StudioController }) {
 export function StudioInspector({ controller }: { controller: StudioController }) {
   const [runtimePreviewOpen, setRuntimePreviewOpen] = 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 {
     activateAvatar,
@@ -541,9 +559,30 @@ export function StudioInspector({ controller }: { controller: StudioController }
     updateWireVisibility,
     workspaceBackButtonRef,
   } = 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
     ? runtimeDefinitionResult.value.animationOrder[0]
     : undefined
+  const guideCopyStatus =
+    guideCopyFeedback?.format === exportFormat ? guideCopyFeedback.status : 'idle'
   const updateSnapshotComposition = (patch: Partial) =>
     setSnapshotComposition(current => ({ ...current, ...patch }))
   const playbackFooterY = useMotionValue(0)
@@ -1725,8 +1764,24 @@ export function StudioInspector({ controller }: { controller: StudioController }
                     

{t('Démarrage rapide')}

-

{t('Utiliser cet avatar')}

+
+
+
- {runtimeCopyStatus !== 'idle' && ( -

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

- )} { + 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')") + }) +}) diff --git a/src/features/studio/useStudioController.ts b/src/features/studio/useStudioController.ts index 9cd2429..044af14 100644 --- a/src/features/studio/useStudioController.ts +++ b/src/features/studio/useStudioController.ts @@ -6,6 +6,7 @@ import { useStudioLanguage } from '@/i18n' import { AMBIENT_FRAME_MS, bounded, + COPY_FEEDBACK_DURATION_MS, createExpressionId, downloadBlob, INSPECTOR_FRAME_MS, @@ -169,6 +170,14 @@ export function useStudioController() { status: 'idle' | 'success' | 'error' source?: readonly unknown[] }>({ 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 updateStudioLibrary = (library: typeof initialDocument.library) => documentStore.update({ library }) diff --git a/src/i18n/__tests__/i18n-test.ts b/src/i18n/__tests__/i18n-test.ts index bf0ac11..b96e759 100644 --- a/src/i18n/__tests__/i18n-test.ts +++ b/src/i18n/__tests__/i18n-test.ts @@ -68,6 +68,12 @@ describe('avatar studio translations', () => { expect(translateStudioText('Guide d’utilisation de l’avatar JavaScript', 'en')).toBe( '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( 'Exported definition preview' ) diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 22ab162..c3cac44 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -170,6 +170,9 @@ const english: Record = { 'Aucune animation sélectionnée': 'No animation selected', 'Guide d’utilisation': '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 JavaScript': 'JavaScript avatar usage guide', 'Installe le package, crée ton composant et choisis le niveau de contrôle adapté.': diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index e715f9e..c13d474 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -139,6 +139,9 @@ export const chinese: Record = { 'Aucune animation sélectionnée': '未选择动画', 'Guide d’utilisation': '使用指南', '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 JavaScript': 'JavaScript 头像使用指南', 'Installe le package, crée ton composant et choisis le niveau de contrôle adapté.':