/** * 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. * Transform origins are the delicate part - see the comments on .sagi-avatar * and .sagi-eye-blink for which reference box each one resolves against. * @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)); /* Every mood animation pivots here: horizontally centred, near the feet, so a breathe/sway/rock reads as the mascot moving in place. MUST stay a percentage - on the root a length would be resolved against the 68px CSS box, not the 938-unit viewBox, and an origin ~9x the box away turns a 1.035 scale into a ~20px lurch (which is also what made the speech bubble look detached, since the bubble is positioned from the box, not the paint). */ transform-origin: 50% 92%; } /* ── 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 ── (attentive sway 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 rock 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 ── `transform-box: view-box` puts the reference box's 0,0 on the viewBox's min-x/min-y ("253.5 106"), so these are mascot.svg coordinates minus that offset. Getting it wrong slides the eyelid off the eye as it closes. */ .sagi-eye-blink, .sagi-sparkle { transform-box: view-box; } .sagi-eye-left .sagi-eye-blink { transform-origin: 306px 269px; /* mascot 560,375 */ } .sagi-eye-right .sagi-eye-blink { transform-origin: 570px 269px; /* mascot 823,375 */ } /* ════════ 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) scale(1); } 20% { transform: translateX(-3px) scale(1.01); } 40% { transform: translateX(3px) scale(0.99); } 60% { transform: translateX(-2px) scale(1.01); } 80% { transform: translateX(2px) scale(1); } } @keyframes sagi-bob { 0%, 100% { transform: translateY(0) rotate(0deg); } 25% { transform: translateY(-4px) rotate(-4deg); } 50% { transform: translateY(-6px) rotate(0deg); } 75% { transform: translateY(-4px) rotate(4deg); } } @keyframes sagi-sparkle-twinkle { 0%, 100% { transform: scale(0.6); opacity: 0.4; } 50% { transform: scale(1); opacity: 1; } } @keyframes sagi-sway { 0%, 100% { transform: rotate(0deg); } 25% { transform: rotate(-2.5deg); } 75% { transform: rotate(2.5deg); } } /* Pondering tilt that always comes back upright - the old -6deg..-11deg range never returned to 0, so a thinking Sagi just sat there permanently crooked. */ @keyframes sagi-rock { 0%, 100% { transform: rotate(0deg); } 50% { transform: rotate(-8deg); } } @keyframes sagi-droop { 0%, 100% { transform: scale(1) translateY(0); } 50% { transform: scale(0.985) translateY(2px); } } @keyframes sagi-enter { from { opacity: 0; transform: scale(0.6) translateY(12px); } to { opacity: 1; transform: scale(1) translateY(0); } } @keyframes sagi-error-pulse { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.15); } } /* idle: gentle breathing + occasional blink */ .sagi-avatar[data-mood="idle"] { animation: sagi-breathe 4s ease-in-out infinite; } /* 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; } .sagi-avatar[data-mood="happy"] .sagi-sparkle { animation: sagi-sparkle-twinkle 0.9s ease-in-out infinite; transform-origin: 797px 274px; /* sparkle centre, mascot 1050,380 */ } /* worried: alarmed shake */ .sagi-avatar[data-mood="worried"] { animation: sagi-shake 0.35s ease-in-out 0s 3; } /* stuck: slow, drooping breathe (fatigued) */ .sagi-avatar[data-mood="stuck"] { animation: sagi-droop 2.4s ease-in-out infinite; } /* thinking: rocking head tilt */ .sagi-avatar[data-mood="thinking"] { animation: sagi-rock 1.8s ease-in-out infinite; } /* watching: attentive sway while a session is live */ .sagi-avatar[data-mood="watching"] { animation: sagi-sway 3.2s ease-in-out infinite; } /* sleeping: slow breathe, droop */ .sagi-avatar[data-mood="sleeping"] { animation: sagi-breathe 5s ease-in-out infinite; } /* ── 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; } .sagi-avatar-btn, .sagi-error-dot, .sagi-bubble-enter { animation: none !important; } .sagi-avatar-btn, .sagi-flyout { transition: 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; animation: sagi-enter 0.4s cubic-bezier(0.22, 1, 0.36, 1); } .sagi-avatar-btn:hover { transform: scale(1.06); } .sagi-avatar-btn:active { transform: scale(0.94); } .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; /* Same curve/duration as .sagi-avatar-btn so the bubble travels *with* the mascot when it snaps to an edge instead of teleporting ahead of it. */ transition: left 0.28s cubic-bezier(0.22, 1, 0.36, 1), top 0.28s cubic-bezier(0.22, 1, 0.36, 1); } .sagi-flyout[data-dragging="1"] { transition: none; /* 1:1 with the pointer while dragging */ } .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; animation: sagi-error-pulse 1.2s ease-in-out infinite; } .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-bubble.sagi-no-anim { animation: none; } /* ════════ a custom pet (PetSprite.tsx) ════════ */ /* An uploaded still image has no frames of its own: it bobs, faster while an agent works and slower asleep. A Codex spritesheet animates by its frames. */ .sagi-pet-image { animation: sagi-pet-bob 2.6s ease-in-out infinite; -webkit-user-drag: none; } .sagi-pet-image[data-mood="thinking"] { animation-duration: 0.9s; } .sagi-pet-image[data-mood="sleeping"] { animation-duration: 5s; } @keyframes sagi-pet-bob { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-4%); } } @media (prefers-reduced-motion: reduce) { .sagi-pet-image { animation: none; } } /* The pet menu may overlap the mascot; it sits above it while open. */ .sagi-flyout[data-front="1"] { z-index: 42; }