4cc39f5069
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.
274 lines
6.7 KiB
CSS
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;
|
|
}
|