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:
117
src/admin/components/odin/OdinMark.tsx
Normal file
117
src/admin/components/odin/OdinMark.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user