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"
: h < 12 : greetingHour < 12
? "Dobrý den" ? "Dobrý den"
: h < 18 : greetingHour < 18
? "Dobré odpoledne" ? "Dobré odpoledne"
: "Dobrý večer"; : "Dobrý večer";
const name = (user?.fullName || user?.username || "") const userName = (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,
}} }}
> >
<MotionBox {...itemMotion}>
<Typography <Typography
component="h1" component="h1"
sx={{ sx={{
fontFamily: "'Fraunces', Georgia, 'Times New Roman', serif", fontFamily: "'Newsreader', Georgia, 'Times New Roman', serif",
fontOpticalSizing: "auto", fontOpticalSizing: "auto",
fontWeight: 500, fontWeight: 500,
fontSize: "clamp(2.1rem, 1.3rem + 2.5vw, 3.1rem)", fontSize: "clamp(2.2rem, 1.3rem + 2.6vw, 3.3rem)",
lineHeight: 1.06, lineHeight: 1.05,
letterSpacing: "-0.015em", letterSpacing: "-0.01em",
color: "text.primary", color: "text.primary",
m: 0, m: 0,
}} }}
> >
{greeting || "Dobrý den"} {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> </Typography>
</MotionBox>
<MotionBox {...itemMotion}>
<Typography <Typography
component="p" component="p"
sx={{ sx={{
fontFamily: "'Fraunces', Georgia, serif", fontFamily: "'Newsreader', Georgia, serif",
fontStyle: "italic", fontStyle: "italic",
fontWeight: 400, fontWeight: 400,
fontSize: "clamp(1.05rem, 0.95rem + 0.45vw, 1.3rem)", fontSize: "clamp(1.05rem, 0.95rem + 0.45vw, 1.3rem)",
lineHeight: 1.55, lineHeight: 1.55,
color: "text.secondary", color: "text.secondary",
maxWidth: 500, maxWidth: 540,
m: 0, m: 0,
}} }}
> >
Váš firemní AI asistent zeptejte se na cokoli, nebo přiložte Zeptejte se na cokoli, nebo přiložte fakturu Odin ji přečte a
fakturu (PDF) k importu. připraví k uložení.
</Typography> </Typography>
</Box> </MotionBox>
</MotionBox>
)} )}
{/* Message bubbles */} {/* Message bubbles */}