feat(avatar): let Oos react to what their bots do (#141)
A bot's Oo now cheers for a moment when its run finishes, the stage Oo follows the pointer and squishes when tapped, and chat headers show the speaking bot's own Oo instead of the generic mascot. Small avatars in lists only animate while working or waiting so long lists stay calm. Claude-Session: https://claude.ai/code/session_01EKuME5CgL1ddGGN7LGvtBj Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
5f3aa8f3fe
commit
55df4a6439
@ -3,16 +3,33 @@ import {
|
|||||||
ACTIVE_RUN_STATUSES,
|
ACTIVE_RUN_STATUSES,
|
||||||
DEFAULT_OO_AVATAR,
|
DEFAULT_OO_AVATAR,
|
||||||
decodeOoAvatar,
|
decodeOoAvatar,
|
||||||
|
OO_SUCCESS_SECONDS,
|
||||||
ooIdentityShapeIndex,
|
ooIdentityShapeIndex,
|
||||||
|
ooMomentAfterStatus,
|
||||||
ooMomentForStatus,
|
ooMomentForStatus,
|
||||||
resolveOoColor,
|
resolveOoColor,
|
||||||
} from "@rakazo/core";
|
} from "@rakazo/core";
|
||||||
import { memo, useMemo } from "react";
|
import { memo, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Image, View } from "react-native";
|
import { Image, View } from "react-native";
|
||||||
import { useI18n } from "../lib/i18n";
|
import { useI18n } from "../lib/i18n";
|
||||||
import { NativeSymbol } from "./native-symbol";
|
import { NativeSymbol } from "./native-symbol";
|
||||||
import { OoFigure } from "./oo-figure";
|
import { OoFigure } from "./oo-figure";
|
||||||
|
|
||||||
|
/** A short moment after a status change, such as joy when a run finishes. */
|
||||||
|
function useOoStatusMoment(status?: string): OoMoment | null {
|
||||||
|
const previous = useRef(status);
|
||||||
|
const [moment, setMoment] = useState<OoMoment | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const next = ooMomentAfterStatus(previous.current, status);
|
||||||
|
previous.current = status;
|
||||||
|
setMoment(next);
|
||||||
|
if (!next) return;
|
||||||
|
const timer = setTimeout(() => setMoment(null), OO_SUCCESS_SECONDS * 1000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [status]);
|
||||||
|
return moment;
|
||||||
|
}
|
||||||
|
|
||||||
export const BotAvatar = memo(function BotAvatar({
|
export const BotAvatar = memo(function BotAvatar({
|
||||||
color,
|
color,
|
||||||
size = 54,
|
size = 54,
|
||||||
@ -37,6 +54,7 @@ export const BotAvatar = memo(function BotAvatar({
|
|||||||
const look = useMemo(() => decodeOoAvatar(color) ?? DEFAULT_OO_AVATAR, [color]);
|
const look = useMemo(() => decodeOoAvatar(color) ?? DEFAULT_OO_AVATAR, [color]);
|
||||||
const seed = identity || (picture ? "agent" : color) || "agent";
|
const seed = identity || (picture ? "agent" : color) || "agent";
|
||||||
const body = resolveOoColor(seed, look.color).hex;
|
const body = resolveOoColor(seed, look.color).hex;
|
||||||
|
const statusMoment = useOoStatusMoment(status);
|
||||||
return (
|
return (
|
||||||
<View style={{ width: size, height: size }}>
|
<View style={{ width: size, height: size }}>
|
||||||
{picture ? (
|
{picture ? (
|
||||||
@ -49,7 +67,7 @@ export const BotAvatar = memo(function BotAvatar({
|
|||||||
avatar={look}
|
avatar={look}
|
||||||
color={body}
|
color={body}
|
||||||
fallbackShape={look.shape ?? ooIdentityShapeIndex(seed)}
|
fallbackShape={look.shape ?? ooIdentityShapeIndex(seed)}
|
||||||
moment={moment ?? ooMomentForStatus(status)}
|
moment={moment ?? statusMoment ?? ooMomentForStatus(status)}
|
||||||
size={size}
|
size={size}
|
||||||
still={still}
|
still={still}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -70,6 +70,7 @@ export function StageHero({
|
|||||||
status={botStatus}
|
status={botStatus}
|
||||||
pose={pose}
|
pose={pose}
|
||||||
size={avatarSize}
|
size={avatarSize}
|
||||||
|
interactive
|
||||||
className="drop-shadow-[0_6px_16px_rgba(0,0,0,0.12)] dark:drop-shadow-[0_6px_24px_rgba(0,0,0,0.4)]"
|
className="drop-shadow-[0_6px_16px_rgba(0,0,0,0.12)] dark:drop-shadow-[0_6px_24px_rgba(0,0,0,0.4)]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -19,7 +19,6 @@ import {
|
|||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
EvooMascot,
|
|
||||||
Popover,
|
Popover,
|
||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
@ -795,7 +794,12 @@ const MessageView = memo(function MessageView({
|
|||||||
<div className="mb-2 flex items-center justify-between">
|
<div className="mb-2 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<div className="relative flex items-center justify-center size-7 rounded-xl bg-foreground/[0.08] border border-border shadow-xs">
|
<div className="relative flex items-center justify-center size-7 rounded-xl bg-foreground/[0.08] border border-border shadow-xs">
|
||||||
<EvooMascot size={22} interactive={false} pose={isLive ? "working" : "idle"} />
|
<BotAvatar
|
||||||
|
color={speakerBot?.color || speakerColorDef.hex}
|
||||||
|
identity={message.botId ?? "bot"}
|
||||||
|
size={22}
|
||||||
|
moment={isLive ? "working" : "idle"}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
|
|||||||
@ -80,6 +80,7 @@ export function ooMomentForStatus(status?: string, streaming = false): OoMoment
|
|||||||
case "leased":
|
case "leased":
|
||||||
return "working";
|
return "working";
|
||||||
case "waiting_input":
|
case "waiting_input":
|
||||||
|
case "waiting_takeover":
|
||||||
return "waiting";
|
return "waiting";
|
||||||
case "error":
|
case "error":
|
||||||
case "failed":
|
case "failed":
|
||||||
@ -557,3 +558,18 @@ export function ooFrame({ rig, avatar, moment, t, life, still = false }: OoFrame
|
|||||||
export function ooBoop(life: OoLife, t: number): void {
|
export function ooBoop(life: OoLife, t: number): void {
|
||||||
life.boopAt = t;
|
life.boopAt = t;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** How long an Oo celebrates a finished run before it settles back to idle. */
|
||||||
|
export const OO_SUCCESS_SECONDS = 2.6;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The moment to show right after a status change: a run that just finished
|
||||||
|
* successfully earns a short celebration, everything else follows the status.
|
||||||
|
*/
|
||||||
|
export function ooMomentAfterStatus(
|
||||||
|
previous: string | undefined,
|
||||||
|
status: string | undefined,
|
||||||
|
): OoMoment | null {
|
||||||
|
const wasWorking = ooMomentForStatus(previous) === "working";
|
||||||
|
return wasWorking && (status === "idle" || status === "completed") ? "success" : null;
|
||||||
|
}
|
||||||
|
|||||||
@ -7,7 +7,13 @@ import {
|
|||||||
OO_RIG_EYES,
|
OO_RIG_EYES,
|
||||||
OO_SPECIES,
|
OO_SPECIES,
|
||||||
} from "./oo-avatar.js";
|
} from "./oo-avatar.js";
|
||||||
import { createOoLife, ooFrame, ooMomentForStatus, ooSeededRandom } from "./oo-motion.js";
|
import {
|
||||||
|
createOoLife,
|
||||||
|
ooFrame,
|
||||||
|
ooMomentAfterStatus,
|
||||||
|
ooMomentForStatus,
|
||||||
|
ooSeededRandom,
|
||||||
|
} from "./oo-motion.js";
|
||||||
import {
|
import {
|
||||||
OO_DEFAULT_COLOR,
|
OO_DEFAULT_COLOR,
|
||||||
OO_PALETTE_HEXES,
|
OO_PALETTE_HEXES,
|
||||||
@ -202,3 +208,13 @@ describe("oo palette", () => {
|
|||||||
expect(ooIdentityShapeIndex("maya")).toBeLessThan(8);
|
expect(ooIdentityShapeIndex("maya")).toBeLessThan(8);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("oo moments after a status change", () => {
|
||||||
|
it("celebrates a finished run only", () => {
|
||||||
|
expect(ooMomentAfterStatus("running", "completed")).toBe("success");
|
||||||
|
expect(ooMomentAfterStatus("running", "idle")).toBe("success");
|
||||||
|
expect(ooMomentAfterStatus("running", "failed")).toBeNull();
|
||||||
|
expect(ooMomentAfterStatus("waiting_input", "idle")).toBeNull();
|
||||||
|
expect(ooMomentAfterStatus(undefined, "idle")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
42
packages/ui-web/src/bot-avatar-moment.test.tsx
Normal file
42
packages/ui-web/src/bot-avatar-moment.test.tsx
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { act } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||||
|
import { BotAvatar } from "./bot-avatar.js";
|
||||||
|
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
vi.useFakeTimers();
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.append(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
act(() => root.unmount());
|
||||||
|
container.remove();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
function moment() {
|
||||||
|
return container.querySelector("svg[data-oo-moment]")?.getAttribute("data-oo-moment");
|
||||||
|
}
|
||||||
|
|
||||||
|
it("celebrates when a run finishes, then settles", () => {
|
||||||
|
act(() => root.render(<BotAvatar color="#8B5CF6" status="running" size={72} />));
|
||||||
|
expect(moment()).toBe("working");
|
||||||
|
act(() => root.render(<BotAvatar color="#8B5CF6" status="completed" size={72} />));
|
||||||
|
expect(moment()).toBe("success");
|
||||||
|
act(() => vi.advanceTimersByTime(3000));
|
||||||
|
expect(moment()).toBe("idle");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not celebrate a failed run", () => {
|
||||||
|
act(() => root.render(<BotAvatar color="#8B5CF6" status="running" size={72} />));
|
||||||
|
act(() => root.render(<BotAvatar color="#8B5CF6" status="failed" size={72} />));
|
||||||
|
expect(moment()).toBe("concerned");
|
||||||
|
});
|
||||||
@ -4,11 +4,13 @@ import {
|
|||||||
DEFAULT_OO_AVATAR,
|
DEFAULT_OO_AVATAR,
|
||||||
decodeOoAvatar,
|
decodeOoAvatar,
|
||||||
OO_CLASSIC_SHAPE_COUNT,
|
OO_CLASSIC_SHAPE_COUNT,
|
||||||
|
OO_SUCCESS_SECONDS,
|
||||||
ooIdentityShapeIndex,
|
ooIdentityShapeIndex,
|
||||||
|
ooMomentAfterStatus,
|
||||||
ooMomentForStatus,
|
ooMomentForStatus,
|
||||||
resolveOoColor,
|
resolveOoColor,
|
||||||
} from "@rakazo/core";
|
} from "@rakazo/core";
|
||||||
import { memo, useId, useMemo } from "react";
|
import { memo, useEffect, useId, useMemo, useRef, useState } from "react";
|
||||||
import { normalizeEvooPose } from "./evoo-character/pose-map.js";
|
import { normalizeEvooPose } from "./evoo-character/pose-map.js";
|
||||||
import type { EvooPose } from "./evoo-character/types.js";
|
import type { EvooPose } from "./evoo-character/types.js";
|
||||||
import { cn } from "./lib/utils.js";
|
import { cn } from "./lib/utils.js";
|
||||||
@ -55,6 +57,21 @@ export function ooMomentForPose(pose: EvooPose): OoMoment {
|
|||||||
return POSE_MOMENT[canonical] ?? (canonical as OoMoment);
|
return POSE_MOMENT[canonical] ?? (canonical as OoMoment);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A short moment after a status change, such as joy when a run finishes. */
|
||||||
|
export function useOoStatusMoment(status?: string): OoMoment | null {
|
||||||
|
const previous = useRef(status);
|
||||||
|
const [moment, setMoment] = useState<OoMoment | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const next = ooMomentAfterStatus(previous.current, status);
|
||||||
|
previous.current = status;
|
||||||
|
setMoment(next);
|
||||||
|
if (!next) return;
|
||||||
|
const timer = setTimeout(() => setMoment(null), OO_SUCCESS_SECONDS * 1000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [status]);
|
||||||
|
return moment;
|
||||||
|
}
|
||||||
|
|
||||||
export interface BotAvatarProps {
|
export interface BotAvatarProps {
|
||||||
color: string;
|
color: string;
|
||||||
size?: number;
|
size?: number;
|
||||||
@ -66,6 +83,8 @@ export interface BotAvatarProps {
|
|||||||
moment?: OoMoment;
|
moment?: OoMoment;
|
||||||
/** Draw one still frame. */
|
/** Draw one still frame. */
|
||||||
still?: boolean;
|
still?: boolean;
|
||||||
|
/** Eyes follow the pointer and a tap boops the Oo. */
|
||||||
|
interactive?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -106,6 +125,7 @@ export const BotAvatar = memo(function BotAvatar({
|
|||||||
pose,
|
pose,
|
||||||
moment,
|
moment,
|
||||||
still,
|
still,
|
||||||
|
interactive,
|
||||||
className,
|
className,
|
||||||
}: BotAvatarProps) {
|
}: BotAvatarProps) {
|
||||||
const isWorking = ACTIVE_RUN_STATUSES.some((s) => s === status);
|
const isWorking = ACTIVE_RUN_STATUSES.some((s) => s === status);
|
||||||
@ -119,7 +139,9 @@ export const BotAvatar = memo(function BotAvatar({
|
|||||||
() => resolveOoColor(effectiveId, parsed.color),
|
() => resolveOoColor(effectiveId, parsed.color),
|
||||||
[effectiveId, parsed.color],
|
[effectiveId, parsed.color],
|
||||||
);
|
);
|
||||||
const activeMoment = moment ?? (pose ? ooMomentForPose(pose) : ooMomentForStatus(status));
|
const statusMoment = useOoStatusMoment(status);
|
||||||
|
const activeMoment =
|
||||||
|
moment ?? statusMoment ?? (pose ? ooMomentForPose(pose) : ooMomentForStatus(status));
|
||||||
|
|
||||||
if (parsed.isImage && parsed.imageUrl) {
|
if (parsed.isImage && parsed.imageUrl) {
|
||||||
return (
|
return (
|
||||||
@ -154,6 +176,7 @@ export const BotAvatar = memo(function BotAvatar({
|
|||||||
moment={activeMoment}
|
moment={activeMoment}
|
||||||
size={size}
|
size={size}
|
||||||
still={still}
|
still={still}
|
||||||
|
interactive={interactive}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -2,14 +2,17 @@ import type { OoAvatar, OoFrame, OoLife, OoMoment, OoPaint, OoShape } from "@rak
|
|||||||
import {
|
import {
|
||||||
buildOoRig,
|
buildOoRig,
|
||||||
createOoLife,
|
createOoLife,
|
||||||
|
OO_LIST_MOMENTS,
|
||||||
OO_RIG_INSET,
|
OO_RIG_INSET,
|
||||||
OO_RIG_SIZE,
|
OO_RIG_SIZE,
|
||||||
OO_STILL_TIME,
|
OO_STILL_TIME,
|
||||||
|
ooBoop,
|
||||||
ooFrame,
|
ooFrame,
|
||||||
ooLodForSize,
|
ooLodForSize,
|
||||||
ooSeededRandom,
|
ooSeededRandom,
|
||||||
} from "@rakazo/core";
|
} from "@rakazo/core";
|
||||||
import { type ReactNode, useEffect, useId, useMemo, useRef } from "react";
|
import type { PointerEvent, ReactNode } from "react";
|
||||||
|
import { useEffect, useId, useMemo, useRef } from "react";
|
||||||
|
|
||||||
export interface OoFigureProps {
|
export interface OoFigureProps {
|
||||||
avatar: OoAvatar;
|
avatar: OoAvatar;
|
||||||
@ -21,12 +24,17 @@ export interface OoFigureProps {
|
|||||||
size: number;
|
size: number;
|
||||||
/** Draw one still frame (thumbnails, pickers). Reduced motion always draws still. */
|
/** Draw one still frame (thumbnails, pickers). Reduced motion always draws still. */
|
||||||
still?: boolean;
|
still?: boolean;
|
||||||
|
/** Eyes follow the pointer and a tap boops the Oo. */
|
||||||
|
interactive?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The body fills the avatar box; ears, hats and effects may reach past it. */
|
/** The body fills the avatar box; ears, hats and effects may reach past it. */
|
||||||
const OO_VIEWBOX = `${OO_RIG_INSET} ${OO_RIG_INSET + 4} ${OO_RIG_SIZE - 2 * OO_RIG_INSET} ${OO_RIG_SIZE - 2 * OO_RIG_INSET}`;
|
const OO_VIEWBOX = `${OO_RIG_INSET} ${OO_RIG_INSET + 4} ${OO_RIG_SIZE - 2 * OO_RIG_INSET} ${OO_RIG_SIZE - 2 * OO_RIG_INSET}`;
|
||||||
|
|
||||||
|
/** Above this size an Oo always moves; smaller ones only for list moments. */
|
||||||
|
const LIVELY_SIZE = 56;
|
||||||
|
|
||||||
/* ---------- shared ticker: one animation frame loop for every visible Oo ---------- */
|
/* ---------- shared ticker: one animation frame loop for every visible Oo ---------- */
|
||||||
|
|
||||||
type Tick = (now: number) => void;
|
type Tick = (now: number) => void;
|
||||||
@ -199,6 +207,7 @@ export function OoFigure({
|
|||||||
moment,
|
moment,
|
||||||
size,
|
size,
|
||||||
still = false,
|
still = false,
|
||||||
|
interactive = false,
|
||||||
className,
|
className,
|
||||||
}: OoFigureProps) {
|
}: OoFigureProps) {
|
||||||
const gradient = `oo${useId().replace(/[^a-zA-Z0-9-_]/g, "")}`;
|
const gradient = `oo${useId().replace(/[^a-zA-Z0-9-_]/g, "")}`;
|
||||||
@ -222,14 +231,20 @@ export function OoFigure({
|
|||||||
);
|
);
|
||||||
const svgRef = useRef<SVGSVGElement>(null);
|
const svgRef = useRef<SVGSVGElement>(null);
|
||||||
const lifeRef = useRef<OoLife | null>(null);
|
const lifeRef = useRef<OoLife | null>(null);
|
||||||
|
const startRef = useRef(0);
|
||||||
|
// Small Oos in lists only move for the moments people watch for.
|
||||||
|
const lively = !still && (size > LIVELY_SIZE || OO_LIST_MOMENTS.includes(moment));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const svg = svgRef.current;
|
const svg = svgRef.current;
|
||||||
if (!svg || still || prefersReducedMotion() || typeof requestAnimationFrame !== "function")
|
if (!svg || !lively || prefersReducedMotion() || typeof requestAnimationFrame !== "function")
|
||||||
return;
|
return;
|
||||||
if (!lifeRef.current) lifeRef.current = createOoLife();
|
if (!lifeRef.current) {
|
||||||
|
lifeRef.current = createOoLife();
|
||||||
|
startRef.current = performance.now() / 1000 - Math.random() * 10;
|
||||||
|
}
|
||||||
const life = lifeRef.current;
|
const life = lifeRef.current;
|
||||||
const start = performance.now() / 1000 - Math.random() * 10;
|
const start = startRef.current;
|
||||||
const tick = (now: number) =>
|
const tick = (now: number) =>
|
||||||
applyFrame(svg, ooFrame({ rig, avatar, moment, t: now - start, life }), rig.mouthColor);
|
applyFrame(svg, ooFrame({ rig, avatar, moment, t: now - start, life }), rig.mouthColor);
|
||||||
let unsubscribe: (() => void) | null = null;
|
let unsubscribe: (() => void) | null = null;
|
||||||
@ -249,7 +264,26 @@ export function OoFigure({
|
|||||||
observer.disconnect();
|
observer.disconnect();
|
||||||
unsubscribe?.();
|
unsubscribe?.();
|
||||||
};
|
};
|
||||||
}, [rig, avatar, moment, still]);
|
}, [rig, avatar, moment, lively]);
|
||||||
|
|
||||||
|
const pointerHandlers = interactive
|
||||||
|
? {
|
||||||
|
onPointerMove: (event: PointerEvent<SVGSVGElement>) => {
|
||||||
|
const life = lifeRef.current;
|
||||||
|
if (!life || lod !== "full") return;
|
||||||
|
const box = event.currentTarget.getBoundingClientRect();
|
||||||
|
const x = ((event.clientX - box.left) / box.width) * 2 - 1;
|
||||||
|
const y = ((event.clientY - box.top) / box.height) * 2 - 1;
|
||||||
|
life.pointer = { x: Math.max(-1, Math.min(1, x)), y: Math.max(-1, Math.min(1, y)) };
|
||||||
|
},
|
||||||
|
onPointerLeave: () => {
|
||||||
|
if (lifeRef.current) lifeRef.current.pointer = null;
|
||||||
|
},
|
||||||
|
onPointerDown: () => {
|
||||||
|
if (lifeRef.current) ooBoop(lifeRef.current, performance.now() / 1000 - startRef.current);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {};
|
||||||
|
|
||||||
const { colors } = rig;
|
const { colors } = rig;
|
||||||
return (
|
return (
|
||||||
@ -263,6 +297,7 @@ export function OoFigure({
|
|||||||
data-oo-species={avatar.species}
|
data-oo-species={avatar.species}
|
||||||
data-oo-moment={moment}
|
data-oo-moment={moment}
|
||||||
overflow="visible"
|
overflow="visible"
|
||||||
|
{...pointerHandlers}
|
||||||
>
|
>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id={`${gradient}-body`} x1="0" y1="0" x2="1" y2="1">
|
<linearGradient id={`${gradient}-body`} x1="0" y1="0" x2="1" y2="1">
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user