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; 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) */ /* ── worried ── (whole-mascot shake handled below) */
@@ -49,7 +49,7 @@
display: block; display: block;
} }
/* ── thinking ── head tilt handled below */ /* ── thinking ── head-tilt rock handled below */
/* ── sleeping / disconnected ── (eyes shut via static scaleY, no cursor tracking) */ /* ── sleeping / disconnected ── (eyes shut via static scaleY, no cursor tracking) */
.sagi-avatar[data-mood="sleeping"] .sagi-eye-blink, .sagi-avatar[data-mood="sleeping"] .sagi-eye-blink,
@@ -95,22 +95,34 @@
@keyframes sagi-shake { @keyframes sagi-shake {
0%, 0%,
100% { 100% {
transform: translateX(0); transform: translateX(0) scale(1);
} }
25% { 20% {
transform: translateX(-2px); transform: translateX(-3px) scale(1.01);
} }
75% { 40% {
transform: translateX(2px); transform: translateX(3px) scale(0.99);
}
60% {
transform: translateX(-2px) scale(1.01);
}
80% {
transform: translateX(2px) scale(1);
} }
} }
@keyframes sagi-bob { @keyframes sagi-bob {
0%, 0%,
100% { 100% {
transform: translateY(0); transform: translateY(0) rotate(0deg);
}
25% {
transform: translateY(-4px) rotate(-4deg);
} }
50% { 50% {
transform: translateY(-3px); transform: translateY(-6px) rotate(0deg);
}
75% {
transform: translateY(-4px) rotate(4deg);
} }
} }
@keyframes sagi-sparkle-twinkle { @keyframes sagi-sparkle-twinkle {
@@ -124,6 +136,55 @@
opacity: 1; 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 */ /* idle: gentle breathing + occasional blink */
.sagi-avatar[data-mood="idle"] { .sagi-avatar[data-mood="idle"] {
@@ -145,19 +206,24 @@
animation: sagi-sparkle-twinkle 0.9s ease-in-out infinite; animation: sagi-sparkle-twinkle 0.9s ease-in-out infinite;
transform-origin: 1073px 424px; transform-origin: 1073px 424px;
} }
/* worried: shake */ /* worried: alarmed shake */
.sagi-avatar[data-mood="worried"] { .sagi-avatar[data-mood="worried"] {
animation: sagi-shake 0.35s ease-in-out 0s 3; animation: sagi-shake 0.35s ease-in-out 0s 3;
transform-origin: 640px 720px; transform-origin: 640px 720px;
} }
/* stuck: slow breathe */ /* stuck: slow, drooping breathe (fatigued) */
.sagi-avatar[data-mood="stuck"] { .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; transform-origin: 640px 720px;
} }
/* thinking: subtle head tilt */ /* thinking: rocking head tilt */
.sagi-avatar[data-mood="thinking"] { .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; transform-origin: 640px 720px;
} }
/* sleeping: slow breathe, droop */ /* sleeping: slow breathe, droop */
@@ -176,6 +242,10 @@
.sagi-avatar * { .sagi-avatar * {
animation: none !important; animation: none !important;
} }
.sagi-avatar-btn,
.sagi-error-dot {
animation: none !important;
}
} }
/* ════════ shell (avatar button, bubble, panel) ════════ */ /* ════════ shell (avatar button, bubble, panel) ════════ */
@@ -197,10 +267,14 @@
left 0.28s cubic-bezier(0.22, 1, 0.36, 1), left 0.28s cubic-bezier(0.22, 1, 0.36, 1),
top 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; transform 0.15s ease;
animation: sagi-enter 0.4s cubic-bezier(0.22, 1, 0.36, 1);
} }
.sagi-avatar-btn:hover { .sagi-avatar-btn:hover {
transform: scale(1.06); transform: scale(1.06);
} }
.sagi-avatar-btn:active {
transform: scale(0.94);
}
.sagi-avatar-btn:focus-visible { .sagi-avatar-btn:focus-visible {
outline: 2px solid #818cf8; outline: 2px solid #818cf8;
outline-offset: 3px; outline-offset: 3px;
@@ -241,6 +315,7 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border: 2px solid #0c0c14; border: 2px solid #0c0c14;
animation: sagi-error-pulse 1.2s ease-in-out infinite;
} }
.sagi-bubble { .sagi-bubble {
max-width: 16rem; max-width: 16rem;
@@ -21,7 +21,12 @@ fs.mkdirSync(DIST);
fs.writeFileSync(path.join(DIST, "index.html"), "<!doctype html><title>from-runtime</title>"); 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 // 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.CLAUDE_HOME = TMP;
process.env.DASHBOARD_DB_PATH = path.join(TMP, "test.db"); process.env.DASHBOARD_DB_PATH = path.join(TMP, "test.db");
process.env.DASHBOARD_LIVENESS_PROBE = "0"; process.env.DASHBOARD_LIVENESS_PROBE = "0";
@@ -81,6 +86,7 @@ describe("DASHBOARD_CLIENT_DIST pointing at a missing directory", () => {
after(() => { after(() => {
if (srv) srv.close(); if (srv) srv.close();
process.env.NODE_ENV = PREV_NODE_ENV;
}); });
it("answers the API and only 404s the UI route", async () => { it("answers the API and only 404s the UI route", async () => {