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 }) => ({ // !important so an ancestor `& svg { width }` rule (e.g. SidebarNav forces // 18px) can't override the mark's own sizing — `size` stays authoritative. width: "70% !important", height: "70% !important", 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 */} ); }