/** * sagi.css - animations + mood expressions for the Sagi companion. * Colors mirror the app's theme tokens (accent #6366f1/#818cf8, surface scale) * with warm pink accents (ears, cheeks, nose) for cuteness. All continuous * motion is disabled under [data-reduced="1"] and prefers-reduced-motion. * @author Nguyễn Ngọc Trí Vĩ */ .sagi-avatar { overflow: visible; cursor: pointer; filter: drop-shadow(0 4px 12px rgba(0, 0, 0, 0.5)); } /* ── palette ── */ /* The mascot artwork is a flat trace (186 self-colored paths) with no part grouping beyond the eyes, so there is no shared fill/stroke palette to set here - every path carries its own `fill`. Only the extracted eye parts and the new overlay glyphs need rules. */ .sagi-sparkle path { fill: #fde68a; } .sagi-zzz text, .sagi-bang text { fill: #a5b4fc; font-family: "JetBrains Mono", monospace; font-size: 44px; font-weight: 700; } /* ── default visibility: hide mood-only overlays ── */ .sagi-zzz, .sagi-bang, .sagi-sparkle { display: none; } /* ── happy ── */ .sagi-avatar[data-mood="happy"] .sagi-sparkle { display: block; } /* ── watching ── (alert, tail/ear motion handled below via whole-mascot transform) */ /* ── worried ── (whole-mascot shake handled below) */ /* ── stuck ── (alert bang) */ .sagi-avatar[data-mood="stuck"] .sagi-bang { display: block; } /* ── thinking ── head tilt handled below */ /* ── sleeping / disconnected ── (eyes shut via static scaleY, no cursor tracking) */ .sagi-avatar[data-mood="sleeping"] .sagi-eye-blink, .sagi-avatar[data-mood="disconnected"] .sagi-eye-blink { transform: scaleY(0.08); } .sagi-avatar[data-mood="sleeping"] .sagi-zzz { display: block; } .sagi-avatar[data-mood="disconnected"] { opacity: 0.5; filter: grayscale(0.65) drop-shadow(0 4px 12px rgba(0, 0, 0, 0.5)); } /* ── eyes: per-eye blink transform-origin (native mascot.svg coordinates) ── */ .sagi-eye-left .sagi-eye-blink { transform-origin: 560px 375px; } .sagi-eye-right .sagi-eye-blink { transform-origin: 823px 375px; } /* ════════ animations ════════ */ @keyframes sagi-breathe { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.035); } } @keyframes sagi-blink { 0%, 92%, 100% { transform: scaleY(1); } 96% { transform: scaleY(0.1); } } @keyframes sagi-shake { 0%, 100% { transform: translateX(0); } 25% { transform: translateX(-2px); } 75% { transform: translateX(2px); } } @keyframes sagi-bob { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-3px); } } @keyframes sagi-sparkle-twinkle { 0%, 100% { transform: scale(0.6); opacity: 0.4; } 50% { transform: scale(1); opacity: 1; } } /* idle: gentle breathing + occasional blink */ .sagi-avatar[data-mood="idle"] { animation: sagi-breathe 4s ease-in-out infinite; transform-origin: 640px 720px; } /* Blink lives on the inner group so it never overrides the outer group's eye-tracking translate (which would freeze the eyes - see the CSS-blink note in SagiAvatar.tsx). */ .sagi-avatar[data-mood="idle"] .sagi-eye-blink { animation: sagi-blink 5s ease-in-out infinite; } /* happy: whole-mascot bob + twinkling sparkle */ .sagi-avatar[data-mood="happy"] { animation: sagi-bob 0.5s ease-in-out 0s 4; transform-origin: 640px 720px; } .sagi-avatar[data-mood="happy"] .sagi-sparkle { animation: sagi-sparkle-twinkle 0.9s ease-in-out infinite; transform-origin: 1073px 424px; } /* worried: shake */ .sagi-avatar[data-mood="worried"] { animation: sagi-shake 0.35s ease-in-out 0s 3; transform-origin: 640px 720px; } /* stuck: slow breathe */ .sagi-avatar[data-mood="stuck"] { animation: sagi-breathe 2.4s ease-in-out infinite; transform-origin: 640px 720px; } /* thinking: subtle head tilt */ .sagi-avatar[data-mood="thinking"] { transform: rotate(-6deg); transform-origin: 640px 720px; } /* sleeping: slow breathe, droop */ .sagi-avatar[data-mood="sleeping"] { animation: sagi-breathe 5s ease-in-out infinite; transform-origin: 640px 720px; } /* ── reduced motion: kill all continuous animation ── */ .sagi-avatar[data-reduced="1"], .sagi-avatar[data-reduced="1"] * { animation: none !important; } @media (prefers-reduced-motion: reduce) { .sagi-avatar, .sagi-avatar * { animation: none !important; } } /* ════════ shell (avatar button, bubble, panel) ════════ */ /* The draggable avatar. Fixed-positioned; left/top are set inline by the drag hook. It docks to an edge (AssistiveTouch-style) and remembers where. */ .sagi-avatar-btn { position: fixed; z-index: 41; /* above the flyout */ background: transparent; border: none; padding: 0; line-height: 0; border-radius: 9999px; cursor: grab; touch-action: none; /* pointer drives the drag instead of scrolling on touch */ /* Glide to the snapped edge after a drag; killed mid-drag for 1:1 tracking. */ transition: left 0.28s cubic-bezier(0.22, 1, 0.36, 1), top 0.28s cubic-bezier(0.22, 1, 0.36, 1), transform 0.15s ease; } .sagi-avatar-btn:hover { transform: scale(1.06); } .sagi-avatar-btn:focus-visible { outline: 2px solid #818cf8; outline-offset: 3px; } .sagi-avatar-btn[data-dragging="1"] { cursor: grabbing; transition: transform 0.15s ease; /* no left/top easing while dragging */ } .sagi-avatar-btn[data-dragging="1"]:hover { transform: scale(1.1); } /* Self-clamping flyout that holds the bubble / panel next to the avatar. Its left/top are computed and set inline by SagiFlyout so it never crops. */ .sagi-flyout { position: fixed; z-index: 40; display: flex; flex-direction: column; gap: 0.5rem; } .sagi-avatar-btn:focus-visible { outline: 2px solid #818cf8; outline-offset: 3px; } .sagi-error-dot { position: absolute; top: 2px; right: 2px; width: 14px; height: 14px; border-radius: 9999px; background: #ef4444; color: #fff; font-size: 9px; font-weight: 700; display: flex; align-items: center; justify-content: center; border: 2px solid #0c0c14; } .sagi-bubble { max-width: 16rem; background: #1a1a28; border: 1px solid #363650; color: #e8e8f0; font-size: 0.8125rem; line-height: 1.2rem; padding: 0.5rem 0.75rem; border-radius: 0.75rem; border-bottom-right-radius: 0.25rem; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45); } .sagi-bubble-enter { animation: sagi-bubble-in 0.22s ease-out; } @keyframes sagi-bubble-in { from { opacity: 0; transform: translateY(6px) scale(0.96); } to { opacity: 1; transform: translateY(0) scale(1); } } .sagi-avatar[data-reduced="1"] ~ * .sagi-bubble-enter, .sagi-bubble.sagi-no-anim { animation: none; }