From 52fbd852af873a07bd31270079691bd29c88af9f Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Fri, 28 Aug 2026 11:17:27 +0700 Subject: [PATCH] feat(sagi): flesh out mood animations and add avatar interaction motion Gives watching/thinking real animations (previously static), adds character to happy/worried/stuck moods, and adds mount-in/press/error-pulse motion to the avatar button - all gated by prefers-reduced-motion. fix(test): pin NODE_ENV=production in client-dist-override.test.js The suite exercises server/index.js's production-only static-serving gate but never set NODE_ENV itself, so it silently depended on the ambient shell's NODE_ENV - failing whenever that wasn't "production". --- client/src/components/Sagi/sagi.css | 103 +++++++++++++++--- server/__tests__/client-dist-override.test.js | 8 +- 2 files changed, 96 insertions(+), 15 deletions(-) diff --git a/client/src/components/Sagi/sagi.css b/client/src/components/Sagi/sagi.css index c8fbfc2f..68d9d5a8 100644 --- a/client/src/components/Sagi/sagi.css +++ b/client/src/components/Sagi/sagi.css @@ -40,7 +40,7 @@ display: block; } -/* ── watching ── (alert, tail/ear motion handled below via whole-mascot transform) */ +/* ── watching ── (attentive sway handled below via whole-mascot transform) */ /* ── worried ── (whole-mascot shake handled below) */ @@ -49,7 +49,7 @@ display: block; } -/* ── thinking ── head tilt handled below */ +/* ── thinking ── head-tilt rock handled below */ /* ── sleeping / disconnected ── (eyes shut via static scaleY, no cursor tracking) */ .sagi-avatar[data-mood="sleeping"] .sagi-eye-blink, @@ -95,22 +95,34 @@ @keyframes sagi-shake { 0%, 100% { - transform: translateX(0); + transform: translateX(0) scale(1); } - 25% { - transform: translateX(-2px); + 20% { + transform: translateX(-3px) scale(1.01); } - 75% { - transform: translateX(2px); + 40% { + transform: translateX(3px) scale(0.99); + } + 60% { + transform: translateX(-2px) scale(1.01); + } + 80% { + transform: translateX(2px) scale(1); } } @keyframes sagi-bob { 0%, 100% { - transform: translateY(0); + transform: translateY(0) rotate(0deg); + } + 25% { + transform: translateY(-4px) rotate(-4deg); } 50% { - transform: translateY(-3px); + transform: translateY(-6px) rotate(0deg); + } + 75% { + transform: translateY(-4px) rotate(4deg); } } @keyframes sagi-sparkle-twinkle { @@ -124,6 +136,55 @@ opacity: 1; } } +@keyframes sagi-sway { + 0%, + 100% { + transform: rotate(0deg); + } + 25% { + transform: rotate(-2.5deg); + } + 75% { + transform: rotate(2.5deg); + } +} +@keyframes sagi-rock { + 0%, + 100% { + transform: rotate(-6deg); + } + 50% { + transform: rotate(-11deg); + } +} +@keyframes sagi-droop { + 0%, + 100% { + transform: scale(1) translateY(0); + } + 50% { + transform: scale(0.985) translateY(2px); + } +} +@keyframes sagi-enter { + from { + opacity: 0; + transform: scale(0.6) translateY(12px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} +@keyframes sagi-error-pulse { + 0%, + 100% { + transform: scale(1); + } + 50% { + transform: scale(1.15); + } +} /* idle: gentle breathing + occasional blink */ .sagi-avatar[data-mood="idle"] { @@ -145,19 +206,24 @@ animation: sagi-sparkle-twinkle 0.9s ease-in-out infinite; transform-origin: 1073px 424px; } -/* worried: shake */ +/* worried: alarmed shake */ .sagi-avatar[data-mood="worried"] { animation: sagi-shake 0.35s ease-in-out 0s 3; transform-origin: 640px 720px; } -/* stuck: slow breathe */ +/* stuck: slow, drooping breathe (fatigued) */ .sagi-avatar[data-mood="stuck"] { - animation: sagi-breathe 2.4s ease-in-out infinite; + animation: sagi-droop 2.4s ease-in-out infinite; transform-origin: 640px 720px; } -/* thinking: subtle head tilt */ +/* thinking: rocking head tilt */ .sagi-avatar[data-mood="thinking"] { - transform: rotate(-6deg); + 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 */ @@ -176,6 +242,10 @@ .sagi-avatar * { animation: none !important; } + .sagi-avatar-btn, + .sagi-error-dot { + animation: none !important; + } } /* ════════ shell (avatar button, bubble, panel) ════════ */ @@ -197,10 +267,14 @@ left 0.28s cubic-bezier(0.22, 1, 0.36, 1), top 0.28s cubic-bezier(0.22, 1, 0.36, 1), transform 0.15s ease; + animation: sagi-enter 0.4s cubic-bezier(0.22, 1, 0.36, 1); } .sagi-avatar-btn:hover { transform: scale(1.06); } +.sagi-avatar-btn:active { + transform: scale(0.94); +} .sagi-avatar-btn:focus-visible { outline: 2px solid #818cf8; outline-offset: 3px; @@ -241,6 +315,7 @@ align-items: center; justify-content: center; border: 2px solid #0c0c14; + animation: sagi-error-pulse 1.2s ease-in-out infinite; } .sagi-bubble { max-width: 16rem; diff --git a/server/__tests__/client-dist-override.test.js b/server/__tests__/client-dist-override.test.js index f0bc7c67..5e72d5d4 100644 --- a/server/__tests__/client-dist-override.test.js +++ b/server/__tests__/client-dist-override.test.js @@ -21,7 +21,12 @@ fs.mkdirSync(DIST); fs.writeFileSync(path.join(DIST, "index.html"), "from-runtime"); // Must be set BEFORE requiring the server: the data dir, discovery file and -// the static mount are all resolved at startup. +// the static mount are all resolved at startup. NODE_ENV=production is the +// gate server/index.js uses to decide whether to serve the client bundle at +// all - this suite exercises exactly that code path, so it cannot rely on +// whatever NODE_ENV the ambient shell happens to have. +const PREV_NODE_ENV = process.env.NODE_ENV; +process.env.NODE_ENV = "production"; process.env.CLAUDE_HOME = TMP; process.env.DASHBOARD_DB_PATH = path.join(TMP, "test.db"); process.env.DASHBOARD_LIVENESS_PROBE = "0"; @@ -81,6 +86,7 @@ describe("DASHBOARD_CLIENT_DIST pointing at a missing directory", () => { after(() => { if (srv) srv.close(); + process.env.NODE_ENV = PREV_NODE_ENV; }); it("answers the API and only 404s the UI route", async () => {