feat(odin): chat animations + immersive fullscreen mobile view

- bubbles spring in anchored at their avatar corner; thinking state is a
  typing bubble (three bouncing dots) that springs in/out where the reply
  lands; reduced-motion falls back to opacity fades
- mobile /odin is immersive: AppShell hides its header and main padding
  below md, the chat owns 100svh full-bleed with safe-area insets and a
  back arrow (history back, home on deep links)
- desktop card border kept divider-colored via longhand border props (a
  responsive border shorthand reset the color to black)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-11 08:31:28 +02:00
parent 06519d521f
commit 4c8ed39d85
3 changed files with 156 additions and 32 deletions

View File

@@ -1,7 +1,12 @@
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import Chip from "@mui/material/Chip";
import { motion, useReducedMotion, type Variants } from "framer-motion";
import {
motion,
AnimatePresence,
useReducedMotion,
type Variants,
} from "framer-motion";
import type { ChatTurn } from "./types";
import OdinMark from "./OdinMark";
@@ -150,12 +155,17 @@ export default function OdinThread({
return (
<MotionBox
key={i}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: reduce ? 0.3 : 0.34,
ease: [0.16, 1, 0.3, 1],
}}
initial={
reduce
? { opacity: 0 }
: { opacity: 0, y: 14, scale: 0.9, x: isUser ? 12 : -12 }
}
animate={{ opacity: 1, y: 0, scale: 1, x: 0 }}
transition={
reduce
? { duration: 0.3 }
: { type: "spring", stiffness: 460, damping: 32, mass: 0.7 }
}
sx={{
display: "flex",
flexDirection: "row",
@@ -163,6 +173,9 @@ export default function OdinThread({
gap: 1,
alignSelf: isUser ? "flex-end" : "flex-start",
maxWidth: "80%",
// The pop grows out of the corner where the bubble is anchored
// (next to the avatar / the composer side), not from its centre.
transformOrigin: isUser ? "bottom right" : "bottom left",
}}
>
{!isUser && <OdinMark size={28} />}
@@ -218,25 +231,79 @@ export default function OdinThread({
);
})}
{/* Busy indicator — same 28px mark and zero row padding as the bubble
rows, so the thinking mark sits exactly in the avatar column. */}
{busy && (
<Box
sx={{
alignSelf: "flex-start",
display: "flex",
alignItems: "center",
gap: 1,
py: 1,
color: "text.secondary",
}}
>
<OdinMark size={28} state="thinking" />
<Typography variant="caption" sx={{ color: "inherit" }}>
Pracuji
</Typography>
</Box>
)}
{/* Busy indicator — a typing bubble (three bouncing dots) anchored in
the avatar column, springing in/out where the reply will land. */}
<AnimatePresence>
{busy && (
<MotionBox
key="busy"
initial={
reduce ? { opacity: 0 } : { opacity: 0, y: 10, scale: 0.85 }
}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={
reduce
? { opacity: 0, transition: { duration: 0.15 } }
: {
opacity: 0,
scale: 0.85,
transition: { duration: 0.16, ease: "easeIn" },
}
}
transition={
reduce
? { duration: 0.3 }
: { type: "spring", stiffness: 460, damping: 32, mass: 0.7 }
}
sx={{
alignSelf: "flex-start",
display: "flex",
alignItems: "flex-end",
gap: 1,
transformOrigin: "bottom left",
}}
>
<OdinMark size={28} state="thinking" />
<Box
role="status"
aria-label="Odin pracuje"
sx={{
px: 1.5,
py: 1.5,
borderRadius: 2,
boxShadow: 1,
bgcolor: "background.paper",
display: "flex",
alignItems: "center",
gap: 0.6,
}}
>
{[0, 1, 2].map((i) => (
<MotionBox
key={i}
animate={
reduce
? { opacity: [0.35, 1, 0.35] }
: { y: [0, -4, 0], opacity: [0.35, 1, 0.35] }
}
transition={{
duration: 0.9,
repeat: Infinity,
delay: i * 0.15,
ease: "easeInOut",
}}
sx={{
width: 6,
height: 6,
borderRadius: "50%",
bgcolor: "text.secondary",
}}
/>
))}
</Box>
</MotionBox>
)}
</AnimatePresence>
</Box>
);
}