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:
+1
-1
@@ -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. |
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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