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
+1 -1
View File
@@ -2304,7 +2304,7 @@ The mood state machine in `deriveMood` resolves to a single expression using a f
| --- | --- |
| **`brain.ts`** | Pure, framework-free core. Exposes a `WSMessage` reducer (`reduceSagi`) and a mood state machine (`deriveMood`) with the priority order `disconnected > worried > stuck > happy > thinking > watching > sleeping > idle`. The clock is injected and there are zero side effects, so the brain is fully unit-tested in isolation. |
| **`useSagiBrain.ts`** | The **only** consumer of the global `eventBus`. Wires the pure brain to real timers (idle / sleep / stuck), the speech-bubble queue, mute, and clear-alerts. Produces the derived `{ mood, status, bubble }` the presentational components render. |
| **`SagiAvatar.tsx`** | Pure presentational SVG mascot. The `data-mood` attribute drives CSS; pupils track the cursor. Rendered at 90×90 px (`size` default, kept in sync with `SAGI_SIZE` in `useSagiPosition.ts`, which is the draggable button's real footprint) through a `viewBox` cropped to the artwork's measured bounding box instead of the exporter's padded `0 0 1280 1280` canvas. Path data is never rescaled, so every coordinate constant (`MAX_PUPIL_SHIFT`, the `transform-origin` values in `sagi.css`) stays in the original 1280-unit space. |
| **`SagiAvatar.tsx`** | Pure presentational SVG mascot. The `data-mood` attribute drives CSS; pupils track the cursor. Rendered at 90×90 px (`size` default, kept in sync with `SAGI_SIZE` in `useSagiPosition.ts`, which is the draggable button's real footprint) through a `viewBox` cropped to the artwork's measured bounding box instead of the exporter's padded `0 0 1280 1280` canvas. Path data is never rescaled, so `MAX_PUPIL_SHIFT` stays in the original 1280-unit space. The `transform-origin` values in `sagi.css` do **not**: an origin on an inner SVG element resolves against `transform-box: view-box`, whose 0,0 sits at the `viewBox` min (`253.5 106`), so those are mascot coordinates **minus that offset**. The root `.sagi-avatar` origin is a percentage on purpose — on the outermost `<svg>` a length would be measured against the 68 px CSS box, and the resulting ~9× overshoot turns every mood animation into a whole-avatar lurch. |
| **`SpeechBubble.tsx`** | Pure presentational speech bubble. |
| **`SagiPanel.tsx`** | Pure presentational panel: status readout + quick actions + the Ask box. |
| **`Sagi.tsx`** | Shell component. Mounted once in `client/src/components/Layout.tsx` as a sibling of `UpdateNotifier`. Owns open/closed state, the `⌘B` / `Esc` shortcuts, reduced-motion detection, and navigation. |
+13 -3
View File
@@ -90,6 +90,7 @@ interface Anchor {
size: number;
side: "left" | "right";
openUp: boolean;
dragging: boolean;
}
function usePrefersReducedMotion(): boolean {
@@ -154,7 +155,14 @@ function SagiFlyout({ anchor, children }: { anchor: Anchor; children: ReactNode
}, [place]);
return (
<div ref={ref} className="sagi-flyout" style={style}>
<div
ref={ref}
className="sagi-flyout"
// Mirrors the avatar button: eased left/top so the flyout glides along
// with the mascot when it snaps to an edge, 1:1 (no easing) mid-drag.
data-dragging={anchor.dragging ? "1" : "0"}
style={style}
>
{children}
</div>
);
@@ -235,11 +243,13 @@ export function Sagi() {
size: place.size,
side: place.side,
openUp: place.openUp,
dragging: place.dragging,
};
return (
<>
{/* Flyouts are hidden while dragging so they don't chase the mascot. */}
{/* The panel is too big to drag around, so it hides mid-drag. The bubble
does not - it is Sagi talking, and it has to stay by its mouth. */}
{!place.dragging && open && (
<SagiFlyout anchor={anchor}>
<SagiPanel
@@ -254,7 +264,7 @@ export function Sagi() {
</SagiFlyout>
)}
{!place.dragging && !open && brain.bubble && (
{!open && brain.bubble && (
<SagiFlyout anchor={anchor}>
<SpeechBubble text={brain.bubble} onDismiss={brain.dismissBubble} />
</SagiFlyout>
+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;
}