feat(odin): animated Odin brand mark (AI core orbiting business data)

A self-contained animated SVG mark in BOHA red: a 4-point AI spark (Odin) that
slow-rotates and breathes, a data node orbiting a faint ring (the assistant at
the centre of the business system). An "idle" and a "thinking" state (faster
orbit + red glow); honours prefers-reduced-motion. Used as the sidebar brand,
the empty-state hero, and the busy/thinking indicator.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-08 20:22:48 +02:00
parent 769767c33d
commit 702a4984ee
3 changed files with 122 additions and 17 deletions

View File

@@ -0,0 +1,117 @@
import { keyframes, styled } from "@mui/material/styles";
import Box from "@mui/material/Box";
import { useReducedMotion } from "framer-motion";
/**
* Odin's animated brand mark: an AI "spark" (Odin) with a data node orbiting a
* ring — the assistant at the centre of the business system, in BOHA red. The
* spark slow-rotates and breathes; the node orbits. In the "thinking" state
* everything speeds up and the tile glows. Honours prefers-reduced-motion.
*
* The animations live in a `styled("svg")` (not inline style): Emotion only
* injects the @keyframes rule when the keyframes object is used inside
* sx/css/styled, and the child selectors keep the SVG elements plain/typed.
*/
const breathe = keyframes`
0%, 100% { transform: scale(0.9); opacity: 0.9; }
50% { transform: scale(1.08); opacity: 1; }
`;
const spin = keyframes`
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
`;
// Reduce-motion safe: opacity only (no transforms / no travel).
const glow = keyframes`
0%, 100% { opacity: 0.55; }
50% { opacity: 1; }
`;
const Glyph = styled("svg", {
shouldForwardProp: (p) => p !== "thinking" && p !== "reduce",
})<{ thinking: boolean; reduce: boolean }>(({ thinking, reduce }) => ({
width: "70%",
height: "70%",
overflow: "visible",
display: "block",
...(reduce
? {
// Reduce-motion: keep a calm opacity glow (no spin/orbit/travel). The
// `!important` + class specificity intentionally overrides the app-wide
// reduced-motion reset in GlobalStyles for this one small mark, since an
// opacity pulse is accessibility-safe (it isn't vestibular motion).
"& .odin-spark": {
animation: `${glow} ${thinking ? "1.1s" : "3s"} ease-in-out infinite !important`,
},
}
: {
"& .odin-orbit": {
transformBox: "view-box",
transformOrigin: "12px 12px",
animation: `${spin} ${thinking ? "1.6s" : "5.5s"} linear infinite`,
},
"& .odin-spin": {
transformBox: "view-box",
transformOrigin: "12px 12px",
animation: `${spin} ${thinking ? "4s" : "11s"} linear infinite`,
},
"& .odin-spark": {
transformBox: "fill-box",
transformOrigin: "center",
animation: `${breathe} ${thinking ? "1s" : "2.8s"} ease-in-out infinite`,
},
}),
}));
export interface OdinMarkProps {
size?: number;
state?: "idle" | "thinking";
}
export default function OdinMark({ size = 28, state = "idle" }: OdinMarkProps) {
const reduce = !!useReducedMotion();
const thinking = state === "thinking";
return (
<Box
sx={(t) => ({
width: size,
height: size,
borderRadius: "32%",
flexShrink: 0,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
background: `linear-gradient(135deg, ${t.vars!.palette.primary.main}, ${t.vars!.palette.primary.dark})`,
boxShadow: thinking
? `0 0 ${Math.round(size * 0.5)}px 0 rgba(${t.vars!.palette.primary.mainChannel} / 0.55)`
: "none",
transition: "box-shadow 0.3s ease",
})}
>
<Glyph viewBox="0 0 24 24" thinking={thinking} reduce={reduce}>
{/* orbit ring */}
<circle
cx="12"
cy="12"
r="9.2"
fill="none"
stroke="#fff"
strokeOpacity="0.28"
strokeWidth="1"
/>
{/* orbiting data node */}
<g className="odin-orbit">
<circle cx="12" cy="2.8" r="1.4" fill="#fff" />
</g>
{/* spark — group slow-rotates, the star breathes about its own centre */}
<g className="odin-spin">
<path
className="odin-spark"
d="M12 4.5 C12.5 9, 13 9.5, 17.5 12 C13 14.5, 12.5 15, 12 19.5 C11.5 15, 11 14.5, 6.5 12 C11 9.5, 11.5 9, 12 4.5 Z"
fill="#fff"
/>
</g>
</Glyph>
</Box>
);
}

View File

@@ -5,6 +5,7 @@ import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem"; import MenuItem from "@mui/material/MenuItem";
import IconButton from "@mui/material/IconButton"; import IconButton from "@mui/material/IconButton";
import { Button, TextField, Modal, ConfirmDialog } from "../../ui"; import { Button, TextField, Modal, ConfirmDialog } from "../../ui";
import OdinMark from "./OdinMark";
interface OdinSidebarProps { interface OdinSidebarProps {
conversations: { id: number; title: string }[]; conversations: { id: number; title: string }[];
@@ -78,20 +79,7 @@ export default function OdinSidebar({
{/* Brand + New chat */} {/* Brand + New chat */}
<Box sx={{ p: 1.5, display: "flex", flexDirection: "column", gap: 1 }}> <Box sx={{ p: 1.5, display: "flex", flexDirection: "column", gap: 1 }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 1, px: 0.5 }}> <Box sx={{ display: "flex", alignItems: "center", gap: 1, px: 0.5 }}>
<Box <OdinMark size={28} />
sx={{
px: 0.75,
py: 0.25,
borderRadius: 1,
fontSize: 11,
fontWeight: 700,
letterSpacing: 0.5,
color: "common.white",
bgcolor: "primary.main",
}}
>
AI
</Box>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}> <Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
Odin Odin
</Typography> </Typography>

View File

@@ -1,7 +1,7 @@
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 CircularProgress from "@mui/material/CircularProgress";
import type { ChatTurn } from "./types"; import type { ChatTurn } from "./types";
import OdinMark from "./OdinMark";
interface OdinThreadProps { interface OdinThreadProps {
turns: ChatTurn[]; turns: ChatTurn[];
@@ -84,7 +84,7 @@ export default function OdinThread({
px: 2, px: 2,
}} }}
> >
<OdinAvatar size={44} /> <OdinMark size={56} />
<Typography variant="body2" sx={{ color: "text.secondary" }}> <Typography variant="body2" sx={{ color: "text.secondary" }}>
Dobrý den, jsem Odin. Zeptejte se, nebo přiložte fakturu (PDF) k Dobrý den, jsem Odin. Zeptejte se, nebo přiložte fakturu (PDF) k
importu. importu.
@@ -147,7 +147,7 @@ export default function OdinThread({
color: "text.secondary", color: "text.secondary",
}} }}
> >
<CircularProgress size={14} /> <OdinMark size={22} state="thinking" />
<Typography variant="caption" sx={{ color: "inherit" }}> <Typography variant="caption" sx={{ color: "inherit" }}>
Pracuji Pracuji
</Typography> </Typography>