feat(odin): refine hero — Newsreader serif, red name, staggered entrance

- Swap Fraunces → Newsreader (literary serif, full Czech, optical sizing).
- Split the greeting so the user's first name renders in brand-red italic.
- Add a subtle staggered fade-in (framer-motion), skipped under
  prefers-reduced-motion.
- New description that names Odin and covers chat + invoice import.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-08 21:03:16 +02:00
parent c0ff7b4549
commit 28fb399217
3 changed files with 96 additions and 52 deletions

View File

@@ -67,21 +67,18 @@ export default function OdinChat() {
// Odin itself only needs ai.use, so the Save affordance is gated separately.
const canSave = hasPermission("invoices.create");
// Time-based Czech greeting + the user's first name (client-local time).
const greeting = (() => {
const h = new Date().getHours();
const part =
h < 9
? "Dobré ráno"
: h < 12
? "Dobrý den"
: h < 18
? "Dobré odpoledne"
: "Dobrý večer";
const name = (user?.fullName || user?.username || "")
.trim()
.split(/\s+/)[0];
return name ? `${part}, ${name}` : part;
})();
const greetingHour = new Date().getHours();
const greetingPhrase =
greetingHour < 9
? "Dobré ráno"
: greetingHour < 12
? "Dobrý den"
: greetingHour < 18
? "Dobré odpoledne"
: "Dobrý večer";
const userName = (user?.fullName || user?.username || "")
.trim()
.split(/\s+/)[0];
const { data: usage } = useQuery(aiUsageOptions());
const { data: convData, isPending: convPending } = useQuery(
aiConversationsOptions(),
@@ -441,7 +438,8 @@ export default function OdinChat() {
turns={turns}
busy={busy}
loading={messagesLoading}
greeting={greeting}
greetingPhrase={greetingPhrase}
userName={userName}
threadRef={threadRef}
/>

View File

@@ -1,13 +1,31 @@
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { motion, useReducedMotion, type Variants } from "framer-motion";
import type { ChatTurn } from "./types";
import OdinMark from "./OdinMark";
const MotionBox = motion.create(Box);
// Staggered hero entrance (skipped under prefers-reduced-motion).
const heroContainer: Variants = {
hidden: {},
show: { transition: { staggerChildren: 0.12, delayChildren: 0.06 } },
};
const heroItem: Variants = {
hidden: { opacity: 0, y: 14 },
show: {
opacity: 1,
y: 0,
transition: { duration: 0.55, ease: [0.16, 1, 0.3, 1] },
},
};
interface OdinThreadProps {
turns: ChatTurn[];
busy: boolean;
loading: boolean;
greeting?: string;
greetingPhrase?: string;
userName?: string;
threadRef: React.RefObject<HTMLDivElement | null>;
}
@@ -38,9 +56,15 @@ export default function OdinThread({
turns,
busy,
loading,
greeting,
greetingPhrase,
userName,
threadRef,
}: OdinThreadProps) {
const reduce = useReducedMotion();
const containerMotion = reduce
? {}
: { variants: heroContainer, initial: "hidden", animate: "show" };
const itemMotion = reduce ? {} : { variants: heroItem };
return (
<Box
ref={threadRef}
@@ -74,7 +98,8 @@ export default function OdinThread({
{/* Empty state */}
{turns.length === 0 && !busy && !loading && (
<Box
<MotionBox
{...containerMotion}
sx={{
flex: 1,
display: "flex",
@@ -86,38 +111,59 @@ export default function OdinThread({
px: 3,
}}
>
<Typography
component="h1"
sx={{
fontFamily: "'Fraunces', Georgia, 'Times New Roman', serif",
fontOpticalSizing: "auto",
fontWeight: 500,
fontSize: "clamp(2.1rem, 1.3rem + 2.5vw, 3.1rem)",
lineHeight: 1.06,
letterSpacing: "-0.015em",
color: "text.primary",
m: 0,
}}
>
{greeting || "Dobrý den"}
</Typography>
<Typography
component="p"
sx={{
fontFamily: "'Fraunces', Georgia, serif",
fontStyle: "italic",
fontWeight: 400,
fontSize: "clamp(1.05rem, 0.95rem + 0.45vw, 1.3rem)",
lineHeight: 1.55,
color: "text.secondary",
maxWidth: 500,
m: 0,
}}
>
Váš firemní AI asistent zeptejte se na cokoli, nebo přiložte
fakturu (PDF) k importu.
</Typography>
</Box>
<MotionBox {...itemMotion}>
<Typography
component="h1"
sx={{
fontFamily: "'Newsreader', Georgia, 'Times New Roman', serif",
fontOpticalSizing: "auto",
fontWeight: 500,
fontSize: "clamp(2.2rem, 1.3rem + 2.6vw, 3.3rem)",
lineHeight: 1.05,
letterSpacing: "-0.01em",
color: "text.primary",
m: 0,
}}
>
{greetingPhrase || "Dobrý den"}
{userName && (
<>
,{" "}
<Box
component="span"
sx={(t) => ({
fontStyle: "italic",
backgroundImage: `linear-gradient(120deg, ${t.vars!.palette.primary.light}, ${t.vars!.palette.primary.main})`,
WebkitBackgroundClip: "text",
backgroundClip: "text",
WebkitTextFillColor: "transparent",
})}
>
{userName}
</Box>
</>
)}
</Typography>
</MotionBox>
<MotionBox {...itemMotion}>
<Typography
component="p"
sx={{
fontFamily: "'Newsreader', Georgia, serif",
fontStyle: "italic",
fontWeight: 400,
fontSize: "clamp(1.05rem, 0.95rem + 0.45vw, 1.3rem)",
lineHeight: 1.55,
color: "text.secondary",
maxWidth: 540,
m: 0,
}}
>
Zeptejte se na cokoli, nebo přiložte fakturu Odin ji přečte a
připraví k uložení.
</Typography>
</MotionBox>
</MotionBox>
)}
{/* Message bubbles */}