feat(studio): redesign responsive navigation

Add a unified bottom navigation, avatar drawer, and responsive preview and inspector layout across desktop and mobile.
This commit is contained in:
smontlouis 2026-08-13 19:55:20 +02:00
parent d71d7478bb
commit 94bc5223e6
7 changed files with 2354 additions and 1775 deletions

View File

@ -109,7 +109,7 @@ export function ExportSection({
}) {
const { t } = useStudioLanguage()
return (
<AccordionItem value={value} className="export-accordion-item">
<AccordionItem value={value} className={`export-accordion-item export-accordion-${value}`}>
<AccordionTrigger className="export-accordion-trigger">
<span>
<strong>{t(title)}</strong>

View File

@ -165,10 +165,24 @@ input {
text-wrap: pretty;
}
.brand {
.studio-identity {
position: absolute;
z-index: 5;
top: 0;
right: 0;
left: 0;
display: flex;
align-items: center;
justify-content: space-between;
pointer-events: none;
}
.studio-identity > * {
pointer-events: auto;
}
.brand {
position: relative;
top: 28px;
left: 32px;
margin-left: 32px;
display: flex;
align-items: center;
gap: 9px;
@ -190,10 +204,9 @@ input {
box-shadow: 0 0 0 3px rgb(91 127 229 / 15%);
}
.language-picker {
position: absolute;
z-index: 5;
position: relative;
top: 24px;
right: 28px;
margin-right: 28px;
display: flex;
align-items: center;
gap: 7px;
@ -225,6 +238,9 @@ input {
border-radius: 5px;
box-shadow: 0 0 0 2px #7895ee;
}
.inspector-identity {
display: none;
}
.avatar-wrap {
position: relative;
@ -377,6 +393,9 @@ input {
width: 100%;
min-width: 0;
}
.export-accordion-snapshot {
order: -1;
}
.export-accordion-item {
min-width: 0;
overflow: hidden;
@ -782,11 +801,13 @@ input {
}
.inspector {
--app-footer-clearance: 140px;
height: 100vh;
overflow-x: hidden;
overflow-y: auto;
scrollbar-gutter: stable;
padding: 28px 26px 48px;
padding: 28px 26px var(--app-footer-clearance);
border-left: 1px solid #d9dce3;
background: #f5f6f8;
}
@ -796,7 +817,7 @@ input {
padding-top: 0;
}
.inspector.state-player-active {
padding-bottom: 128px;
--app-footer-clearance: 220px;
}
@keyframes studio-workspace-in {
from {
@ -826,7 +847,7 @@ input {
display: flex;
min-height: calc(100vh - 76px);
flex-direction: column;
padding-bottom: 74px;
padding-bottom: 0;
}
.workspace-header {
position: sticky;
@ -905,6 +926,16 @@ input {
gap: 16px;
margin-bottom: 22px;
}
.mode-page-header {
margin-bottom: 20px;
padding: 4px 2px 0;
}
.mode-page-header .eyebrow {
margin-bottom: 5px;
}
.mode-page-header h1 {
font-size: 28px;
}
.eyebrow {
margin: 0 0 6px;
color: #7185c4;
@ -990,7 +1021,7 @@ p {
position: fixed;
z-index: 9;
right: 0;
bottom: 0;
bottom: 62px;
width: min(520px, 100vw);
box-sizing: border-box;
padding: 10px 18px;
@ -1262,6 +1293,151 @@ p {
padding: 5px;
backdrop-filter: blur(14px);
}
.desktop-mode-tabs {
display: none;
}
.mobile-mode-tabs {
position: fixed;
z-index: 10;
right: 0;
bottom: 0;
display: grid;
width: min(520px, 100vw);
min-height: 62px;
padding: 5px 8px;
border-top: 1px solid #d9dce3;
background: rgb(250 251 253 / 97%);
box-shadow: 0 -8px 24px rgb(24 28 35 / 7%);
grid-template-columns: repeat(5, minmax(0, 1fr));
backdrop-filter: blur(16px);
}
.mobile-mode-tab {
display: flex;
height: 51px;
min-width: 0;
flex-direction: column;
gap: 3px;
border-radius: 12px;
color: #7b828e;
font-size: 9px;
font-weight: 750;
}
.mobile-mode-tab > svg {
width: 19px;
height: 19px;
}
.mobile-mode-tab > span {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
}
.mobile-mode-tab[aria-pressed='true'] {
background: #edf1ff;
color: #4168d5;
}
.mobile-avatar-tab > svg {
width: 27px;
height: 27px;
margin-block: -4px -1px;
border-radius: 50%;
}
.drawer-backdrop {
position: fixed;
z-index: 40;
inset: 0;
background: transparent;
animation: drawer-backdrop-in 180ms ease-out;
}
.drawer-viewport {
position: fixed;
z-index: 41;
inset: 0;
display: flex;
align-items: flex-end;
justify-content: flex-end;
pointer-events: none;
}
.drawer-content {
width: min(520px, 100vw);
max-height: min(72dvh, 680px);
overflow-x: hidden;
overflow-y: auto;
padding: 9px 20px 28px;
border: 1px solid #d9dce3;
border-bottom: 0;
border-radius: 24px 24px 0 0;
outline: 0;
background: #f5f6f8;
box-shadow: 0 -24px 70px rgb(12 16 24 / 22%);
animation: drawer-content-in 240ms cubic-bezier(0.22, 1, 0.36, 1);
pointer-events: auto;
}
.drawer-handle {
display: grid;
width: 100%;
height: 24px;
margin: 0 0 3px;
padding: 0;
border: 0;
outline: 0;
background: transparent;
cursor: pointer;
place-items: center;
}
.drawer-handle > span {
width: 42px;
height: 5px;
border-radius: 999px;
background: #c8ccd4;
}
.avatar-drawer-heading {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
margin-bottom: 14px;
padding-inline: 2px;
}
.avatar-drawer-heading > span {
color: #858b96;
font-size: 11px;
font-weight: 800;
}
.drawer-title {
margin: 0;
font-size: 21px;
letter-spacing: -0.035em;
}
.drawer-description {
margin: 4px 0 0;
color: #7c838e;
font-size: 10px;
}
.avatar-drawer .avatar-shelf {
margin: 0;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
}
.avatar-drawer .avatar-shelf .avatar-grid {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
@keyframes drawer-backdrop-in {
from {
opacity: 0;
}
}
@keyframes drawer-content-in {
from {
transform: translateY(100%);
}
}
.inspector.state-player-active .mobile-mode-tabs {
border-top: 0;
box-shadow: none;
}
.state-buttons button,
.button-row button,
.reset {
@ -2131,30 +2307,195 @@ p {
}
@media (max-width: 600px) {
html,
body,
#root {
width: 100%;
height: 100%;
overflow: hidden;
}
.studio {
height: 100dvh;
min-height: 0;
grid-template-rows: auto minmax(0, 1fr);
overflow: hidden;
}
.stage-column {
min-height: 420px;
padding: 18px;
position: relative;
aspect-ratio: 1;
height: auto;
min-height: 0;
padding: 10px;
}
.stage-identity {
display: flex;
}
.inspector-identity {
position: sticky;
top: 0;
z-index: 20;
display: none;
min-height: 52px;
margin: 0 -14px 16px;
padding: 0 14px;
border-bottom: 1px solid #d9dce3;
background: rgb(245 246 248 / 94%);
backdrop-filter: blur(14px);
}
.inspector-identity .brand {
top: 0;
margin-left: 0;
color: #17191d;
font-size: 11px;
}
.inspector-identity .brand em {
color: #767d88;
}
.inspector-identity .brand-mark {
width: 16px;
height: 16px;
}
.inspector-identity .language-picker {
top: 0;
min-height: 32px;
margin-right: 0;
padding: 3px 6px 3px 8px;
border-color: #d9dce3;
background: white;
box-shadow: 0 4px 14px rgb(24 28 35 / 7%);
}
.inspector-identity .language-picker [data-slot='select-trigger'] {
min-width: 68px;
color: #17191d;
}
.stage-identity .brand {
top: 14px;
margin-left: 14px;
font-size: 11px;
}
.stage-identity .brand-mark {
width: 16px;
height: 16px;
}
.stage-identity .language-picker {
top: 10px;
min-height: 32px;
margin-right: 12px;
padding: 3px 6px 3px 8px;
}
.stage-identity .language-picker [data-slot='select-trigger'] {
min-width: 68px;
}
.avatar-wrap {
width: min(42dvh, 78vw, 340px);
}
.photo-capture-bar {
bottom: 48px;
bottom: max(12px, env(safe-area-inset-bottom));
left: 12px;
transform: none;
}
.photo-capture-button {
min-width: 168px;
width: 40px;
min-width: 40px;
height: 40px;
padding: 0;
border-radius: 50%;
}
.brand {
top: 18px;
left: 20px;
.photo-capture-button > svg {
width: 18px;
height: 18px;
}
.photo-capture-label,
.photo-format-badge,
.photo-help-button {
display: none;
}
.inspector {
padding: 22px 14px 40px;
height: auto;
min-height: 0;
overscroll-behavior: contain;
padding: 0 14px calc(var(--app-footer-clearance) + env(safe-area-inset-bottom));
scrollbar-gutter: auto;
}
.inspector.expression-workspace-active,
.inspector.sequence-workspace-active,
.inspector.body-workspace {
padding-top: 0;
}
.inspector.expression-workspace-active .inspector-identity,
.inspector.sequence-workspace-active .inspector-identity,
.inspector.body-workspace .inspector-identity {
position: static;
}
.workspace-page {
min-height: calc(50dvh - 52px);
}
.mode-page-header {
margin-bottom: 16px;
padding-top: 18px;
}
.mode-page-header h1 {
font-size: 24px;
}
.workspace-header {
position: static;
top: auto;
}
.numeric-field {
grid-template-columns: 100px minmax(76px, 1fr) 66px;
gap: 7px;
}
.desktop-mode-tabs {
display: none;
}
.mobile-mode-tabs {
position: fixed;
z-index: 10;
right: 0;
bottom: 0;
left: 0;
display: grid;
min-height: calc(62px + env(safe-area-inset-bottom));
padding: 5px 8px max(5px, env(safe-area-inset-bottom));
border-top: 1px solid #d9dce3;
background: rgb(250 251 253 / 97%);
box-shadow: 0 -8px 24px rgb(24 28 35 / 7%);
grid-template-columns: repeat(5, minmax(0, 1fr));
backdrop-filter: blur(16px);
}
.mobile-mode-tab {
display: flex;
height: 51px;
min-width: 0;
flex-direction: column;
gap: 3px;
border-radius: 12px;
color: #7b828e;
font-size: 9px;
font-weight: 750;
}
.mobile-mode-tab > svg {
width: 19px;
height: 19px;
}
.mobile-mode-tab > span {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
}
.mobile-mode-tab[aria-pressed='true'] {
background: #edf1ff;
color: #4168d5;
}
.eye-columns {
grid-template-columns: minmax(0, 1fr);
}
.expression-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.state-buttons {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.avatar-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@ -2174,4 +2515,32 @@ p {
.workspace-header-actions .state-player > span {
display: none;
}
.state-playback-footer {
bottom: calc(62px + env(safe-area-inset-bottom));
padding: 6px 10px;
}
.state-playback-bar {
gap: 8px;
}
.state-playback-timeline {
gap: 4px;
padding: 3px 2px 4px;
}
.state-playback-step {
width: 42px;
height: 42px;
flex-basis: 42px;
border-radius: 9px;
}
.state-playback-controls {
padding-left: 8px;
}
.state-playback-footer .state-player [data-slot='button'] {
width: 36px;
height: 36px;
}
.state-playback-footer .state-player svg {
width: 22px;
height: 22px;
}
}

View File

@ -0,0 +1,35 @@
import { Dialog as DialogPrimitive } from '@base-ui/react/dialog'
import type * as React from 'react'
import { cn } from '@/lib/utils'
function Drawer(props: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root {...props} />
}
function DrawerContent({ className, children, ...props }: DialogPrimitive.Popup.Props) {
return (
<DialogPrimitive.Portal>
<DialogPrimitive.Backdrop className="drawer-backdrop" />
<DialogPrimitive.Viewport className="drawer-viewport">
<DialogPrimitive.Popup className={cn('drawer-content', className)} {...props}>
{children}
</DialogPrimitive.Popup>
</DialogPrimitive.Viewport>
</DialogPrimitive.Portal>
)
}
function DrawerClose(props: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close {...props} />
}
function DrawerTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return <DialogPrimitive.Title className={cn('drawer-title', className)} {...props} />
}
function DrawerDescription({ className, ...props }: DialogPrimitive.Description.Props) {
return <DialogPrimitive.Description className={cn('drawer-description', className)} {...props} />
}
export { Drawer, DrawerClose, DrawerContent, DrawerDescription, DrawerTitle }

View File

@ -0,0 +1,166 @@
import { Copy, Pencil, Plus, Trash2 } from 'lucide-react'
import { motion } from 'motion/react'
import { Button } from '@/components/ui/button'
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from '@/components/ui/context-menu'
import { DrawerClose, DrawerContent, DrawerDescription, DrawerTitle } from '@/components/ui/drawer'
import { defaultAvatarEyes } from '@/features/avatar/avatars'
import { ExpressionPreview } from '@/features/avatar/components/ExpressionWorkspace'
import { defaultExpression } from '@/features/avatar/presets'
import type { StudioController } from '@/features/studio/useStudioController'
export function AvatarDrawer({
controller,
onOpenChange,
}: {
controller: StudioController
onOpenChange: (open: boolean) => void
}) {
const {
activateAvatar,
activeAvatarId,
avatarDragOrigin,
avatarDragPreview,
avatars,
avatarsRef,
cancelAvatarMove,
commitAvatarMove,
createNewAvatar,
draggedAvatarId,
draggingAvatarId,
duplicateAvatar,
expressions,
previewAvatarMove,
reduceMotion,
setDeleteAvatarOpen,
setDraggingAvatarId,
setFocusAvatarName,
t,
} = controller
return (
<DrawerContent className="avatar-drawer">
<DrawerClose className="drawer-handle" aria-label="Close avatar picker">
<span />
</DrawerClose>
<div className="avatar-drawer-heading">
<div>
<DrawerTitle>Avatars</DrawerTitle>
<DrawerDescription>{t('Double-clic pour modifier')}</DrawerDescription>
</div>
<span>{avatars.length}</span>
</div>
<section className="avatar-shelf" aria-label={t('Choisir un avatar')}>
<div className="avatar-grid">
{avatars.map(avatar => (
<motion.div
className="avatar-sort-item"
data-dragging={draggingAvatarId === avatar.id || undefined}
key={avatar.id}
layout="position"
animate={{
opacity: draggingAvatarId === avatar.id ? 0.28 : 1,
scale: draggingAvatarId === avatar.id ? 0.96 : 1,
}}
transition={
reduceMotion
? { duration: 0 }
: { type: 'spring', stiffness: 520, damping: 42, mass: 0.7 }
}
>
<ContextMenu>
<ContextMenuTrigger
render={
<Button
className="avatar-card"
variant="outline"
aria-pressed={activeAvatarId === avatar.id}
type="button"
draggable
onDragStart={event => {
avatarDragOrigin.current = avatarsRef.current
avatarDragPreview.current = avatarsRef.current
draggedAvatarId.current = avatar.id
setDraggingAvatarId(avatar.id)
event.dataTransfer.effectAllowed = 'move'
}}
onDragEnter={() => previewAvatarMove(avatar.id)}
onDragOver={event => {
event.preventDefault()
event.dataTransfer.dropEffect = 'move'
}}
onDrop={event => {
event.preventDefault()
commitAvatarMove(avatar.id)
}}
onDragEnd={cancelAvatarMove}
onClick={() => activateAvatar(avatar.id, false, true)}
onDoubleClick={() => {
setFocusAvatarName(false)
activateAvatar(avatar.id, true)
onOpenChange(false)
}}
>
<ExpressionPreview
expression={expressions[0] ?? defaultExpression}
surface={avatar.body.primary}
bodyNodes={avatar.body.nodes}
colors={avatar.colors}
avatarEyes={avatar.eyes ?? defaultAvatarEyes}
id={`avatar-${avatar.id}`}
/>
<span>{avatar.name}</span>
</Button>
}
/>
<ContextMenuContent>
<ContextMenuItem
onClick={() => {
setFocusAvatarName(false)
activateAvatar(avatar.id, true)
onOpenChange(false)
}}
>
<Pencil /> {t('Modifier')}
</ContextMenuItem>
<ContextMenuItem onClick={() => duplicateAvatar(avatar)}>
<Copy /> {t('Dupliquer')}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
variant="destructive"
disabled={avatars.length <= 1}
onClick={() => {
activateAvatar(avatar.id, false, true)
onOpenChange(false)
setDeleteAvatarOpen(true)
}}
>
<Trash2 /> {t('Supprimer')}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
</motion.div>
))}
<Button
variant="outline"
className="avatar-add creation-card"
onClick={() => {
createNewAvatar()
onOpenChange(false)
}}
aria-label={t('Nouvel avatar')}
>
<Plus />
</Button>
</div>
</section>
</DrawerContent>
)
}

View File

@ -0,0 +1,45 @@
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import type { StudioController } from '@/features/studio/useStudioController'
import { type StudioLanguage } from '@/i18n'
type StudioIdentityProps = Pick<StudioController, 'language' | 'setLanguage' | 't'> & {
className?: string
}
export function StudioIdentity({ className = '', language, setLanguage, t }: StudioIdentityProps) {
return (
<div className={`studio-identity ${className}`.trim()}>
<div className="brand">
<span className="brand-mark" />
Bible Strong <em>Avatar Lab</em>
</div>
<div className="language-picker">
<span aria-hidden="true">{language === 'en' ? '🇬🇧' : language === 'fr' ? '🇫🇷' : '🇨🇳'}</span>
<Select
value={language}
items={[
{ value: 'en', label: 'English' },
{ value: 'fr', label: 'Français' },
{ value: 'zh-CN', label: '中文' },
]}
onValueChange={next => next && setLanguage(next as StudioLanguage)}
>
<SelectTrigger aria-label={t('Langue de l’interface')}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="en">English</SelectItem>
<SelectItem value="fr">Français</SelectItem>
<SelectItem value="zh-CN">中文</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@ -3,17 +3,10 @@ import { motion } from 'motion/react'
import { type CSSProperties } from 'react'
import { Button } from '@/components/ui/button'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { type StudioLanguage } from '@/i18n'
import { AvatarCanvas } from '@/features/rendering/components/AvatarCanvas'
import { StudioIdentity } from '@/features/studio/components/StudioIdentity'
import type { StudioController } from '@/features/studio/useStudioController'
export function StudioStage({ controller }: { controller: StudioController }) {
@ -27,7 +20,6 @@ export function StudioStage({ controller }: { controller: StudioController }) {
expression,
freezeLivePreviewForManipulation,
highlight,
language,
linked,
mode,
persistEditedEyeExpression,
@ -44,7 +36,6 @@ export function StudioStage({ controller }: { controller: StudioController }) {
selectedBodyNodeId,
selectedEyeSide,
setEditing,
setLanguage,
setSelectedEyeSide,
showWire,
snapshotFormat,
@ -65,31 +56,12 @@ export function StudioStage({ controller }: { controller: StudioController }) {
} as CSSProperties
}
>
<div className="brand">
<span className="brand-mark" />
Bible Strong <em>Avatar Lab</em>
</div>
<div className="language-picker">
<span aria-hidden="true">{language === 'en' ? '🇬🇧' : language === 'fr' ? '🇫🇷' : '🇨🇳'}</span>
<Select
value={language}
items={[
{ value: 'en', label: 'English' },
{ value: 'fr', label: 'Français' },
{ value: 'zh-CN', label: '中文' },
]}
onValueChange={next => next && setLanguage(next as StudioLanguage)}
>
<SelectTrigger aria-label={t('Langue de l’interface')}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="en">English</SelectItem>
<SelectItem value="fr">Français</SelectItem>
<SelectItem value="zh-CN">中文</SelectItem>
</SelectContent>
</Select>
</div>
<StudioIdentity
className="stage-identity"
language={controller.language}
setLanguage={controller.setLanguage}
t={t}
/>
<AvatarCanvas
expression={canvasExpression}
avatarEyes={activeAvatarEyes}
@ -138,9 +110,14 @@ export function StudioStage({ controller }: { controller: StudioController }) {
)}
<TooltipProvider>
<div className="photo-capture-bar">
<Button className="photo-capture-button" type="button" onClick={takePicture}>
<Button
className="photo-capture-button"
type="button"
aria-label={t('Prendre une photo')}
onClick={takePicture}
>
<Camera />
{t('Prendre une photo')}
<span className="photo-capture-label">{t('Prendre une photo')}</span>
<span className="photo-format-badge">{snapshotFormat.toUpperCase()}</span>
</Button>
<Tooltip>