fix(sagi): anchor mood animations to the mascot, and let the bubble follow it
The seven per-mood `transform-origin: 640px 720px` rules were written in
mascot.svg's 1280-unit space but applied to the root <svg>, where CSS
resolves a length against the 68px CSS box. The pivot landed ~9x the
element away, so `scale(1.035)` ("gentle breathing") swung the whole
mascot ~20px up-left every 4s - and the speech bubble, positioned from
the untransformed box, stayed put while Sagi visibly moved.
- One `transform-origin: 50% 92%` on `.sagi-avatar` (feet pivot); a
percentage so it cannot be misresolved against either reference box.
- Eye-blink and sparkle origins rebased onto `transform-box: view-box`,
whose 0,0 is the viewBox min (253.5 106) - the eyelids were closing
off-target and the sparkle twinkled around a point outside itself.
- `sagi-rock` returns to 0deg; it used to park `thinking` at a
permanent -6deg tilt.
- `.sagi-flyout` gets the avatar button's exact left/top easing so the
bubble glides with the snap instead of teleporting, and the bubble no
longer unmounts mid-drag (the panel still does - dragging a 320px
panel around is jank).
- Reduced motion now also covers `.sagi-bubble-enter` and both
transitions; dropped a `~ *` selector that never matched.
This commit is contained in:
@@ -3,6 +3,8 @@
|
||||
* 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ĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -10,6 +12,13 @@
|
||||
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 <svg> 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 ── */
|
||||
@@ -64,12 +73,19 @@
|
||||
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) ── */
|
||||
/* ── 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: 560px 375px;
|
||||
transform-origin: 306px 269px; /* mascot 560,375 */
|
||||
}
|
||||
.sagi-eye-right .sagi-eye-blink {
|
||||
transform-origin: 823px 375px;
|
||||
transform-origin: 570px 269px; /* mascot 823,375 */
|
||||
}
|
||||
|
||||
/* ════════ animations ════════ */
|
||||
@@ -148,13 +164,15 @@
|
||||
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(-6deg);
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
50% {
|
||||
transform: rotate(-11deg);
|
||||
transform: rotate(-8deg);
|
||||
}
|
||||
}
|
||||
@keyframes sagi-droop {
|
||||
@@ -189,7 +207,6 @@
|
||||
/* 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
|
||||
@@ -200,36 +217,30 @@
|
||||
/* 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;
|
||||
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;
|
||||
transform-origin: 640px 720px;
|
||||
}
|
||||
/* stuck: slow, drooping breathe (fatigued) */
|
||||
.sagi-avatar[data-mood="stuck"] {
|
||||
animation: sagi-droop 2.4s ease-in-out infinite;
|
||||
transform-origin: 640px 720px;
|
||||
}
|
||||
/* thinking: rocking head tilt */
|
||||
.sagi-avatar[data-mood="thinking"] {
|
||||
animation: sagi-rock 1.8s ease-in-out infinite;
|
||||
transform-origin: 640px 720px;
|
||||
}
|
||||
/* watching: attentive sway while a session is live */
|
||||
.sagi-avatar[data-mood="watching"] {
|
||||
animation: sagi-sway 3.2s ease-in-out infinite;
|
||||
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 ── */
|
||||
@@ -243,9 +254,14 @@
|
||||
animation: none !important;
|
||||
}
|
||||
.sagi-avatar-btn,
|
||||
.sagi-error-dot {
|
||||
.sagi-error-dot,
|
||||
.sagi-bubble-enter {
|
||||
animation: none !important;
|
||||
}
|
||||
.sagi-avatar-btn,
|
||||
.sagi-flyout {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ════════ shell (avatar button, bubble, panel) ════════ */
|
||||
@@ -295,6 +311,14 @@
|
||||
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;
|
||||
@@ -342,7 +366,6 @@
|
||||
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