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".
This commit is contained in:
2026-08-28 11:17:27 +07:00
parent cc4e71c127
commit 52fbd852af
2 changed files with 96 additions and 15 deletions
+89 -14
View File
@@ -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;
@@ -21,7 +21,12 @@ fs.mkdirSync(DIST);
fs.writeFileSync(path.join(DIST, "index.html"), "<!doctype html><title>from-runtime</title>");
// 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 () => {