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:
@@ -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 () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user