From 702a4984ee46d9e489f52a6ba2c49182698024b5 Mon Sep 17 00:00:00 2001 From: BOHA Date: Mon, 8 Jun 2026 20:22:48 +0200 Subject: [PATCH] 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) --- src/admin/components/odin/OdinMark.tsx | 117 ++++++++++++++++++++++ src/admin/components/odin/OdinSidebar.tsx | 16 +-- src/admin/components/odin/OdinThread.tsx | 6 +- 3 files changed, 122 insertions(+), 17 deletions(-) create mode 100644 src/admin/components/odin/OdinMark.tsx diff --git a/src/admin/components/odin/OdinMark.tsx b/src/admin/components/odin/OdinMark.tsx new file mode 100644 index 0000000..0db8d6c --- /dev/null +++ b/src/admin/components/odin/OdinMark.tsx @@ -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 ( + ({ + 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", + })} + > + + {/* orbit ring */} + + {/* orbiting data node */} + + + + {/* spark — group slow-rotates, the star breathes about its own centre */} + + + + + + ); +} diff --git a/src/admin/components/odin/OdinSidebar.tsx b/src/admin/components/odin/OdinSidebar.tsx index 67d6448..8268b05 100644 --- a/src/admin/components/odin/OdinSidebar.tsx +++ b/src/admin/components/odin/OdinSidebar.tsx @@ -5,6 +5,7 @@ import Menu from "@mui/material/Menu"; import MenuItem from "@mui/material/MenuItem"; import IconButton from "@mui/material/IconButton"; import { Button, TextField, Modal, ConfirmDialog } from "../../ui"; +import OdinMark from "./OdinMark"; interface OdinSidebarProps { conversations: { id: number; title: string }[]; @@ -78,20 +79,7 @@ export default function OdinSidebar({ {/* Brand + New chat */} - - AI - + Odin diff --git a/src/admin/components/odin/OdinThread.tsx b/src/admin/components/odin/OdinThread.tsx index d80a2ef..4d6b8c9 100644 --- a/src/admin/components/odin/OdinThread.tsx +++ b/src/admin/components/odin/OdinThread.tsx @@ -1,7 +1,7 @@ import Box from "@mui/material/Box"; import Typography from "@mui/material/Typography"; -import CircularProgress from "@mui/material/CircularProgress"; import type { ChatTurn } from "./types"; +import OdinMark from "./OdinMark"; interface OdinThreadProps { turns: ChatTurn[]; @@ -84,7 +84,7 @@ export default function OdinThread({ px: 2, }} > - + Dobrý den, jsem Odin. Zeptejte se, nebo přiložte fakturu (PDF) k importu. @@ -147,7 +147,7 @@ export default function OdinThread({ color: "text.secondary", }} > - + Pracuji…