diff --git a/src/app/styles.css b/src/app/styles.css
index 75b5c34..0e393c1 100644
--- a/src/app/styles.css
+++ b/src/app/styles.css
@@ -67,6 +67,7 @@ input {
}
.studio-root {
min-height: 100vh;
+ overflow-x: clip;
}
.stage-column {
@@ -932,6 +933,10 @@ input {
font-size: 11px;
line-height: 1.45;
}
+.workspace-header-reset {
+ flex: 0 0 auto;
+ margin-top: 18px;
+}
.workspace-header-actions {
display: flex;
flex: 0 0 auto;
@@ -975,6 +980,10 @@ input {
margin-bottom: 22px;
}
.mode-page-header {
+ display: flex;
+ align-items: flex-end;
+ justify-content: space-between;
+ gap: 16px;
margin-bottom: 20px;
padding: 4px 2px 0;
}
@@ -1072,20 +1081,57 @@ p {
bottom: 62px;
width: min(520px, 100vw);
box-sizing: border-box;
- padding: 10px 18px;
+ padding: 24px 18px 10px;
border-top: 1px solid #d9dce3;
background: rgb(250 251 253 / 96%);
box-shadow: 0 -10px 30px rgb(24 28 35 / 8%);
backdrop-filter: blur(14px);
}
-.state-playback-expand {
+.state-playback-drag-handle-slot {
position: absolute;
+ z-index: 2;
top: 0;
- left: 50%;
+ right: 0;
+ left: 0;
+ display: flex;
+ height: 24px;
+ align-items: center;
+ justify-content: center;
+ pointer-events: none;
+}
+.state-playback-drag-handle {
+ display: grid;
+ width: 64px;
+ height: 24px;
+ padding: 0;
+ border: 0;
+ outline: 0;
border-radius: 999px;
- background: white;
- box-shadow: 0 4px 14px rgb(24 28 35 / 9%);
- transform: translate(-50%, -50%);
+ background: transparent;
+ cursor: grab;
+ place-items: center;
+ pointer-events: auto;
+ touch-action: none;
+}
+.state-playback-drag-handle:active {
+ cursor: grabbing;
+}
+.state-playback-drag-handle:focus-visible {
+ box-shadow: 0 0 0 3px rgb(91 127 229 / 28%);
+}
+.state-playback-drag-handle > span {
+ width: 38px;
+ height: 4px;
+ border-radius: 999px;
+ background: #b9bfca;
+ transition:
+ width 160ms ease,
+ background-color 160ms ease;
+}
+.state-playback-drag-handle:hover > span,
+.state-playback-drag-handle:focus-visible > span {
+ width: 44px;
+ background: #5b7fe5;
}
.state-playback-bar {
display: flex;
@@ -1093,11 +1139,15 @@ p {
align-items: center;
gap: 14px;
}
+.state-playback-details-shell {
+ will-change: opacity;
+}
.state-playback-details {
display: grid;
+ overflow: hidden;
gap: 14px;
- padding: 14px 2px 16px;
- border-bottom: 1px solid #dfe3eb;
+ padding: 16px 2px 6px;
+ border-top: 1px solid #dfe3eb;
}
.state-playback-details-header {
display: flex;
@@ -1151,9 +1201,6 @@ p {
.state-playback-detail-grid strong {
font-size: 12px;
}
-.state-playback-footer.is-expanded .state-playback-bar {
- padding-top: 10px;
-}
.state-playback-controls {
display: flex;
flex: 0 0 auto;
@@ -1173,7 +1220,7 @@ p {
min-width: 0;
flex: 1;
gap: 6px;
- overflow-x: auto;
+ overflow-x: scroll;
padding: 5px 3px 7px;
scroll-behavior: smooth;
scrollbar-color: #cbd2df transparent;
@@ -2624,7 +2671,7 @@ p {
}
.state-playback-footer {
bottom: calc(62px + env(safe-area-inset-bottom));
- padding: 6px 10px;
+ padding: 22px 10px 6px;
}
.state-playback-bar {
gap: 8px;
diff --git a/src/features/avatar/components/ExpressionWorkspace.tsx b/src/features/avatar/components/ExpressionWorkspace.tsx
index b61b80b..808dbdb 100644
--- a/src/features/avatar/components/ExpressionWorkspace.tsx
+++ b/src/features/avatar/components/ExpressionWorkspace.tsx
@@ -247,6 +247,18 @@ export function ExpressionWorkspace({
{t('L’avatar à gauche affiche cette expression en direct.')}
+ {editing.index === null && (
+
+ )}
diff --git a/src/features/studio/components/StudioInspector.tsx b/src/features/studio/components/StudioInspector.tsx
index 6a371d5..fe17652 100644
--- a/src/features/studio/components/StudioInspector.tsx
+++ b/src/features/studio/components/StudioInspector.tsx
@@ -1,7 +1,5 @@
import {
ArrowLeft,
- ChevronDown,
- ChevronUp,
Copy,
Download,
FileCode2,
@@ -14,8 +12,8 @@ import {
Trash2,
Upload,
} from 'lucide-react'
-import { AnimatePresence, motion } from 'motion/react'
-import { type CSSProperties } from 'react'
+import { AnimatePresence, animate, motion, useMotionValue, useTransform } from 'motion/react'
+import { type CSSProperties, useLayoutEffect, useRef, useState } from 'react'
import { Accordion } from '@/components/ui/accordion'
import { Badge } from '@/components/ui/badge'
@@ -205,6 +203,66 @@ export function StudioInspector({ controller }: { controller: StudioController }
updateWireVisibility,
workspaceBackButtonRef,
} = controller
+ const playbackFooterY = useMotionValue(0)
+ const playbackHandleY = useMotionValue(0)
+ const playbackHandleCounterY = useTransform(playbackHandleY, value => -value)
+ const playbackFooterDragOriginY = useRef(0)
+ const playbackDetailsRef = useRef
(null)
+ const measuredSequenceIdRef = useRef(null)
+ const [playbackFooterCollapsedOffset, setPlaybackFooterCollapsedOffset] = useState(0)
+ const playbackDetailsOpacity = useTransform(
+ playbackFooterY,
+ [0, Math.max(playbackFooterCollapsedOffset, 1)],
+ [1, 0]
+ )
+
+ useLayoutEffect(() => {
+ const details = playbackDetailsRef.current
+ if (!details || !activeSequence) return
+
+ const measure = () => {
+ const nextOffset = details.getBoundingClientRect().height
+ if (nextOffset <= 0) return
+
+ const firstMeasurement = measuredSequenceIdRef.current !== activeSequence.id
+ if (firstMeasurement) {
+ measuredSequenceIdRef.current = activeSequence.id
+ playbackFooterY.set(statePlayerExpanded ? 0 : nextOffset)
+ } else if (
+ playbackFooterCollapsedOffset > 0 &&
+ Math.abs(nextOffset - playbackFooterCollapsedOffset) > 0.5
+ ) {
+ const progress = Math.min(
+ 1,
+ Math.max(0, playbackFooterY.get() / playbackFooterCollapsedOffset)
+ )
+ playbackFooterY.set(progress * nextOffset)
+ }
+ setPlaybackFooterCollapsedOffset(nextOffset)
+ }
+
+ measure()
+ const observer = new ResizeObserver(measure)
+ observer.observe(details)
+ return () => observer.disconnect()
+ }, [
+ activeSequence,
+ language,
+ playbackFooterCollapsedOffset,
+ playbackFooterY,
+ statePlayerExpanded,
+ ])
+
+ const snapPlaybackFooter = (expanded: boolean) => {
+ setStatePlayerExpanded(expanded)
+ animate(playbackFooterY, expanded ? 0 : playbackFooterCollapsedOffset, {
+ type: 'spring',
+ stiffness: 440,
+ damping: 40,
+ duration: reduceMotion ? 0 : undefined,
+ })
+ }
+
return (
{!editorPageOpen && (
- {activeAvatar.name}
-
- {t(
- mode === 'manual'
- ? 'Pose'
- : mode === 'avatars'
- ? 'Avatars'
- : mode === 'expressions'
- ? 'Expressions'
- : mode === 'states'
- ? 'Animations'
- : 'Exporter'
- )}
-
+
+
{activeAvatar.name}
+
+ {t(
+ mode === 'manual'
+ ? 'Pose'
+ : mode === 'avatars'
+ ? 'Avatars'
+ : mode === 'expressions'
+ ? 'Expressions'
+ : mode === 'states'
+ ? 'Animations'
+ : 'Exporter'
+ )}
+
+
+ {mode === 'manual' && (
+
+ )}
)}
@@ -1215,14 +1286,6 @@ export function StudioInspector({ controller }: { controller: StudioController }
aria-label={t('Afficher le maillage')}
/>
-
>
@@ -1792,79 +1855,46 @@ export function StudioInspector({ controller }: { controller: StudioController }
{activeSequence && !editorPageOpen && (
-
- {statePlayerExpanded && (
-
-
-
-
{t('Détails de l’animation')}
-
{activeSequenceLabel}
-
- {activeSequence.builtIn
- ? t(activeSequence.description)
- : activeSequence.description}
-
-
-
- {t('Mode de lecture')} · {t(activeSequence.playbackMode)}
-
-
-
-
- {t('Expressions')}
- {activeSequence.steps.length}
-
- {activeSequence.steps
- .map(step => formatSeconds(step.holdMs, language))
- .join(' · ')}
-
-
-
- {t('Premier clignement')}
-
- {activeSequence.blink.enabled
- ? formatSeconds(activeSequence.blink.initialDelayMs, language)
- : t('Désactivé')}
-
- {t('après le lancement')}
-
-
- {t('Intervalle du clignement')}
-
- {formatSeconds(activeSequence.blink.minIntervalMs, language)}–
- {formatSeconds(activeSequence.blink.maxIntervalMs, language)}
-
- {t('tirage aléatoire')}
-
-
- {t('Durée du clignement')}
- {activeSequence.blink.durationMs} ms
- {t('fermeture et ouverture')}
-
-
+
+
+ snapPlaybackFooter(!statePlayerExpanded)}
+ onDragStart={() => {
+ playbackFooterDragOriginY.current = playbackFooterY.get()
+ }}
+ onDrag={(_, info) => {
+ playbackFooterY.set(
+ Math.min(
+ playbackFooterCollapsedOffset,
+ Math.max(0, playbackFooterDragOriginY.current + info.offset.y)
+ )
+ )
+ }}
+ onDragEnd={(_, info) => {
+ playbackHandleY.set(0)
+ const projectedY = playbackFooterY.get() + info.velocity.y * 0.16
+ snapPlaybackFooter(projectedY < playbackFooterCollapsedOffset / 2)
+ }}
+ >
+
+
- )}
+
{activeSequence.steps.map((step, position) => {
@@ -1914,6 +1944,62 @@ export function StudioInspector({ controller }: { controller: StudioController }
/>
+
+
+
+
+
{t('Détails de l’animation')}
+
{activeSequenceLabel}
+
+ {activeSequence.builtIn
+ ? t(activeSequence.description)
+ : activeSequence.description}
+
+
+
+ {t('Mode de lecture')} · {t(activeSequence.playbackMode)}
+
+
+
+
+ {t('Expressions')}
+ {activeSequence.steps.length}
+
+ {activeSequence.steps
+ .map(step => formatSeconds(step.holdMs, language))
+ .join(' · ')}
+
+
+
+ {t('Premier clignement')}
+
+ {activeSequence.blink.enabled
+ ? formatSeconds(activeSequence.blink.initialDelayMs, language)
+ : t('Désactivé')}
+
+ {t('après le lancement')}
+
+
+ {t('Intervalle du clignement')}
+
+ {formatSeconds(activeSequence.blink.minIntervalMs, language)}–
+ {formatSeconds(activeSequence.blink.maxIntervalMs, language)}
+
+ {t('tirage aléatoire')}
+
+
+ {t('Durée du clignement')}
+ {activeSequence.blink.durationMs} ms
+ {t('fermeture et ouverture')}
+
+
+
+
)}
{!editorPageOpen && (
diff --git a/src/features/studio/useStudioController.ts b/src/features/studio/useStudioController.ts
index 9e50dee..21551b2 100644
--- a/src/features/studio/useStudioController.ts
+++ b/src/features/studio/useStudioController.ts
@@ -113,7 +113,7 @@ import {
export function useStudioController() {
const { language, setLanguage, t } = useStudioLanguage()
- const [mode, setMode] = useState
('manual')
+ const [mode, setMode] = useState('avatars')
const [initialDocument] = useState(loadStudioDocument)
const [documentStore] = useState(() => createStudioDocumentStore(initialDocument))
const initialLibrary = initialDocument.library
@@ -830,6 +830,7 @@ export function useStudioController() {
const snapshot = avatarEditSnapshot.current
if (!snapshot) {
setBodyEditing(false)
+ setMode('avatars')
restoreStateAfterEditor()
return
}
@@ -837,6 +838,7 @@ export function useStudioController() {
avatarsRef.current = snapshot.avatars
setAvatars(snapshot.avatars)
activateAvatar(snapshot.activeAvatarId, false, true)
+ setMode('avatars')
restoreStateAfterEditor()
}