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

@@ -13,7 +13,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link <link
href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@300;400;500&family=Fraunces:ital,opsz,wght@0,9..144,400;0,9..144,500;0,9..144,600;1,9..144,400;1,9..144,500&family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&family=Urbanist:wght@300;400;500;600;700;800&display=swap" href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@300;400;500&family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;0,6..72,600;1,6..72,400;1,6..72,500&family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&family=Urbanist:wght@300;400;500;600;700;800&display=swap"
rel="stylesheet" rel="stylesheet"
/> />
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" /> <link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />

View File

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

View File

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