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
+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>