Files
Claude-Code-Monitor/client/src/components/Sagi/sagi.css
T
nntrivi2001 4cc39f5069 feat(companion): rename Tabby companion to Sagi, replace cat avatar with mascot
Full rename across files, identifiers, CSS classes, and user-facing strings
(en/vi locales, ARCHITECTURE.md). Replaces the hand-drawn animated cat
avatar with the pig-superhero mascot artwork, split into parts to keep
per-eye cursor tracking and blink animation working; whole-mascot mood
transforms (breathe/bob/shake/tilt) and new zzz/bang/sparkle overlays
carry the rest of the mood expression since the traced art has no shared
palette to key off of.
2026-08-19 11:07:59 +07:00

274 lines
6.7 KiB
CSS

/**
* 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ĩ <vinnt@smartgift.vn>
*/
.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;
}