ed1bf20e6b
The mascot can wear a pet. Right-click it for Hide pet, Size (48-250 px), Choose pet (Sagi, featured, your own - deletable), Upload your own..., Import .codex-pet bundle..., Find more on codex-pets.net and Pet settings.... The worn pet and its size are viewer prefs (agenthub.pet, agenthub.pet.size); the pets live on the server under <data dir>/pets/<id>/ (server/lib/pets.js, /api/pets). A Codex pet is the .codex-pet.zip format: pet.json + a 1536x1872 spritesheet.webp, 8x9 frames of 192x208, one row per state. PetSprite.tsx plays the row of the mascot's mood; an uploaded image bobs. Only the two bundle files are read, sizes are checked from the zip directory before inflating, the sheet's WebP header must match, uploads are raster only (no SVG), ids are made by the server, every write is same-origin. The four featured pets (Goku, Bac Ho, Border Collie, 11) are codex-pets.net uploads with no redistribution licence, so none is bundled: a pick downloads it from codex-pets.net by a fixed id.
406 lines
10 KiB
CSS
406 lines
10 KiB
CSS
/**
|
|
* sagi.css - animations + mood expressions for the Sagi companion.
|
|
* 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ĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
.sagi-avatar {
|
|
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 <svg> 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 ── */
|
|
/* The mascot artwork is a flat trace (186 self-colored paths) with no part
|
|
grouping beyond the eyes, so there is no shared fill/stroke palette to set
|
|
here - every path carries its own `fill`. Only the extracted eye parts and
|
|
the new overlay glyphs need rules. */
|
|
.sagi-sparkle path {
|
|
fill: #fde68a;
|
|
}
|
|
.sagi-zzz text,
|
|
.sagi-bang text {
|
|
fill: #a5b4fc;
|
|
font-family: "JetBrains Mono", monospace;
|
|
font-size: 44px;
|
|
font-weight: 700;
|
|
}
|
|
|
|
/* ── default visibility: hide mood-only overlays ── */
|
|
.sagi-zzz,
|
|
.sagi-bang,
|
|
.sagi-sparkle {
|
|
display: none;
|
|
}
|
|
|
|
/* ── happy ── */
|
|
.sagi-avatar[data-mood="happy"] .sagi-sparkle {
|
|
display: block;
|
|
}
|
|
|
|
/* ── watching ── (attentive sway handled below via whole-mascot transform) */
|
|
|
|
/* ── worried ── (whole-mascot shake handled below) */
|
|
|
|
/* ── stuck ── (alert bang) */
|
|
.sagi-avatar[data-mood="stuck"] .sagi-bang {
|
|
display: block;
|
|
}
|
|
|
|
/* ── thinking ── head-tilt rock handled below */
|
|
|
|
/* ── sleeping / disconnected ── (eyes shut via static scaleY, no cursor tracking) */
|
|
.sagi-avatar[data-mood="sleeping"] .sagi-eye-blink,
|
|
.sagi-avatar[data-mood="disconnected"] .sagi-eye-blink {
|
|
transform: scaleY(0.08);
|
|
}
|
|
.sagi-avatar[data-mood="sleeping"] .sagi-zzz {
|
|
display: block;
|
|
}
|
|
.sagi-avatar[data-mood="disconnected"] {
|
|
opacity: 0.5;
|
|
filter: grayscale(0.65) drop-shadow(0 4px 12px rgba(0, 0, 0, 0.5));
|
|
}
|
|
|
|
/* ── 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: 306px 269px; /* mascot 560,375 */
|
|
}
|
|
.sagi-eye-right .sagi-eye-blink {
|
|
transform-origin: 570px 269px; /* mascot 823,375 */
|
|
}
|
|
|
|
/* ════════ animations ════════ */
|
|
@keyframes sagi-breathe {
|
|
0%,
|
|
100% {
|
|
transform: scale(1);
|
|
}
|
|
50% {
|
|
transform: scale(1.035);
|
|
}
|
|
}
|
|
@keyframes sagi-blink {
|
|
0%,
|
|
92%,
|
|
100% {
|
|
transform: scaleY(1);
|
|
}
|
|
96% {
|
|
transform: scaleY(0.1);
|
|
}
|
|
}
|
|
@keyframes sagi-shake {
|
|
0%,
|
|
100% {
|
|
transform: translateX(0) scale(1);
|
|
}
|
|
20% {
|
|
transform: translateX(-3px) scale(1.01);
|
|
}
|
|
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) rotate(0deg);
|
|
}
|
|
25% {
|
|
transform: translateY(-4px) rotate(-4deg);
|
|
}
|
|
50% {
|
|
transform: translateY(-6px) rotate(0deg);
|
|
}
|
|
75% {
|
|
transform: translateY(-4px) rotate(4deg);
|
|
}
|
|
}
|
|
@keyframes sagi-sparkle-twinkle {
|
|
0%,
|
|
100% {
|
|
transform: scale(0.6);
|
|
opacity: 0.4;
|
|
}
|
|
50% {
|
|
transform: scale(1);
|
|
opacity: 1;
|
|
}
|
|
}
|
|
@keyframes sagi-sway {
|
|
0%,
|
|
100% {
|
|
transform: rotate(0deg);
|
|
}
|
|
25% {
|
|
transform: rotate(-2.5deg);
|
|
}
|
|
75% {
|
|
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(0deg);
|
|
}
|
|
50% {
|
|
transform: rotate(-8deg);
|
|
}
|
|
}
|
|
@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"] {
|
|
animation: sagi-breathe 4s ease-in-out infinite;
|
|
}
|
|
/* 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
|
|
note in SagiAvatar.tsx). */
|
|
.sagi-avatar[data-mood="idle"] .sagi-eye-blink {
|
|
animation: sagi-blink 5s ease-in-out infinite;
|
|
}
|
|
/* happy: whole-mascot bob + twinkling sparkle */
|
|
.sagi-avatar[data-mood="happy"] {
|
|
animation: sagi-bob 0.5s ease-in-out 0s 4;
|
|
}
|
|
.sagi-avatar[data-mood="happy"] .sagi-sparkle {
|
|
animation: sagi-sparkle-twinkle 0.9s ease-in-out infinite;
|
|
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;
|
|
}
|
|
/* stuck: slow, drooping breathe (fatigued) */
|
|
.sagi-avatar[data-mood="stuck"] {
|
|
animation: sagi-droop 2.4s ease-in-out infinite;
|
|
}
|
|
/* thinking: rocking head tilt */
|
|
.sagi-avatar[data-mood="thinking"] {
|
|
animation: sagi-rock 1.8s ease-in-out infinite;
|
|
}
|
|
/* watching: attentive sway while a session is live */
|
|
.sagi-avatar[data-mood="watching"] {
|
|
animation: sagi-sway 3.2s ease-in-out infinite;
|
|
}
|
|
/* sleeping: slow breathe, droop */
|
|
.sagi-avatar[data-mood="sleeping"] {
|
|
animation: sagi-breathe 5s ease-in-out infinite;
|
|
}
|
|
|
|
/* ── reduced motion: kill all continuous animation ── */
|
|
.sagi-avatar[data-reduced="1"],
|
|
.sagi-avatar[data-reduced="1"] * {
|
|
animation: none !important;
|
|
}
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.sagi-avatar,
|
|
.sagi-avatar * {
|
|
animation: none !important;
|
|
}
|
|
.sagi-avatar-btn,
|
|
.sagi-error-dot,
|
|
.sagi-bubble-enter {
|
|
animation: none !important;
|
|
}
|
|
.sagi-avatar-btn,
|
|
.sagi-flyout {
|
|
transition: none !important;
|
|
}
|
|
}
|
|
|
|
/* ════════ shell (avatar button, bubble, panel) ════════ */
|
|
|
|
/* The draggable avatar. Fixed-positioned; left/top are set inline by the drag
|
|
hook. It docks to an edge (AssistiveTouch-style) and remembers where. */
|
|
.sagi-avatar-btn {
|
|
position: fixed;
|
|
z-index: 41; /* above the flyout */
|
|
background: transparent;
|
|
border: none;
|
|
padding: 0;
|
|
line-height: 0;
|
|
border-radius: 9999px;
|
|
cursor: grab;
|
|
touch-action: none; /* pointer drives the drag instead of scrolling on touch */
|
|
/* Glide to the snapped edge after a drag; killed mid-drag for 1:1 tracking. */
|
|
transition:
|
|
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;
|
|
}
|
|
.sagi-avatar-btn[data-dragging="1"] {
|
|
cursor: grabbing;
|
|
transition: transform 0.15s ease; /* no left/top easing while dragging */
|
|
}
|
|
.sagi-avatar-btn[data-dragging="1"]:hover {
|
|
transform: scale(1.1);
|
|
}
|
|
|
|
/* Self-clamping flyout that holds the bubble / panel next to the avatar. Its
|
|
left/top are computed and set inline by SagiFlyout so it never crops. */
|
|
.sagi-flyout {
|
|
position: fixed;
|
|
z-index: 40;
|
|
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;
|
|
outline-offset: 3px;
|
|
}
|
|
.sagi-error-dot {
|
|
position: absolute;
|
|
top: 2px;
|
|
right: 2px;
|
|
width: 14px;
|
|
height: 14px;
|
|
border-radius: 9999px;
|
|
background: #ef4444;
|
|
color: #fff;
|
|
font-size: 9px;
|
|
font-weight: 700;
|
|
display: flex;
|
|
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;
|
|
background: #1a1a28;
|
|
border: 1px solid #363650;
|
|
color: #e8e8f0;
|
|
font-size: 0.8125rem;
|
|
line-height: 1.2rem;
|
|
padding: 0.5rem 0.75rem;
|
|
border-radius: 0.75rem;
|
|
border-bottom-right-radius: 0.25rem;
|
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
|
|
}
|
|
.sagi-bubble-enter {
|
|
animation: sagi-bubble-in 0.22s ease-out;
|
|
}
|
|
@keyframes sagi-bubble-in {
|
|
from {
|
|
opacity: 0;
|
|
transform: translateY(6px) scale(0.96);
|
|
}
|
|
to {
|
|
opacity: 1;
|
|
transform: translateY(0) scale(1);
|
|
}
|
|
}
|
|
.sagi-bubble.sagi-no-anim {
|
|
animation: none;
|
|
}
|
|
|
|
/* ════════ a custom pet (PetSprite.tsx) ════════ */
|
|
|
|
/* An uploaded still image has no frames of its own: it bobs, faster while an
|
|
agent works and slower asleep. A Codex spritesheet animates by its frames. */
|
|
.sagi-pet-image {
|
|
animation: sagi-pet-bob 2.6s ease-in-out infinite;
|
|
-webkit-user-drag: none;
|
|
}
|
|
.sagi-pet-image[data-mood="thinking"] {
|
|
animation-duration: 0.9s;
|
|
}
|
|
.sagi-pet-image[data-mood="sleeping"] {
|
|
animation-duration: 5s;
|
|
}
|
|
@keyframes sagi-pet-bob {
|
|
0%,
|
|
100% {
|
|
transform: translateY(0);
|
|
}
|
|
50% {
|
|
transform: translateY(-4%);
|
|
}
|
|
}
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.sagi-pet-image {
|
|
animation: none;
|
|
}
|
|
}
|
|
|
|
/* The pet menu may overlap the mascot; it sits above it while open. */
|
|
.sagi-flyout[data-front="1"] {
|
|
z-index: 42;
|
|
}
|