/** * 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; } /* ── moments: one-shot reactions to an event or a pat (useSagiBrain), played over the mood for ~2.4 s. Declared after the moods so they win the cascade. Shared by Sagi's own art and an uploaded still image. ── */ .sagi-avatar[data-moment="jump"], .sagi-pet-image[data-moment="jump"] { animation: sagi-hop 0.6s cubic-bezier(0.3, 0, 0.4, 1) 0s 3; } .sagi-avatar[data-moment="wave"], .sagi-pet-image[data-moment="wave"] { animation: sagi-wiggle 0.5s ease-in-out 0s 4; } .sagi-avatar[data-moment="fail"], .sagi-pet-image[data-moment="fail"] { animation: sagi-shake 0.35s ease-in-out 0s 5; } .sagi-avatar[data-moment="wait"], .sagi-pet-image[data-moment="wait"] { animation: sagi-tap 0.4s ease-in-out 0s 5; } .sagi-avatar[data-moment="review"], .sagi-pet-image[data-moment="review"] { animation: sagi-peek 2.2s ease-in-out 0s 1; } .sagi-avatar[data-moment="jump"] .sagi-sparkle { animation: sagi-sparkle-twinkle 0.9s ease-in-out infinite; transform-origin: 797px 274px; } @keyframes sagi-hop { 0%, 100% { transform: translateY(0) scale(1, 1); } 15% { transform: translateY(0) scale(1.08, 0.92); } 45% { transform: translateY(-14%) scale(0.96, 1.05); } 75% { transform: translateY(0) scale(1.04, 0.96); } } @keyframes sagi-wiggle { 0%, 100% { transform: rotate(0deg); } 25% { transform: rotate(-9deg); } 75% { transform: rotate(9deg); } } @keyframes sagi-tap { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-4%); } } @keyframes sagi-peek { 0%, 100% { transform: rotate(0deg) translateX(0); } 25%, 40% { transform: rotate(-7deg) translateX(-4%); } 60%, 75% { transform: rotate(7deg) translateX(4%); } } /* ── 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; } /* Sessions waiting on the user, when none errored: the same dot, amber. */ .sagi-error-dot[data-kind="waiting"] { background: #f59e0b; animation: none; } .sagi-pet-image { transform-origin: 50% 92%; } .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; } .sagi-bubble[data-tone="error"] { border-color: rgba(239, 68, 68, 0.6); } .sagi-bubble[data-tone="waiting"] { border-color: rgba(245, 158, 11, 0.6); } /* The session(s) a bubble is about, and the button that opens it. */ .sagi-bubble-detail { display: flex; align-items: center; gap: 0.5rem; margin-top: 0.25rem; font-size: 0.75rem; line-height: 1rem; color: #a8a8c0; } .sagi-bubble-subject { min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .sagi-bubble-open { flex-shrink: 0; padding: 0.125rem 0.5rem; border-radius: 9999px; background: rgba(139, 92, 246, 0.2); color: #c4b5fd; font-weight: 500; transition: background 0.15s; } .sagi-bubble-open:hover { background: rgba(139, 92, 246, 0.35); } @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; } /* ════════ the pet's chat window (PetChat.tsx) ════════ */ /* The reference's soft gradient, in the theme's own accent rather than a fixed colour: a tint of the accent at the top fading into the surface, so it reads in every light and dark theme. Replies sit on the plain surface like the reference's white bubbles; the user's own are accent-solid (white text is only ever on accent-solid). */ .pet-chat { background: linear-gradient( 180deg, color-mix(in srgb, rgb(var(--accent)) 22%, rgb(var(--surface-1))) 0%, color-mix(in srgb, rgb(var(--accent)) 9%, rgb(var(--surface-1))) 45%, rgb(var(--surface-1)) 100% ); } .pet-chat-head { border-bottom: 1px solid color-mix(in srgb, rgb(var(--accent)) 18%, transparent); } .pet-chat-reply { background: rgb(var(--surface-0)); border: 1px solid color-mix(in srgb, rgb(var(--accent)) 12%, transparent); } .pet-chat-tools { background: color-mix(in srgb, rgb(var(--surface-0)) 70%, transparent); border: 1px dashed color-mix(in srgb, rgb(var(--accent)) 25%, transparent); } .pet-chat-foot { background: rgb(var(--surface-0)); border-top: 1px solid rgb(var(--border) / 0.6); } /* Three bouncing dots while a reply is written. */ .pet-chat-dot { width: 6px; height: 6px; border-radius: 9999px; background: rgb(var(--accent)); opacity: 0.5; animation: pet-chat-dot 1.2s ease-in-out infinite; } .pet-chat-dot:nth-child(2) { animation-delay: 0.15s; } .pet-chat-dot:nth-child(3) { animation-delay: 0.3s; } @keyframes pet-chat-dot { 0%, 60%, 100% { transform: translateY(0); opacity: 0.35; } 30% { transform: translateY(-4px); opacity: 0.9; } } /* The chat's terminal, attached but off-screen. It keeps a real size (so tmux does not shrink the pane to fit it) and a non-zero box (so the paste registry counts it as on screen). */ .pet-chat-term { position: fixed; left: -10000px; top: 0; width: 760px; height: 460px; pointer-events: none; } @media (prefers-reduced-motion: reduce) { .pet-chat-dot { animation: none; } } /* ════════ a pat's reaction, drawn on the pet itself ════════ */ /* Hearts float up from the pet and its line shows beside it, inside the avatar button — so a pat reads even while the panel or the chat is open (the speech bubble hides then). */ .sagi-reaction { position: absolute; inset: 0; pointer-events: none; } .sagi-hearts { position: absolute; left: 50%; top: 10%; display: flex; gap: 2px; transform: translateX(-50%); font-size: 14px; line-height: 1; } .sagi-hearts > span { animation: sagi-heart-up 1.6s ease-out forwards; opacity: 0; } .sagi-hearts > span:nth-child(2) { animation-delay: 0.15s; font-size: 18px; } .sagi-hearts > span:nth-child(3) { animation-delay: 0.3s; } @keyframes sagi-heart-up { 0% { opacity: 0; transform: translateY(6px) scale(0.6); } 20% { opacity: 1; transform: translateY(-4px) scale(1); } 100% { opacity: 0; transform: translateY(-34px) scale(0.9); } } /* The line sits on the side away from the screen edge the pet docks to. */ .sagi-reaction-text { position: absolute; top: 30%; white-space: nowrap; padding: 0.2rem 0.55rem; border-radius: 9999px; background: rgb(var(--surface-0)); border: 1px solid color-mix(in srgb, rgb(var(--accent)) 35%, transparent); color: rgb(var(--fg-primary)); font-size: 0.75rem; line-height: 1.1rem; box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18); animation: sagi-reaction-in 2.4s ease-out forwards; } .sagi-reaction-text[data-side="right"] { right: calc(100% + 6px); } .sagi-reaction-text[data-side="left"] { left: calc(100% + 6px); } @keyframes sagi-reaction-in { 0% { opacity: 0; transform: scale(0.85); } 10%, 80% { opacity: 1; transform: scale(1); } 100% { opacity: 0; } } @media (prefers-reduced-motion: reduce) { .sagi-hearts > span, .sagi-reaction-text { animation: none; opacity: 1; } }