0cb5e41faf
- Fix: an errored session alerted on every update, and Clear alerts only dropped it in memory, so its next update (or a reload) brought the alert straight back. An error now alerts on the transition into error only; cleared sessions are remembered (agenthub.pet.dismissedErrors) and stay quiet until they recover and fail anew. - Quips, greetings, notifications and Ask answers move to i18n (EN/VI); Ask understands Vietnamese keywords and names the sessions it counts. - The pet goes by the worn pet's name; click it in the panel to rename (agenthub.pet.names, synced to the account). - Bubbles name the session they are about, with an Open button (never a resume); a burst of the same event coalesces into one count. - Moments: a new session waves, a finish hops, a subagent peeks, a wait taps, an error flinches — on Sagi, Codex sprites and still images. Idle fidgets, a once-per-session greeting, a "Needs you" list, an amber waiting dot. - Pat (heart button, Ask, or stroking the pet) reacts on the pet itself, so it shows while the panel or chat is open. - Panel drops Run Claude / Activity / Sessions / Errored. - Ask hands off to a chat window (PetChat) on the default AI tool (Claude/Codex): transcript as bubbles, follow-ups and CLI menus through an off-screen attached terminal; no new server route. Verified on HEAD + this diff in a clean worktree: tsc -b, the full client suite (1334/1335; the one failure, ConversationView.follow, is flaky under load and passes 3/3 alone) and server me-prefs.test.js. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
689 lines
17 KiB
CSS
689 lines
17 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;
|
|
}
|
|
|
|
/* ── moments: one-shot reactions to an event or a pat (useSagiBrain), played
|
|
over the mood for ~2.4 s. Declared after the moods so they win the cascade.
|
|
Shared by Sagi's own art and an uploaded still image. ── */
|
|
.sagi-avatar[data-moment="jump"],
|
|
.sagi-pet-image[data-moment="jump"] {
|
|
animation: sagi-hop 0.6s cubic-bezier(0.3, 0, 0.4, 1) 0s 3;
|
|
}
|
|
.sagi-avatar[data-moment="wave"],
|
|
.sagi-pet-image[data-moment="wave"] {
|
|
animation: sagi-wiggle 0.5s ease-in-out 0s 4;
|
|
}
|
|
.sagi-avatar[data-moment="fail"],
|
|
.sagi-pet-image[data-moment="fail"] {
|
|
animation: sagi-shake 0.35s ease-in-out 0s 5;
|
|
}
|
|
.sagi-avatar[data-moment="wait"],
|
|
.sagi-pet-image[data-moment="wait"] {
|
|
animation: sagi-tap 0.4s ease-in-out 0s 5;
|
|
}
|
|
.sagi-avatar[data-moment="review"],
|
|
.sagi-pet-image[data-moment="review"] {
|
|
animation: sagi-peek 2.2s ease-in-out 0s 1;
|
|
}
|
|
.sagi-avatar[data-moment="jump"] .sagi-sparkle {
|
|
animation: sagi-sparkle-twinkle 0.9s ease-in-out infinite;
|
|
transform-origin: 797px 274px;
|
|
}
|
|
@keyframes sagi-hop {
|
|
0%,
|
|
100% {
|
|
transform: translateY(0) scale(1, 1);
|
|
}
|
|
15% {
|
|
transform: translateY(0) scale(1.08, 0.92);
|
|
}
|
|
45% {
|
|
transform: translateY(-14%) scale(0.96, 1.05);
|
|
}
|
|
75% {
|
|
transform: translateY(0) scale(1.04, 0.96);
|
|
}
|
|
}
|
|
@keyframes sagi-wiggle {
|
|
0%,
|
|
100% {
|
|
transform: rotate(0deg);
|
|
}
|
|
25% {
|
|
transform: rotate(-9deg);
|
|
}
|
|
75% {
|
|
transform: rotate(9deg);
|
|
}
|
|
}
|
|
@keyframes sagi-tap {
|
|
0%,
|
|
100% {
|
|
transform: translateY(0);
|
|
}
|
|
50% {
|
|
transform: translateY(-4%);
|
|
}
|
|
}
|
|
@keyframes sagi-peek {
|
|
0%,
|
|
100% {
|
|
transform: rotate(0deg) translateX(0);
|
|
}
|
|
25%,
|
|
40% {
|
|
transform: rotate(-7deg) translateX(-4%);
|
|
}
|
|
60%,
|
|
75% {
|
|
transform: rotate(7deg) translateX(4%);
|
|
}
|
|
}
|
|
|
|
/* ── 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;
|
|
}
|
|
/* Sessions waiting on the user, when none errored: the same dot, amber. */
|
|
.sagi-error-dot[data-kind="waiting"] {
|
|
background: #f59e0b;
|
|
animation: none;
|
|
}
|
|
.sagi-pet-image {
|
|
transform-origin: 50% 92%;
|
|
}
|
|
.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;
|
|
}
|
|
.sagi-bubble[data-tone="error"] {
|
|
border-color: rgba(239, 68, 68, 0.6);
|
|
}
|
|
.sagi-bubble[data-tone="waiting"] {
|
|
border-color: rgba(245, 158, 11, 0.6);
|
|
}
|
|
/* The session(s) a bubble is about, and the button that opens it. */
|
|
.sagi-bubble-detail {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
margin-top: 0.25rem;
|
|
font-size: 0.75rem;
|
|
line-height: 1rem;
|
|
color: #a8a8c0;
|
|
}
|
|
.sagi-bubble-subject {
|
|
min-width: 0;
|
|
flex: 1;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
.sagi-bubble-open {
|
|
flex-shrink: 0;
|
|
padding: 0.125rem 0.5rem;
|
|
border-radius: 9999px;
|
|
background: rgba(139, 92, 246, 0.2);
|
|
color: #c4b5fd;
|
|
font-weight: 500;
|
|
transition: background 0.15s;
|
|
}
|
|
.sagi-bubble-open:hover {
|
|
background: rgba(139, 92, 246, 0.35);
|
|
}
|
|
@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;
|
|
}
|
|
|
|
/* ════════ the pet's chat window (PetChat.tsx) ════════ */
|
|
|
|
/* The reference's soft gradient, in the theme's own accent rather than a fixed
|
|
colour: a tint of the accent at the top fading into the surface, so it
|
|
reads in every light and dark theme. Replies sit on the plain surface like
|
|
the reference's white bubbles; the user's own are accent-solid (white text
|
|
is only ever on accent-solid). */
|
|
.pet-chat {
|
|
background: linear-gradient(
|
|
180deg,
|
|
color-mix(in srgb, rgb(var(--accent)) 22%, rgb(var(--surface-1))) 0%,
|
|
color-mix(in srgb, rgb(var(--accent)) 9%, rgb(var(--surface-1))) 45%,
|
|
rgb(var(--surface-1)) 100%
|
|
);
|
|
}
|
|
.pet-chat-head {
|
|
border-bottom: 1px solid color-mix(in srgb, rgb(var(--accent)) 18%, transparent);
|
|
}
|
|
.pet-chat-reply {
|
|
background: rgb(var(--surface-0));
|
|
border: 1px solid color-mix(in srgb, rgb(var(--accent)) 12%, transparent);
|
|
}
|
|
.pet-chat-tools {
|
|
background: color-mix(in srgb, rgb(var(--surface-0)) 70%, transparent);
|
|
border: 1px dashed color-mix(in srgb, rgb(var(--accent)) 25%, transparent);
|
|
}
|
|
.pet-chat-foot {
|
|
background: rgb(var(--surface-0));
|
|
border-top: 1px solid rgb(var(--border) / 0.6);
|
|
}
|
|
/* Three bouncing dots while a reply is written. */
|
|
.pet-chat-dot {
|
|
width: 6px;
|
|
height: 6px;
|
|
border-radius: 9999px;
|
|
background: rgb(var(--accent));
|
|
opacity: 0.5;
|
|
animation: pet-chat-dot 1.2s ease-in-out infinite;
|
|
}
|
|
.pet-chat-dot:nth-child(2) {
|
|
animation-delay: 0.15s;
|
|
}
|
|
.pet-chat-dot:nth-child(3) {
|
|
animation-delay: 0.3s;
|
|
}
|
|
@keyframes pet-chat-dot {
|
|
0%,
|
|
60%,
|
|
100% {
|
|
transform: translateY(0);
|
|
opacity: 0.35;
|
|
}
|
|
30% {
|
|
transform: translateY(-4px);
|
|
opacity: 0.9;
|
|
}
|
|
}
|
|
/* The chat's terminal, attached but off-screen. It keeps a real size (so tmux
|
|
does not shrink the pane to fit it) and a non-zero box (so the paste
|
|
registry counts it as on screen). */
|
|
.pet-chat-term {
|
|
position: fixed;
|
|
left: -10000px;
|
|
top: 0;
|
|
width: 760px;
|
|
height: 460px;
|
|
pointer-events: none;
|
|
}
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.pet-chat-dot {
|
|
animation: none;
|
|
}
|
|
}
|
|
|
|
/* ════════ a pat's reaction, drawn on the pet itself ════════ */
|
|
|
|
/* Hearts float up from the pet and its line shows beside it, inside the
|
|
avatar button — so a pat reads even while the panel or the chat is open
|
|
(the speech bubble hides then). */
|
|
.sagi-reaction {
|
|
position: absolute;
|
|
inset: 0;
|
|
pointer-events: none;
|
|
}
|
|
.sagi-hearts {
|
|
position: absolute;
|
|
left: 50%;
|
|
top: 10%;
|
|
display: flex;
|
|
gap: 2px;
|
|
transform: translateX(-50%);
|
|
font-size: 14px;
|
|
line-height: 1;
|
|
}
|
|
.sagi-hearts > span {
|
|
animation: sagi-heart-up 1.6s ease-out forwards;
|
|
opacity: 0;
|
|
}
|
|
.sagi-hearts > span:nth-child(2) {
|
|
animation-delay: 0.15s;
|
|
font-size: 18px;
|
|
}
|
|
.sagi-hearts > span:nth-child(3) {
|
|
animation-delay: 0.3s;
|
|
}
|
|
@keyframes sagi-heart-up {
|
|
0% {
|
|
opacity: 0;
|
|
transform: translateY(6px) scale(0.6);
|
|
}
|
|
20% {
|
|
opacity: 1;
|
|
transform: translateY(-4px) scale(1);
|
|
}
|
|
100% {
|
|
opacity: 0;
|
|
transform: translateY(-34px) scale(0.9);
|
|
}
|
|
}
|
|
/* The line sits on the side away from the screen edge the pet docks to. */
|
|
.sagi-reaction-text {
|
|
position: absolute;
|
|
top: 30%;
|
|
white-space: nowrap;
|
|
padding: 0.2rem 0.55rem;
|
|
border-radius: 9999px;
|
|
background: rgb(var(--surface-0));
|
|
border: 1px solid color-mix(in srgb, rgb(var(--accent)) 35%, transparent);
|
|
color: rgb(var(--fg-primary));
|
|
font-size: 0.75rem;
|
|
line-height: 1.1rem;
|
|
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18);
|
|
animation: sagi-reaction-in 2.4s ease-out forwards;
|
|
}
|
|
.sagi-reaction-text[data-side="right"] {
|
|
right: calc(100% + 6px);
|
|
}
|
|
.sagi-reaction-text[data-side="left"] {
|
|
left: calc(100% + 6px);
|
|
}
|
|
@keyframes sagi-reaction-in {
|
|
0% {
|
|
opacity: 0;
|
|
transform: scale(0.85);
|
|
}
|
|
10%,
|
|
80% {
|
|
opacity: 1;
|
|
transform: scale(1);
|
|
}
|
|
100% {
|
|
opacity: 0;
|
|
}
|
|
}
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.sagi-hearts > span,
|
|
.sagi-reaction-text {
|
|
animation: none;
|
|
opacity: 1;
|
|
}
|
|
}
|