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.
This commit is contained in:
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
Reference in New Issue
Block a user