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:
2026-09-04 14:40:45 +07:00
parent 6cb7f4f843
commit 58774f211a
3 changed files with 52 additions and 19 deletions
+38 -15
View File
@@ -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;
}