diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 1bacc0a9..6a097045 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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 `` 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. | diff --git a/client/src/components/Sagi/Sagi.tsx b/client/src/components/Sagi/Sagi.tsx index 88326d22..1e6a178d 100644 --- a/client/src/components/Sagi/Sagi.tsx +++ b/client/src/components/Sagi/Sagi.tsx @@ -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 ( -
+
{children}
); @@ -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 && ( )} - {!place.dragging && !open && brain.bubble && ( + {!open && brain.bubble && ( diff --git a/client/src/components/Sagi/sagi.css b/client/src/components/Sagi/sagi.css index 68d9d5a8..4af1c88d 100644 --- a/client/src/components/Sagi/sagi.css +++ b/client/src/components/Sagi/sagi.css @@ -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ĩ */ @@ -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 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; }