feat(avatar): give mobile bots the same Oo face as web (#138)
Native avatars now draw the shared Oo rig with react-native-svg instead of a visor with two equal eyes, so the Oo eyes look the same on every surface. The robot/organic avatar setting is gone from web and mobile because every bot is now an Oo; uploaded pictures show on native too. Claude-Session: https://claude.ai/code/session_01EKuME5CgL1ddGGN7LGvtBj Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
47988602e2
commit
7db3a56fe8
@ -4,7 +4,6 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import { View } from "react-native";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import { KeyboardProvider } from "react-native-keyboard-controller";
|
||||
import { AvatarStyleProvider } from "../components/avatar-style";
|
||||
import { ComputerUpdateProgress } from "../components/computer-update-progress";
|
||||
import { currentApiBase, loadApiBase, loadSessionToken, selectedSpaceId } from "../lib/api";
|
||||
import { loadAppearancePreference, mobileTokens } from "../lib/appearance";
|
||||
@ -57,7 +56,6 @@ export default function Layout() {
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<KeyboardProvider>
|
||||
{ready ? (
|
||||
<AvatarStyleProvider>
|
||||
<ThemeProvider value={navigationTheme}>
|
||||
<StatusBar style={resolved === "light" ? "dark" : "light"} />
|
||||
<Stack
|
||||
@ -124,7 +122,6 @@ export default function Layout() {
|
||||
</Stack>
|
||||
<ComputerUpdateProgress />
|
||||
</ThemeProvider>
|
||||
</AvatarStyleProvider>
|
||||
) : (
|
||||
<View style={{ flex: 1, backgroundColor: String(native.page) }} />
|
||||
)}
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import type { AvatarStyle } from "@rakazo/contracts";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
@ -15,8 +14,6 @@ import {
|
||||
View,
|
||||
} from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { useAvatarStyle } from "../components/avatar-style";
|
||||
import { BotAvatar } from "../components/bot-avatar";
|
||||
import type { MobileBot, MobileMe } from "../lib/api";
|
||||
import {
|
||||
currentApiBase,
|
||||
@ -61,8 +58,6 @@ export default function Account() {
|
||||
const [localeSaving, setLocaleSaving] = useState(false);
|
||||
const [localeError, setLocaleError] = useState<string | null>(null);
|
||||
const [pending, setPending] = useState(false);
|
||||
const [avatarPending, setAvatarPending] = useState(false);
|
||||
const [avatarError, setAvatarError] = useState<string | null>(null);
|
||||
const [notifications, setNotifications] = useState<LiveNotificationSettings>(
|
||||
DEFAULT_LIVE_NOTIFICATION_SETTINGS,
|
||||
);
|
||||
@ -76,7 +71,6 @@ export default function Account() {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
} | null>(null);
|
||||
const { avatarStyle, updateAvatarStyle } = useAvatarStyle();
|
||||
const appearance = getCachedAppearancePreference();
|
||||
const styles = useThemedStyles(createAccountStyles);
|
||||
const versionInfo = getAppVersionInfo();
|
||||
@ -127,19 +121,6 @@ export default function Account() {
|
||||
}
|
||||
}
|
||||
|
||||
async function selectAvatarStyle(next: AvatarStyle) {
|
||||
if (next === avatarStyle) return;
|
||||
setAvatarPending(true);
|
||||
setAvatarError(null);
|
||||
try {
|
||||
await updateAvatarStyle(next);
|
||||
} catch {
|
||||
setAvatarError(t("Couldn't update avatars"));
|
||||
} finally {
|
||||
setAvatarPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSignOut() {
|
||||
setPending(true);
|
||||
setError(null);
|
||||
@ -260,7 +241,7 @@ export default function Account() {
|
||||
<Text style={styles.chevron}>›</Text>
|
||||
</Pressable>
|
||||
|
||||
<View accessibilityLabel={t("Appearance")} style={styles.avatarSection}>
|
||||
<View accessibilityLabel={t("Appearance")} style={styles.appearanceSection}>
|
||||
<Text style={styles.settingsTitle}>{t("Appearance")}</Text>
|
||||
<View style={styles.appearanceOptions}>
|
||||
{(
|
||||
@ -292,40 +273,6 @@ export default function Account() {
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View accessibilityLabel={t("Avatar style")} style={styles.avatarSection}>
|
||||
<Text style={styles.settingsTitle}>{t("Avatars")}</Text>
|
||||
<View style={styles.avatarOptions}>
|
||||
{(["robot", "organic"] as const).map((style) => {
|
||||
const selected = avatarStyle === style;
|
||||
const styleLabel = style === "robot" ? t("Robot") : t("Organic");
|
||||
return (
|
||||
<Pressable
|
||||
key={style}
|
||||
accessibilityLabel={t("{style} avatars", { style: styleLabel })}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ selected, disabled: avatarPending }}
|
||||
disabled={avatarPending}
|
||||
onPress={() => void selectAvatarStyle(style)}
|
||||
style={({ pressed }) => [
|
||||
styles.avatarOption,
|
||||
selected && styles.avatarOptionSelected,
|
||||
pressed && styles.pressed,
|
||||
]}
|
||||
>
|
||||
<BotAvatar
|
||||
color={style === "robot" ? "#8B5CF6" : "#D62F8B"}
|
||||
identity="avatar-preview"
|
||||
size={42}
|
||||
variant={style}
|
||||
/>
|
||||
<Text style={styles.avatarLabel}>{styleLabel}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
{avatarError ? <Text style={styles.error}>{avatarError}</Text> : null}
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("Language")}
|
||||
@ -649,7 +596,7 @@ function createAccountStyles() {
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
},
|
||||
avatarSection: {
|
||||
appearanceSection: {
|
||||
borderRadius: 16,
|
||||
backgroundColor: native.fill,
|
||||
padding: 18,
|
||||
@ -677,29 +624,6 @@ function createAccountStyles() {
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
},
|
||||
avatarOptions: {
|
||||
flexDirection: "row",
|
||||
gap: 12,
|
||||
},
|
||||
avatarOption: {
|
||||
flex: 1,
|
||||
minHeight: 86,
|
||||
borderRadius: 14,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: native.tertiaryLabel,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
},
|
||||
avatarOptionSelected: {
|
||||
borderColor: native.label,
|
||||
backgroundColor: native.fillPressed,
|
||||
},
|
||||
avatarLabel: {
|
||||
color: native.label,
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
},
|
||||
settingsTitle: {
|
||||
color: native.label,
|
||||
fontSize: 17,
|
||||
|
||||
@ -1,52 +0,0 @@
|
||||
import type { AvatarStyle, Me } from "@rakazo/contracts";
|
||||
import { usePathname } from "expo-router";
|
||||
import { createContext, type ReactNode, useContext, useEffect, useRef, useState } from "react";
|
||||
import { rpc } from "../lib/api";
|
||||
|
||||
const AvatarStyleContext = createContext<{
|
||||
avatarStyle: AvatarStyle;
|
||||
updateAvatarStyle: (avatarStyle: AvatarStyle) => Promise<void>;
|
||||
}>({
|
||||
avatarStyle: "robot",
|
||||
updateAvatarStyle: async () => undefined,
|
||||
});
|
||||
|
||||
export function AvatarStyleProvider({ children }: { children: ReactNode }) {
|
||||
const [avatarStyle, setAvatarStyle] = useState<AvatarStyle>("robot");
|
||||
const pathname = usePathname();
|
||||
const requestIdRef = useRef(0);
|
||||
const updatePromiseRef = useRef<Promise<void> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const requestId = ++requestIdRef.current;
|
||||
void rpc<Me>("me")
|
||||
.then((me) => {
|
||||
if (requestId !== requestIdRef.current) return;
|
||||
setAvatarStyle(me.avatarStyle);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}, [pathname]);
|
||||
|
||||
function updateAvatarStyle(next: AvatarStyle): Promise<void> {
|
||||
if (updatePromiseRef.current) return updatePromiseRef.current;
|
||||
const requestId = ++requestIdRef.current;
|
||||
const update = rpc<Me>("preferences/update", { avatarStyle: next })
|
||||
.then((me) => {
|
||||
if (requestId !== requestIdRef.current) return;
|
||||
setAvatarStyle(me.avatarStyle);
|
||||
})
|
||||
.finally(() => {
|
||||
updatePromiseRef.current = null;
|
||||
});
|
||||
updatePromiseRef.current = update;
|
||||
return update;
|
||||
}
|
||||
|
||||
return (
|
||||
<AvatarStyleContext value={{ avatarStyle, updateAvatarStyle }}>{children}</AvatarStyleContext>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAvatarStyle() {
|
||||
return useContext(AvatarStyleContext);
|
||||
}
|
||||
@ -1,95 +1,58 @@
|
||||
import type { AvatarStyle } from "@rakazo/contracts";
|
||||
import type { OoMoment } from "@rakazo/core";
|
||||
import {
|
||||
ACTIVE_RUN_STATUSES,
|
||||
avatarIdentitySeed,
|
||||
DEFAULT_OO_AVATAR,
|
||||
ooAvatarColor,
|
||||
organicAvatarPath,
|
||||
decodeOoAvatar,
|
||||
ooIdentityShapeIndex,
|
||||
ooMomentForStatus,
|
||||
resolveOoColor,
|
||||
} from "@rakazo/core";
|
||||
import { memo, useEffect } from "react";
|
||||
import { View } from "react-native";
|
||||
import Animated, {
|
||||
cancelAnimation,
|
||||
Easing,
|
||||
useAnimatedProps,
|
||||
useAnimatedStyle,
|
||||
useReducedMotion,
|
||||
useSharedValue,
|
||||
withRepeat,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import Svg, { G, Path, Rect } from "react-native-svg";
|
||||
import { workingAvatarDuration, workingAvatarFrame } from "../lib/avatar-motion";
|
||||
import { memo, useMemo } from "react";
|
||||
import { Image, View } from "react-native";
|
||||
import { useI18n } from "../lib/i18n";
|
||||
import { useAvatarStyle } from "./avatar-style";
|
||||
import { NativeSymbol } from "./native-symbol";
|
||||
|
||||
const AnimatedRect = Animated.createAnimatedComponent(Rect);
|
||||
import { OoFigure } from "./oo-figure";
|
||||
|
||||
export const BotAvatar = memo(function BotAvatar({
|
||||
color,
|
||||
size = 54,
|
||||
status,
|
||||
identity,
|
||||
variant,
|
||||
moment,
|
||||
still,
|
||||
muted = false,
|
||||
}: {
|
||||
color: string;
|
||||
size?: number;
|
||||
status?: string;
|
||||
identity?: string;
|
||||
variant?: AvatarStyle;
|
||||
/** Explicit moment; wins over `status`. */
|
||||
moment?: OoMoment;
|
||||
still?: boolean;
|
||||
muted?: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const isWorking = ACTIVE_RUN_STATUSES.some((activeStatus) => activeStatus === status);
|
||||
const { avatarStyle } = useAvatarStyle();
|
||||
const fill = ooAvatarColor(color) || DEFAULT_OO_AVATAR.color;
|
||||
const visorW = Math.round(size * 0.68);
|
||||
const visorH = Math.round(size * 0.44);
|
||||
const eyeW = Math.max(3, Math.round(size * 0.11));
|
||||
const eyeH = Math.max(4, Math.round(size * 0.17));
|
||||
const gap = Math.max(3, Math.round(size * 0.11));
|
||||
const picture = color.startsWith("data:image/") ? color : null;
|
||||
const look = useMemo(() => decodeOoAvatar(color) ?? DEFAULT_OO_AVATAR, [color]);
|
||||
const seed = identity || (picture ? "agent" : color) || "agent";
|
||||
const body = resolveOoColor(seed, look.color).hex;
|
||||
return (
|
||||
<View style={{ width: size, height: size }}>
|
||||
{(variant ?? avatarStyle) === "organic" ? (
|
||||
<OrganicAvatar color={fill} identity={identity} size={size} isWorking={isWorking} />
|
||||
) : (
|
||||
<View
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: size / 2,
|
||||
backgroundColor: fill,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: visorW,
|
||||
height: visorH,
|
||||
borderRadius: Math.round(visorH * 0.52),
|
||||
backgroundColor: "#0C0C0E",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap,
|
||||
}}
|
||||
>
|
||||
{[0, 1].map((eye) => (
|
||||
<View
|
||||
key={eye}
|
||||
style={{
|
||||
width: eyeW,
|
||||
height: eyeH,
|
||||
borderRadius: Math.max(2, Math.round(eyeW * 0.6)),
|
||||
backgroundColor: "#fff",
|
||||
}}
|
||||
{picture ? (
|
||||
<Image
|
||||
source={{ uri: picture }}
|
||||
style={{ width: size, height: size, borderRadius: size / 2 }}
|
||||
/>
|
||||
) : (
|
||||
<OoFigure
|
||||
avatar={look}
|
||||
color={body}
|
||||
fallbackShape={look.shape ?? ooIdentityShapeIndex(seed)}
|
||||
moment={moment ?? ooMomentForStatus(status)}
|
||||
size={size}
|
||||
still={still}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
{isWorking ? (
|
||||
<View
|
||||
@ -135,81 +98,3 @@ export const BotAvatar = memo(function BotAvatar({
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
function OrganicAvatar({
|
||||
color,
|
||||
identity,
|
||||
size,
|
||||
isWorking,
|
||||
}: {
|
||||
color: string;
|
||||
identity?: string;
|
||||
size: number;
|
||||
isWorking: boolean;
|
||||
}) {
|
||||
const seed = avatarIdentitySeed(identity || color || "#8B5CF6");
|
||||
const progress = useSharedValue(0);
|
||||
const reducedMotion = useReducedMotion();
|
||||
|
||||
useEffect(() => {
|
||||
cancelAnimation(progress);
|
||||
progress.value = 0;
|
||||
if (isWorking && !reducedMotion) {
|
||||
progress.value = withRepeat(
|
||||
withTiming(1, {
|
||||
duration: workingAvatarDuration(seed),
|
||||
easing: Easing.linear,
|
||||
}),
|
||||
-1,
|
||||
);
|
||||
}
|
||||
return () => cancelAnimation(progress);
|
||||
}, [isWorking, progress, reducedMotion, seed]);
|
||||
|
||||
const bodyStyle = useAnimatedStyle(() => {
|
||||
const frame = workingAvatarFrame(seed, progress.value);
|
||||
return {
|
||||
transform: [
|
||||
{ translateX: (frame.translationX * size) / 120 },
|
||||
{ translateY: (frame.translationY * size) / 120 },
|
||||
{ rotate: `${frame.rotation}deg` },
|
||||
{ scaleX: frame.scaleX },
|
||||
{ scaleY: frame.scaleY },
|
||||
],
|
||||
};
|
||||
});
|
||||
const leftEyeProps = useAnimatedProps(() => {
|
||||
const frame = workingAvatarFrame(seed, progress.value);
|
||||
return { x: -14 + frame.eyeOffsetX, y: -12 + frame.eyeOffsetY };
|
||||
});
|
||||
const rightEyeProps = useAnimatedProps(() => {
|
||||
const frame = workingAvatarFrame(seed, progress.value);
|
||||
return { x: 7 + frame.eyeOffsetX, y: -12 + frame.eyeOffsetY };
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={{ width: size, height: size }}>
|
||||
<Animated.View style={[{ width: size, height: size }, bodyStyle]}>
|
||||
<Svg width={size} height={size} viewBox="-60 -60 120 120">
|
||||
<Path d={organicAvatarPath(seed)} fill={color} />
|
||||
<G transform={`rotate(${(seed % 9) - 4})`}>
|
||||
<AnimatedRect
|
||||
animatedProps={leftEyeProps}
|
||||
width={7}
|
||||
height={24}
|
||||
rx={3.5}
|
||||
fill="#101014"
|
||||
/>
|
||||
<AnimatedRect
|
||||
animatedProps={rightEyeProps}
|
||||
width={7}
|
||||
height={24}
|
||||
rx={3.5}
|
||||
fill="#101014"
|
||||
/>
|
||||
</G>
|
||||
</Svg>
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
218
apps/mobile/components/oo-figure.tsx
Normal file
218
apps/mobile/components/oo-figure.tsx
Normal file
@ -0,0 +1,218 @@
|
||||
import type { OoAvatar, OoFrame, OoLife, OoMoment, OoPaint, OoShape } from "@rakazo/core";
|
||||
import {
|
||||
buildOoRig,
|
||||
createOoLife,
|
||||
OO_LIST_MOMENTS,
|
||||
OO_RIG_INSET,
|
||||
OO_RIG_SIZE,
|
||||
OO_STILL_TIME,
|
||||
ooFrame,
|
||||
ooLodForSize,
|
||||
ooSeededRandom,
|
||||
} from "@rakazo/core";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import { useReducedMotion } from "react-native-reanimated";
|
||||
import Svg, {
|
||||
Circle,
|
||||
Defs,
|
||||
Ellipse,
|
||||
G,
|
||||
LinearGradient,
|
||||
Path,
|
||||
RadialGradient,
|
||||
Rect,
|
||||
Stop,
|
||||
} from "react-native-svg";
|
||||
|
||||
const VIEWBOX = `${OO_RIG_INSET} ${OO_RIG_INSET + 4} ${OO_RIG_SIZE - 2 * OO_RIG_INSET} ${OO_RIG_SIZE - 2 * OO_RIG_INSET}`;
|
||||
/** Native frames redraw the SVG tree, so they run at half the display rate. */
|
||||
const FRAME_SECONDS = 1 / 30;
|
||||
/** Above this size an Oo always moves; smaller ones only for list moments. */
|
||||
const LIVELY_SIZE = 56;
|
||||
|
||||
type Tick = (now: number) => void;
|
||||
const ticks = new Set<Tick>();
|
||||
let frameRequest = 0;
|
||||
|
||||
function loop(now: number) {
|
||||
for (const tick of ticks) tick(now / 1000);
|
||||
frameRequest = ticks.size ? requestAnimationFrame(loop) : 0;
|
||||
}
|
||||
|
||||
function subscribe(tick: Tick): () => void {
|
||||
ticks.add(tick);
|
||||
if (!frameRequest) frameRequest = requestAnimationFrame(loop);
|
||||
return () => {
|
||||
ticks.delete(tick);
|
||||
};
|
||||
}
|
||||
|
||||
function paintProps(paint: OoPaint, gradient: string) {
|
||||
const fill =
|
||||
paint.fill === "$body"
|
||||
? `url(#${gradient}-body)`
|
||||
: paint.fill === "$shine"
|
||||
? `url(#${gradient}-shine)`
|
||||
: paint.fill;
|
||||
return {
|
||||
fill: fill ?? (paint.stroke ? "none" : undefined),
|
||||
stroke: paint.stroke,
|
||||
strokeWidth: paint.strokeWidth,
|
||||
opacity: paint.opacity,
|
||||
fillOpacity: paint.fillOpacity,
|
||||
strokeOpacity: paint.strokeOpacity,
|
||||
strokeLinecap: paint.round ? ("round" as const) : undefined,
|
||||
strokeLinejoin: paint.round ? ("round" as const) : undefined,
|
||||
strokeDasharray: paint.dash,
|
||||
};
|
||||
}
|
||||
|
||||
function draw(
|
||||
shape: OoShape,
|
||||
key: number,
|
||||
gradient: string,
|
||||
frame: OoFrame,
|
||||
mouthColor: string,
|
||||
): ReactNode {
|
||||
switch (shape.type) {
|
||||
case "path":
|
||||
return <Path key={key} d={shape.d} {...paintProps(shape, gradient)} />;
|
||||
case "circle":
|
||||
return (
|
||||
<Circle
|
||||
key={key}
|
||||
cx={shape.cx}
|
||||
cy={shape.cy}
|
||||
r={shape.r}
|
||||
{...paintProps(shape, gradient)}
|
||||
/>
|
||||
);
|
||||
case "ellipse":
|
||||
return (
|
||||
<Ellipse
|
||||
key={key}
|
||||
cx={shape.cx}
|
||||
cy={shape.cy}
|
||||
rx={shape.rx}
|
||||
ry={shape.ry}
|
||||
{...paintProps(shape, gradient)}
|
||||
/>
|
||||
);
|
||||
case "rect":
|
||||
return (
|
||||
<Rect
|
||||
key={key}
|
||||
x={shape.x}
|
||||
y={shape.y}
|
||||
width={shape.width}
|
||||
height={shape.height}
|
||||
rx={shape.rx}
|
||||
{...paintProps(shape, gradient)}
|
||||
/>
|
||||
);
|
||||
case "group": {
|
||||
const { part, fx } = shape;
|
||||
if (part === "mouth") {
|
||||
return (
|
||||
<Path
|
||||
key={key}
|
||||
d={frame.mouth.d}
|
||||
fill={frame.mouth.filled ? mouthColor : "none"}
|
||||
stroke={mouthColor}
|
||||
strokeWidth={3.2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (part === "tongue") {
|
||||
return frame.tongue ? <Path key={key} d={frame.tongue} fill="#FF7FA6" /> : null;
|
||||
}
|
||||
const state = fx
|
||||
? frame.fx(fx.kind, fx.index)
|
||||
: part && part !== "fx"
|
||||
? frame.parts[part]
|
||||
: undefined;
|
||||
if (state?.hidden ?? shape.hidden) return null;
|
||||
return (
|
||||
<G key={key} transform={state?.transform ?? shape.transform} opacity={state?.opacity}>
|
||||
{shape.children.map((child, index) => draw(child, index, gradient, frame, mouthColor))}
|
||||
</G>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function OoFigure({
|
||||
avatar,
|
||||
color,
|
||||
fallbackShape,
|
||||
moment,
|
||||
size,
|
||||
still = false,
|
||||
}: {
|
||||
avatar: OoAvatar;
|
||||
color: string;
|
||||
fallbackShape: number;
|
||||
moment: OoMoment;
|
||||
size: number;
|
||||
still?: boolean;
|
||||
}) {
|
||||
const gradient = `oo${useId().replace(/[^a-zA-Z0-9-_]/g, "")}`;
|
||||
const lod = ooLodForSize(size);
|
||||
const rig = useMemo(
|
||||
() => buildOoRig({ avatar, color, fallbackShape, moment, lod }),
|
||||
[avatar, color, fallbackShape, moment, lod],
|
||||
);
|
||||
const reducedMotion = useReducedMotion();
|
||||
const lively =
|
||||
!still && !reducedMotion && (size > LIVELY_SIZE || OO_LIST_MOMENTS.includes(moment));
|
||||
const lifeRef = useRef<OoLife | null>(null);
|
||||
const [frame, setFrame] = useState<OoFrame | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setFrame(null);
|
||||
if (!lively) return;
|
||||
if (!lifeRef.current) lifeRef.current = createOoLife();
|
||||
const life = lifeRef.current;
|
||||
const start = performance.now() / 1000 - Math.random() * 10;
|
||||
let last = 0;
|
||||
return subscribe((now) => {
|
||||
if (now - last < FRAME_SECONDS) return;
|
||||
last = now;
|
||||
setFrame(ooFrame({ rig, avatar, moment, t: now - start, life }));
|
||||
});
|
||||
}, [rig, avatar, moment, lively]);
|
||||
|
||||
const stillFrame = useMemo(
|
||||
() =>
|
||||
ooFrame({
|
||||
rig,
|
||||
avatar,
|
||||
moment,
|
||||
t: OO_STILL_TIME,
|
||||
life: createOoLife(ooSeededRandom(1)),
|
||||
still: true,
|
||||
}),
|
||||
[rig, avatar, moment],
|
||||
);
|
||||
const shown = frame ?? stillFrame;
|
||||
const { colors } = rig;
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox={VIEWBOX}>
|
||||
<Defs>
|
||||
<LinearGradient id={`${gradient}-body`} x1="0" y1="0" x2="1" y2="1">
|
||||
<Stop offset="0" stopColor={colors.light} />
|
||||
<Stop offset="0.45" stopColor={colors.base} />
|
||||
<Stop offset="1" stopColor={colors.dark} />
|
||||
</LinearGradient>
|
||||
<RadialGradient id={`${gradient}-shine`} cx="0.3" cy="0.22" r="0.55">
|
||||
<Stop offset="0" stopColor="#FFFFFF" stopOpacity="0.55" />
|
||||
<Stop offset="1" stopColor="#FFFFFF" stopOpacity="0" />
|
||||
</RadialGradient>
|
||||
</Defs>
|
||||
{rig.shapes.map((shape, index) => draw(shape, index, gradient, shown, rig.mouthColor))}
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
@ -1,16 +0,0 @@
|
||||
import { workingAvatarDuration, workingAvatarFrame } from "@rakazo/core";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("working avatar motion", () => {
|
||||
it("reuses shared core choreography", () => {
|
||||
const start = workingAvatarFrame(0, 0);
|
||||
const end = workingAvatarFrame(0, 1);
|
||||
expect(end.translationY).toBeCloseTo(start.translationY);
|
||||
expect(end.scaleX).toBeCloseTo(start.scaleX);
|
||||
expect(end.eyeOffsetX).toBeCloseTo(start.eyeOffsetX);
|
||||
expect(end.eyeOffsetY).toBeCloseTo(start.eyeOffsetY);
|
||||
expect(workingAvatarFrame(0, 0.5)).not.toEqual(workingAvatarFrame(0, 0));
|
||||
expect(workingAvatarFrame(2, 0.5)).not.toEqual(workingAvatarFrame(0, 0.5));
|
||||
expect(workingAvatarDuration(6)).toBe(1100);
|
||||
});
|
||||
});
|
||||
@ -1,10 +0,0 @@
|
||||
/**
|
||||
* Re-export shared choreography for Reanimated worklets.
|
||||
* Source of truth: `@rakazo/core` (`avatar-motion.ts`), mirrored by web CSS.
|
||||
*/
|
||||
export {
|
||||
WORKING_AVATAR_DURATIONS_MS,
|
||||
type WorkingAvatarFrame,
|
||||
workingAvatarDuration,
|
||||
workingAvatarFrame,
|
||||
} from "@rakazo/core";
|
||||
@ -75,7 +75,6 @@ export const RU_MESSAGES: Record<string, string> = {
|
||||
"{name} is working": "{name} работает",
|
||||
"{name}’s computer": "Компьютер {name}",
|
||||
"{runs} runs · {tokens} tokens": "Запусков: {runs} · токенов: {tokens}",
|
||||
"{style} avatars": "Аватары: {style}",
|
||||
"@{name}": "@{name}",
|
||||
"API key": "API-ключ",
|
||||
Account: "Аккаунт",
|
||||
@ -122,8 +121,6 @@ export const RU_MESSAGES: Record<string, string> = {
|
||||
"Authorization timed out. Please try again.":
|
||||
"Время авторизации истекло. Пожалуйста, попробуйте еще раз.",
|
||||
"Authorize {name}": "Авторизовать {name}",
|
||||
Avatars: "Аватары",
|
||||
"Avatar style": "Стиль аватара",
|
||||
"Bearer token": "Bearer-токен",
|
||||
"Back to sign in": "Вернуться ко входу",
|
||||
Back: "Назад",
|
||||
@ -233,7 +230,6 @@ export const RU_MESSAGES: Record<string, string> = {
|
||||
"Could not update computer": "Не удалось обновить компьютер",
|
||||
"Could not update notifications": "Не удалось обновить уведомления.",
|
||||
"Could not update reaction": "Не удалось обновить реакцию",
|
||||
"Couldn't update avatars": "Не удалось обновить аватары",
|
||||
Create: "Создать",
|
||||
"Create group": "Создать группу",
|
||||
"Create section": "Создать раздел",
|
||||
@ -366,7 +362,6 @@ export const RU_MESSAGES: Record<string, string> = {
|
||||
"OpenAI-compatible server URL": "URL-адрес сервера, совместимого с OpenAI",
|
||||
Optional: "Необязательно",
|
||||
"Or connect an API key": "Или подключите API-ключ",
|
||||
Organic: "Органичный",
|
||||
"Other model…": "Другая модель…",
|
||||
Password: "Пароль",
|
||||
"Password recovery is not configured for this server":
|
||||
@ -414,7 +409,6 @@ export const RU_MESSAGES: Record<string, string> = {
|
||||
"Восстановить последнее сохранённое рабочее пространство. Несохранённые данные на компьютере будут потеряны.",
|
||||
"Resume notifications": "Возобновить уведомления",
|
||||
"Return to inbox": "Вернуться во входящие",
|
||||
Robot: "Робот",
|
||||
Routine: "Задача",
|
||||
"Routine link is incomplete": "Ссылка на задачу неполная",
|
||||
Running: "Выполняется",
|
||||
|
||||
@ -63,7 +63,6 @@ export const ZH_MESSAGES: Record<string, string> = {
|
||||
"{name} is working": "{name} 正在工作",
|
||||
"{name}’s computer": "{name} 的电脑",
|
||||
"{runs} runs · {tokens} tokens": "{runs} 次运行 · {tokens} 个 token",
|
||||
"{style} avatars": "{style} 头像",
|
||||
"@{name}": "@{name}",
|
||||
"API key": "API 密钥",
|
||||
Account: "账户",
|
||||
@ -110,8 +109,6 @@ export const ZH_MESSAGES: Record<string, string> = {
|
||||
"Authorization code": "授权码",
|
||||
"Authorization timed out. Please try again.": "授权超时,请重试。",
|
||||
"Authorize {name}": "授权 {name}",
|
||||
Avatars: "头像",
|
||||
"Avatar style": "头像风格",
|
||||
"Bearer token": "Bearer 令牌",
|
||||
"Back to sign in": "返回登录",
|
||||
Back: "返回",
|
||||
@ -218,7 +215,6 @@ export const ZH_MESSAGES: Record<string, string> = {
|
||||
"Could not update computer": "无法更新电脑",
|
||||
"Could not update notifications": "无法更新通知",
|
||||
"Could not update reaction": "无法更新反应",
|
||||
"Couldn't update avatars": "无法更新头像",
|
||||
Create: "创建",
|
||||
"Create group": "创建群组",
|
||||
"Create section": "创建分组",
|
||||
@ -350,7 +346,6 @@ export const ZH_MESSAGES: Record<string, string> = {
|
||||
"OpenAI-compatible server URL": "OpenAI 兼容服务器地址",
|
||||
Optional: "可选",
|
||||
"Or connect an API key": "或连接 API 密钥",
|
||||
Organic: "有机",
|
||||
"Other model…": "其他模型…",
|
||||
Password: "密码",
|
||||
"Password recovery is not configured for this server": "此服务器未配置密码找回",
|
||||
@ -395,7 +390,6 @@ export const ZH_MESSAGES: Record<string, string> = {
|
||||
"恢复上次保存的工作区。电脑上未保存的工作将会丢失。",
|
||||
"Resume notifications": "恢复通知",
|
||||
"Return to inbox": "返回收件箱",
|
||||
Robot: "机器人",
|
||||
Routine: "例行任务",
|
||||
"Routine link is incomplete": "例行任务链接不完整",
|
||||
Running: "运行中",
|
||||
|
||||
@ -756,10 +756,6 @@ msgstr "Verfügbar, sobald die Routine gespeichert ist"
|
||||
msgid "Avatar Studio"
|
||||
msgstr "Avatar-Studio"
|
||||
|
||||
#: src/pages/AccountSettingsOverlay.tsx
|
||||
msgid "Avatars"
|
||||
msgstr "Avatare"
|
||||
|
||||
#: src/components/browser/InAppBrowser.tsx
|
||||
#: src/components/onboarding/OnboardingVoiceStep.tsx
|
||||
#: src/pages/Onboarding.tsx
|
||||
@ -1748,10 +1744,6 @@ msgstr "Messaging-Einstellungen konnten nicht geladen werden"
|
||||
msgid "Couldn't save"
|
||||
msgstr "Konnte nicht gespeichert werden"
|
||||
|
||||
#: src/pages/AccountSettingsOverlay.tsx
|
||||
msgid "Couldn't update avatars"
|
||||
msgstr "Avatare konnten nicht aktualisiert werden"
|
||||
|
||||
#: src/pages/MessagingSettingsOverlay.tsx
|
||||
msgid "Couldn't update messaging settings"
|
||||
msgstr "Messaging-Einstellungen konnten nicht aktualisiert werden"
|
||||
@ -3510,10 +3502,6 @@ msgstr "Optionaler Header-Wert"
|
||||
msgid "Or connect an API key"
|
||||
msgstr "Oder einen API-Schlüssel verbinden"
|
||||
|
||||
#: src/pages/AccountSettingsOverlay.tsx
|
||||
msgid "Organic"
|
||||
msgstr "Organisch"
|
||||
|
||||
#: src/pages/memory-providers/SupermemorySettingsForm.tsx
|
||||
msgid "Organization API key"
|
||||
msgstr "Organisations-API-Schlüssel"
|
||||
@ -4025,10 +4013,6 @@ msgstr "Widerrufen"
|
||||
msgid "Riffing"
|
||||
msgstr "Am Riffen"
|
||||
|
||||
#: src/pages/AccountSettingsOverlay.tsx
|
||||
msgid "Robot"
|
||||
msgstr "Roboter"
|
||||
|
||||
#: src/pages/ExternalConversationSettings.tsx
|
||||
msgid "Room guidance"
|
||||
msgstr "Hinweise für diesen Raum"
|
||||
|
||||
@ -756,10 +756,6 @@ msgstr "Available after the routine is saved"
|
||||
msgid "Avatar Studio"
|
||||
msgstr "Avatar Studio"
|
||||
|
||||
#: src/pages/AccountSettingsOverlay.tsx
|
||||
msgid "Avatars"
|
||||
msgstr "Avatars"
|
||||
|
||||
#: src/components/browser/InAppBrowser.tsx
|
||||
#: src/components/onboarding/OnboardingVoiceStep.tsx
|
||||
#: src/pages/Onboarding.tsx
|
||||
@ -1748,10 +1744,6 @@ msgstr "Couldn't load messaging settings"
|
||||
msgid "Couldn't save"
|
||||
msgstr "Couldn't save"
|
||||
|
||||
#: src/pages/AccountSettingsOverlay.tsx
|
||||
msgid "Couldn't update avatars"
|
||||
msgstr "Couldn't update avatars"
|
||||
|
||||
#: src/pages/MessagingSettingsOverlay.tsx
|
||||
msgid "Couldn't update messaging settings"
|
||||
msgstr "Couldn't update messaging settings"
|
||||
@ -3510,10 +3502,6 @@ msgstr "Optional header value"
|
||||
msgid "Or connect an API key"
|
||||
msgstr "Or connect an API key"
|
||||
|
||||
#: src/pages/AccountSettingsOverlay.tsx
|
||||
msgid "Organic"
|
||||
msgstr "Organic"
|
||||
|
||||
#: src/pages/memory-providers/SupermemorySettingsForm.tsx
|
||||
msgid "Organization API key"
|
||||
msgstr "Organization API key"
|
||||
@ -4025,10 +4013,6 @@ msgstr "Revoke"
|
||||
msgid "Riffing"
|
||||
msgstr "Riffing"
|
||||
|
||||
#: src/pages/AccountSettingsOverlay.tsx
|
||||
msgid "Robot"
|
||||
msgstr "Robot"
|
||||
|
||||
#: src/pages/ExternalConversationSettings.tsx
|
||||
msgid "Room guidance"
|
||||
msgstr "Room guidance"
|
||||
|
||||
@ -756,10 +756,6 @@ msgstr "Disponible después de guardar la rutina"
|
||||
msgid "Avatar Studio"
|
||||
msgstr "Estudio de avatares"
|
||||
|
||||
#: src/pages/AccountSettingsOverlay.tsx
|
||||
msgid "Avatars"
|
||||
msgstr "Avatares"
|
||||
|
||||
#: src/components/browser/InAppBrowser.tsx
|
||||
#: src/components/onboarding/OnboardingVoiceStep.tsx
|
||||
#: src/pages/Onboarding.tsx
|
||||
@ -1748,10 +1744,6 @@ msgstr "No se pudo cargar la configuración de mensajería"
|
||||
msgid "Couldn't save"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/AccountSettingsOverlay.tsx
|
||||
msgid "Couldn't update avatars"
|
||||
msgstr "No se pudieron actualizar los avatares"
|
||||
|
||||
#: src/pages/MessagingSettingsOverlay.tsx
|
||||
msgid "Couldn't update messaging settings"
|
||||
msgstr "No se pudo actualizar la configuración de mensajería"
|
||||
@ -3510,10 +3502,6 @@ msgstr "Valor de encabezado opcional"
|
||||
msgid "Or connect an API key"
|
||||
msgstr "O conecta una clave de API"
|
||||
|
||||
#: src/pages/AccountSettingsOverlay.tsx
|
||||
msgid "Organic"
|
||||
msgstr "Orgánico"
|
||||
|
||||
#: src/pages/memory-providers/SupermemorySettingsForm.tsx
|
||||
msgid "Organization API key"
|
||||
msgstr "Clave de API de organización"
|
||||
@ -4025,10 +4013,6 @@ msgstr "Revocar"
|
||||
msgid "Riffing"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/AccountSettingsOverlay.tsx
|
||||
msgid "Robot"
|
||||
msgstr "Robot"
|
||||
|
||||
#: src/pages/ExternalConversationSettings.tsx
|
||||
msgid "Room guidance"
|
||||
msgstr ""
|
||||
|
||||
@ -756,10 +756,6 @@ msgstr ""
|
||||
msgid "Avatar Studio"
|
||||
msgstr "अवतार स्टूडियो"
|
||||
|
||||
#: src/pages/AccountSettingsOverlay.tsx
|
||||
msgid "Avatars"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/browser/InAppBrowser.tsx
|
||||
#: src/components/onboarding/OnboardingVoiceStep.tsx
|
||||
#: src/pages/Onboarding.tsx
|
||||
@ -1748,10 +1744,6 @@ msgstr ""
|
||||
msgid "Couldn't save"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/AccountSettingsOverlay.tsx
|
||||
msgid "Couldn't update avatars"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/MessagingSettingsOverlay.tsx
|
||||
msgid "Couldn't update messaging settings"
|
||||
msgstr ""
|
||||
@ -3510,10 +3502,6 @@ msgstr "वैकल्पिक हेडर मान"
|
||||
msgid "Or connect an API key"
|
||||
msgstr "या एक API कुंजी कनेक्ट करें"
|
||||
|
||||
#: src/pages/AccountSettingsOverlay.tsx
|
||||
msgid "Organic"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/memory-providers/SupermemorySettingsForm.tsx
|
||||
msgid "Organization API key"
|
||||
msgstr "संगठन API कुंजी"
|
||||
@ -4025,10 +4013,6 @@ msgstr ""
|
||||
msgid "Riffing"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/AccountSettingsOverlay.tsx
|
||||
msgid "Robot"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/ExternalConversationSettings.tsx
|
||||
msgid "Room guidance"
|
||||
msgstr ""
|
||||
|
||||
@ -756,10 +756,6 @@ msgstr "루틴을 저장한 후 사용할 수 있습니다"
|
||||
msgid "Avatar Studio"
|
||||
msgstr "아바타 스튜디오"
|
||||
|
||||
#: src/pages/AccountSettingsOverlay.tsx
|
||||
msgid "Avatars"
|
||||
msgstr "아바타"
|
||||
|
||||
#: src/components/browser/InAppBrowser.tsx
|
||||
#: src/components/onboarding/OnboardingVoiceStep.tsx
|
||||
#: src/pages/Onboarding.tsx
|
||||
@ -1748,10 +1744,6 @@ msgstr "메시징 설정을 불러올 수 없습니다"
|
||||
msgid "Couldn't save"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/AccountSettingsOverlay.tsx
|
||||
msgid "Couldn't update avatars"
|
||||
msgstr "아바타를 업데이트할 수 없습니다"
|
||||
|
||||
#: src/pages/MessagingSettingsOverlay.tsx
|
||||
msgid "Couldn't update messaging settings"
|
||||
msgstr "메시징 설정을 업데이트할 수 없습니다"
|
||||
@ -3510,10 +3502,6 @@ msgstr "선택 사항인 헤더 값"
|
||||
msgid "Or connect an API key"
|
||||
msgstr "또는 API 키 연결"
|
||||
|
||||
#: src/pages/AccountSettingsOverlay.tsx
|
||||
msgid "Organic"
|
||||
msgstr "자연스럽게"
|
||||
|
||||
#: src/pages/memory-providers/SupermemorySettingsForm.tsx
|
||||
msgid "Organization API key"
|
||||
msgstr "조직 API 키"
|
||||
@ -4025,10 +4013,6 @@ msgstr "연결 해제"
|
||||
msgid "Riffing"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/AccountSettingsOverlay.tsx
|
||||
msgid "Robot"
|
||||
msgstr "로봇"
|
||||
|
||||
#: src/pages/ExternalConversationSettings.tsx
|
||||
msgid "Room guidance"
|
||||
msgstr ""
|
||||
|
||||
@ -756,10 +756,6 @@ msgstr ""
|
||||
msgid "Avatar Studio"
|
||||
msgstr "Estúdio de avatar"
|
||||
|
||||
#: src/pages/AccountSettingsOverlay.tsx
|
||||
msgid "Avatars"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/browser/InAppBrowser.tsx
|
||||
#: src/components/onboarding/OnboardingVoiceStep.tsx
|
||||
#: src/pages/Onboarding.tsx
|
||||
@ -1748,10 +1744,6 @@ msgstr ""
|
||||
msgid "Couldn't save"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/AccountSettingsOverlay.tsx
|
||||
msgid "Couldn't update avatars"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/MessagingSettingsOverlay.tsx
|
||||
msgid "Couldn't update messaging settings"
|
||||
msgstr ""
|
||||
@ -3510,10 +3502,6 @@ msgstr "Valor de cabeçalho opcional"
|
||||
msgid "Or connect an API key"
|
||||
msgstr "Ou conecte uma chave de API"
|
||||
|
||||
#: src/pages/AccountSettingsOverlay.tsx
|
||||
msgid "Organic"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/memory-providers/SupermemorySettingsForm.tsx
|
||||
msgid "Organization API key"
|
||||
msgstr "Chave da API da organização"
|
||||
@ -4025,10 +4013,6 @@ msgstr ""
|
||||
msgid "Riffing"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/AccountSettingsOverlay.tsx
|
||||
msgid "Robot"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/ExternalConversationSettings.tsx
|
||||
msgid "Room guidance"
|
||||
msgstr ""
|
||||
|
||||
@ -756,10 +756,6 @@ msgstr "Доступно после сохранения задачи"
|
||||
msgid "Avatar Studio"
|
||||
msgstr "Студия аватаров"
|
||||
|
||||
#: src/pages/AccountSettingsOverlay.tsx
|
||||
msgid "Avatars"
|
||||
msgstr "Аватары"
|
||||
|
||||
#: src/components/browser/InAppBrowser.tsx
|
||||
#: src/components/onboarding/OnboardingVoiceStep.tsx
|
||||
#: src/pages/Onboarding.tsx
|
||||
@ -1748,10 +1744,6 @@ msgstr "Не удалось загрузить настройки обмена
|
||||
msgid "Couldn't save"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/AccountSettingsOverlay.tsx
|
||||
msgid "Couldn't update avatars"
|
||||
msgstr "Не удалось обновить аватары"
|
||||
|
||||
#: src/pages/MessagingSettingsOverlay.tsx
|
||||
msgid "Couldn't update messaging settings"
|
||||
msgstr "Не удалось обновить настройки обмена сообщениями."
|
||||
@ -3510,10 +3502,6 @@ msgstr "Необязательное значение заголовка"
|
||||
msgid "Or connect an API key"
|
||||
msgstr "Или подключить API-ключ"
|
||||
|
||||
#: src/pages/AccountSettingsOverlay.tsx
|
||||
msgid "Organic"
|
||||
msgstr "Органичный"
|
||||
|
||||
#: src/pages/memory-providers/SupermemorySettingsForm.tsx
|
||||
msgid "Organization API key"
|
||||
msgstr "Ключ API организации"
|
||||
@ -4025,10 +4013,6 @@ msgstr "Отозвать"
|
||||
msgid "Riffing"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/AccountSettingsOverlay.tsx
|
||||
msgid "Robot"
|
||||
msgstr "Робот"
|
||||
|
||||
#: src/pages/ExternalConversationSettings.tsx
|
||||
msgid "Room guidance"
|
||||
msgstr "Инструкции для комнаты"
|
||||
|
||||
@ -756,10 +756,6 @@ msgstr ""
|
||||
msgid "Avatar Studio"
|
||||
msgstr "Avatar Stüdyosu"
|
||||
|
||||
#: src/pages/AccountSettingsOverlay.tsx
|
||||
msgid "Avatars"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/browser/InAppBrowser.tsx
|
||||
#: src/components/onboarding/OnboardingVoiceStep.tsx
|
||||
#: src/pages/Onboarding.tsx
|
||||
@ -1748,10 +1744,6 @@ msgstr ""
|
||||
msgid "Couldn't save"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/AccountSettingsOverlay.tsx
|
||||
msgid "Couldn't update avatars"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/MessagingSettingsOverlay.tsx
|
||||
msgid "Couldn't update messaging settings"
|
||||
msgstr ""
|
||||
@ -3510,10 +3502,6 @@ msgstr "İsteğe bağlı üstbilgi değeri"
|
||||
msgid "Or connect an API key"
|
||||
msgstr "Veya bir API anahtarı bağla"
|
||||
|
||||
#: src/pages/AccountSettingsOverlay.tsx
|
||||
msgid "Organic"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/memory-providers/SupermemorySettingsForm.tsx
|
||||
msgid "Organization API key"
|
||||
msgstr "Kuruluş API anahtarı"
|
||||
@ -4025,10 +4013,6 @@ msgstr "İptal et"
|
||||
msgid "Riffing"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/AccountSettingsOverlay.tsx
|
||||
msgid "Robot"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/ExternalConversationSettings.tsx
|
||||
msgid "Room guidance"
|
||||
msgstr ""
|
||||
|
||||
@ -756,10 +756,6 @@ msgstr "保存例行任务后可用"
|
||||
msgid "Avatar Studio"
|
||||
msgstr "头像工作室"
|
||||
|
||||
#: src/pages/AccountSettingsOverlay.tsx
|
||||
msgid "Avatars"
|
||||
msgstr "头像"
|
||||
|
||||
#: src/components/browser/InAppBrowser.tsx
|
||||
#: src/components/onboarding/OnboardingVoiceStep.tsx
|
||||
#: src/pages/Onboarding.tsx
|
||||
@ -1748,10 +1744,6 @@ msgstr "无法加载消息设置"
|
||||
msgid "Couldn't save"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/AccountSettingsOverlay.tsx
|
||||
msgid "Couldn't update avatars"
|
||||
msgstr "无法更新头像"
|
||||
|
||||
#: src/pages/MessagingSettingsOverlay.tsx
|
||||
msgid "Couldn't update messaging settings"
|
||||
msgstr "无法更新消息设置"
|
||||
@ -3510,10 +3502,6 @@ msgstr "可选请求头值"
|
||||
msgid "Or connect an API key"
|
||||
msgstr "或连接 API 密钥"
|
||||
|
||||
#: src/pages/AccountSettingsOverlay.tsx
|
||||
msgid "Organic"
|
||||
msgstr "自然"
|
||||
|
||||
#: src/pages/memory-providers/SupermemorySettingsForm.tsx
|
||||
msgid "Organization API key"
|
||||
msgstr "组织 API 密钥"
|
||||
@ -4025,10 +4013,6 @@ msgstr "撤销"
|
||||
msgid "Riffing"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/AccountSettingsOverlay.tsx
|
||||
msgid "Robot"
|
||||
msgstr "机器人"
|
||||
|
||||
#: src/pages/ExternalConversationSettings.tsx
|
||||
msgid "Room guidance"
|
||||
msgstr ""
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import { Trans, useLingui } from "@lingui/react/macro";
|
||||
import type { AvatarStyle } from "@rakazo/contracts";
|
||||
import { BotAvatar, Button, Field, FieldLabel, Input, Toggle } from "@rakazo/ui-web";
|
||||
import { Button, Field, FieldLabel, Input, Toggle } from "@rakazo/ui-web";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import {
|
||||
type KeyboardEvent as ReactKeyboardEvent,
|
||||
@ -28,8 +27,6 @@ import { UI_LOCALE_LABELS, UI_LOCALES, type UiLocale } from "../lib/ui-locale";
|
||||
export type SettingsGeneralProps = {
|
||||
email?: string | null;
|
||||
name: string;
|
||||
avatarStyle: AvatarStyle;
|
||||
onAvatarStyleChange: (style: AvatarStyle) => Promise<void>;
|
||||
messagingEnabled?: boolean;
|
||||
onOpenMessaging?: () => void;
|
||||
isDeploymentOwner?: boolean;
|
||||
@ -38,20 +35,15 @@ export type SettingsGeneralProps = {
|
||||
export function GeneralSettingsPanels({
|
||||
email,
|
||||
name,
|
||||
avatarStyle,
|
||||
onAvatarStyleChange,
|
||||
messagingEnabled = false,
|
||||
onOpenMessaging,
|
||||
isDeploymentOwner = false,
|
||||
}: SettingsGeneralProps) {
|
||||
const { t } = useLingui();
|
||||
const [locale, setLocale] = useState<UiLocale>(() => getActiveUiLocale());
|
||||
const localeRequestRef = useRef(0);
|
||||
const [appearance, setAppearance] = useState<AppearancePreference>(() =>
|
||||
getUiAppearancePreference(),
|
||||
);
|
||||
const [avatarPending, setAvatarPending] = useState(false);
|
||||
const [avatarError, setAvatarError] = useState<string | null>(null);
|
||||
|
||||
function chooseLocale(next: UiLocale) {
|
||||
if (next === locale) return;
|
||||
@ -63,19 +55,6 @@ export function GeneralSettingsPanels({
|
||||
});
|
||||
}
|
||||
|
||||
async function chooseAvatarStyle(next: AvatarStyle) {
|
||||
if (avatarPending || next === avatarStyle) return;
|
||||
setAvatarPending(true);
|
||||
setAvatarError(null);
|
||||
try {
|
||||
await onAvatarStyleChange(next);
|
||||
} catch {
|
||||
setAvatarError(t`Couldn't update avatars`);
|
||||
} finally {
|
||||
setAvatarPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<section className="rounded-xl border border-border px-4 py-4">
|
||||
@ -122,37 +101,6 @@ export function GeneralSettingsPanels({
|
||||
<UiLocalePicker value={locale} onChange={chooseLocale} />
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-border px-4 py-4">
|
||||
<h3 className="text-ui-body font-medium text-foreground">
|
||||
<Trans>Avatars</Trans>
|
||||
</h3>
|
||||
<div className="mt-3 grid grid-cols-2 gap-3">
|
||||
{(["robot", "organic"] as const).map((style) => (
|
||||
<Toggle
|
||||
key={style}
|
||||
variant="outline"
|
||||
pressed={style === avatarStyle}
|
||||
disabled={avatarPending}
|
||||
onPressedChange={() => void chooseAvatarStyle(style)}
|
||||
className="h-auto justify-start gap-3 px-3.5 py-3 text-ui-body font-normal"
|
||||
>
|
||||
<BotAvatar
|
||||
color="#D9508A"
|
||||
identity="avatar-style-preview"
|
||||
size={32}
|
||||
variant={style}
|
||||
/>
|
||||
<span>{style === "robot" ? <Trans>Robot</Trans> : <Trans>Organic</Trans>}</span>
|
||||
</Toggle>
|
||||
))}
|
||||
</div>
|
||||
{avatarError ? (
|
||||
<p role="alert" className="mt-3 text-ui-body-sm text-destructive">
|
||||
{avatarError}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{isDeploymentOwner ? (
|
||||
<Button variant="outline" render={<Link to="/integrations/setup" />}>
|
||||
<Trans>Server integrations</Trans>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useLingui } from "@lingui/react/macro";
|
||||
import type { AvatarStyle, SpaceMemoryConfig } from "@rakazo/contracts";
|
||||
import type { SpaceMemoryConfig } from "@rakazo/contracts";
|
||||
import { Button, Dialog, DialogClose, DialogContent, DialogTitle } from "@rakazo/ui-web";
|
||||
import {
|
||||
Bell,
|
||||
@ -58,8 +58,6 @@ export function SettingsOverlay({
|
||||
name,
|
||||
usage,
|
||||
initialSection = "general",
|
||||
avatarStyle,
|
||||
onAvatarStyleChange,
|
||||
isDeploymentOwner = false,
|
||||
sandboxProvider,
|
||||
messagingEnabled = false,
|
||||
@ -75,8 +73,6 @@ export function SettingsOverlay({
|
||||
name: string;
|
||||
usage?: { runs: number; inputTokens: number; outputTokens: number } | null;
|
||||
initialSection?: SettingsSection;
|
||||
avatarStyle: AvatarStyle;
|
||||
onAvatarStyleChange: (style: AvatarStyle) => Promise<void>;
|
||||
isDeploymentOwner?: boolean;
|
||||
sandboxProvider?: string | null;
|
||||
messagingEnabled?: boolean;
|
||||
@ -236,8 +232,6 @@ export function SettingsOverlay({
|
||||
<GeneralSettingsPanels
|
||||
email={email}
|
||||
name={name}
|
||||
avatarStyle={avatarStyle}
|
||||
onAvatarStyleChange={onAvatarStyleChange}
|
||||
messagingEnabled={messagingEnabled}
|
||||
onOpenMessaging={
|
||||
onOpenMessaging ? () => leaveSettings(onOpenMessaging) : undefined
|
||||
|
||||
@ -51,7 +51,6 @@ import {
|
||||
type WorkspaceFocus,
|
||||
} from "@rakazo/core";
|
||||
import {
|
||||
AvatarStyleProvider,
|
||||
BotAvatar,
|
||||
Button,
|
||||
DropdownMenuItem,
|
||||
@ -4127,7 +4126,6 @@ export function ShellPage() {
|
||||
email={session.data?.user.email}
|
||||
usage={usage}
|
||||
initialSection={settingsSection}
|
||||
avatarStyle={bootstrapMe?.avatarStyle ?? "robot"}
|
||||
isDeploymentOwner={bootstrapMe?.isDeploymentOwner === true}
|
||||
sandboxProvider={bootstrapMe?.sandboxProvider}
|
||||
messagingEnabled={messagingSurfaceEnabled}
|
||||
@ -4135,10 +4133,6 @@ export function ShellPage() {
|
||||
setSettingsOpen(false);
|
||||
setMessagingSettingsOpen(true);
|
||||
}}
|
||||
onAvatarStyleChange={async (avatarStyle) => {
|
||||
const nextMe = await rpc.preferences.update({ avatarStyle });
|
||||
setBootstrapMe(nextMe);
|
||||
}}
|
||||
memoryConfig={memoryProviderConfig}
|
||||
onMemoryConfigChange={(config) => {
|
||||
memoryProviderConfigRevision.current += 1;
|
||||
@ -4236,5 +4230,5 @@ export function ShellPage() {
|
||||
</div>
|
||||
);
|
||||
|
||||
return <AvatarStyleProvider value="organic">{shell}</AvatarStyleProvider>;
|
||||
return shell;
|
||||
}
|
||||
|
||||
@ -1,16 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { workingAvatarDuration, workingAvatarFrame } from "./avatar-motion.js";
|
||||
|
||||
describe("working avatar motion", () => {
|
||||
it("loops cleanly while keeping identity-specific choreography", () => {
|
||||
const start = workingAvatarFrame(0, 0);
|
||||
const end = workingAvatarFrame(0, 1);
|
||||
expect(end.translationY).toBeCloseTo(start.translationY);
|
||||
expect(end.scaleX).toBeCloseTo(start.scaleX);
|
||||
expect(end.eyeOffsetX).toBeCloseTo(start.eyeOffsetX);
|
||||
expect(end.eyeOffsetY).toBeCloseTo(start.eyeOffsetY);
|
||||
expect(workingAvatarFrame(0, 0.5)).not.toEqual(workingAvatarFrame(0, 0));
|
||||
expect(workingAvatarFrame(2, 0.5)).not.toEqual(workingAvatarFrame(0, 0.5));
|
||||
expect(workingAvatarDuration(6)).toBe(1100);
|
||||
});
|
||||
});
|
||||
@ -1,94 +0,0 @@
|
||||
/**
|
||||
* Shared working-avatar choreography used by mobile (Reanimated) and mirrored
|
||||
* by web CSS in `@rakazo/ui-web` (`styles.css` organic working keyframes).
|
||||
*
|
||||
* Duration families and transform midpoints must stay aligned across surfaces.
|
||||
*/
|
||||
|
||||
export interface WorkingAvatarFrame {
|
||||
translationX: number;
|
||||
translationY: number;
|
||||
scaleX: number;
|
||||
scaleY: number;
|
||||
rotation: number;
|
||||
eyeOffsetX: number;
|
||||
eyeOffsetY: number;
|
||||
}
|
||||
|
||||
/** Per shape-family loop length in ms — keep in sync with web CSS animation durations. */
|
||||
export const WORKING_AVATAR_DURATIONS_MS = [
|
||||
1800, 1350, 1600, 2400, 2400, 1350, 1100, 1350, 1600, 1350,
|
||||
] as const;
|
||||
|
||||
export function workingAvatarDuration(seed: number): number {
|
||||
"worklet";
|
||||
return WORKING_AVATAR_DURATIONS_MS[seed % 10] ?? 1800;
|
||||
}
|
||||
|
||||
export function workingAvatarFrame(seed: number, progress: number): WorkingAvatarFrame {
|
||||
"worklet";
|
||||
const middle = (1 - Math.cos(progress * Math.PI * 2)) / 2;
|
||||
const frame: WorkingAvatarFrame = {
|
||||
translationX: 0,
|
||||
translationY: 0,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
rotation: 0,
|
||||
eyeOffsetX: 0,
|
||||
eyeOffsetY: 0,
|
||||
};
|
||||
|
||||
switch (seed % 10) {
|
||||
case 0:
|
||||
frame.translationY = 2 - 5 * middle;
|
||||
frame.scaleX = 1.02 - 0.04 * middle;
|
||||
break;
|
||||
case 1:
|
||||
frame.translationY = 2 - 5 * middle;
|
||||
frame.scaleX = 1.04 - 0.08 * middle;
|
||||
frame.scaleY = 0.96 + 0.09 * middle;
|
||||
break;
|
||||
case 2:
|
||||
case 8:
|
||||
frame.translationX = -1 + 2 * middle;
|
||||
frame.rotation = -3 + 6 * middle;
|
||||
break;
|
||||
case 3:
|
||||
case 4:
|
||||
frame.scaleX = 0.98 + 0.06 * middle;
|
||||
frame.scaleY = 0.98 + 0.06 * middle;
|
||||
frame.rotation = -4 + 8 * middle;
|
||||
break;
|
||||
case 5:
|
||||
case 9:
|
||||
frame.scaleX = 1.04 - 0.08 * middle;
|
||||
frame.scaleY = 0.96 + 0.08 * middle;
|
||||
break;
|
||||
case 6:
|
||||
frame.scaleX = 0.96 + 0.1 * middle;
|
||||
frame.scaleY = 0.96 + 0.1 * middle;
|
||||
break;
|
||||
default:
|
||||
frame.rotation = -4 + 9 * middle;
|
||||
}
|
||||
|
||||
const angle = progress * Math.PI * 2;
|
||||
switch (seed % 4) {
|
||||
case 0:
|
||||
frame.eyeOffsetX = Math.sin(angle) * 9;
|
||||
frame.eyeOffsetY = Math.cos(angle) * 2;
|
||||
break;
|
||||
case 1:
|
||||
frame.eyeOffsetX = Math.cos(angle) * 7;
|
||||
frame.eyeOffsetY = Math.sin(angle) * 4;
|
||||
break;
|
||||
case 2:
|
||||
frame.eyeOffsetX = Math.cos(angle) * 8;
|
||||
frame.eyeOffsetY = Math.sin(angle) * 3;
|
||||
break;
|
||||
default:
|
||||
frame.eyeOffsetX = Math.sin(angle * 2) * 6;
|
||||
frame.eyeOffsetY = Math.cos(angle * 2) * 3;
|
||||
}
|
||||
return frame;
|
||||
}
|
||||
@ -1,24 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { avatarIdentitySeed, organicAvatarPath } from "./avatar-shape.js";
|
||||
|
||||
describe("organic avatar geometry", () => {
|
||||
it("is stable for an identity and changes across identities", () => {
|
||||
const first = organicAvatarPath(avatarIdentitySeed("research"));
|
||||
expect(first).toBe(organicAvatarPath(avatarIdentitySeed("research")));
|
||||
expect(first).not.toBe(organicAvatarPath(avatarIdentitySeed("health")));
|
||||
});
|
||||
|
||||
it("emits only path geometry from a numeric seed", () => {
|
||||
const path = organicAvatarPath(42);
|
||||
expect(path).toMatch(/^M[-0-9. ]+(?:C[-0-9. ]+)+Z$/);
|
||||
expect(path).not.toMatch(/<|>|javascript:|url\(/i);
|
||||
});
|
||||
|
||||
it("produces ten distinct smooth shape families", () => {
|
||||
const families = Array.from({ length: 10 }, (_, seed) =>
|
||||
organicAvatarPath(seed, -((seed % 360) * Math.PI) / 180),
|
||||
);
|
||||
expect(new Set(families)).toHaveLength(10);
|
||||
expect(families.every((path) => (path.match(/C/g) ?? []).length >= 12)).toBe(true);
|
||||
});
|
||||
});
|
||||
@ -1,187 +0,0 @@
|
||||
type AvatarPoint = readonly [number, number];
|
||||
|
||||
let organicAvatarTemplates: readonly (readonly AvatarPoint[])[] | undefined;
|
||||
|
||||
export function avatarIdentitySeed(identity: string): number {
|
||||
let hash = 0;
|
||||
for (let index = 0; index < identity.length; index++) {
|
||||
hash = (hash << 5) - hash + identity.charCodeAt(index);
|
||||
hash |= 0;
|
||||
}
|
||||
return Math.abs(hash);
|
||||
}
|
||||
|
||||
export function organicAvatarPath(seed: number, phaseOffset = 0): string {
|
||||
const phase = ((seed % 360) * Math.PI) / 180 + phaseOffset;
|
||||
const family = seed % 10;
|
||||
if (!organicAvatarTemplates) {
|
||||
organicAvatarTemplates = [
|
||||
[
|
||||
[-43, 22],
|
||||
[-51, 16],
|
||||
[-53, 6],
|
||||
[-49, -4],
|
||||
[-40, -12],
|
||||
[-30, -13],
|
||||
[-27, -24],
|
||||
[-18, -34],
|
||||
[-6, -36],
|
||||
[4, -32],
|
||||
[12, -42],
|
||||
[25, -45],
|
||||
[37, -38],
|
||||
[43, -27],
|
||||
[42, -17],
|
||||
[51, -11],
|
||||
[56, 0],
|
||||
[54, 12],
|
||||
[46, 20],
|
||||
[30, 24],
|
||||
[0, 25],
|
||||
[-29, 25],
|
||||
],
|
||||
[
|
||||
[0, -52],
|
||||
[12, -35],
|
||||
[28, -17],
|
||||
[39, 2],
|
||||
[42, 21],
|
||||
[34, 38],
|
||||
[19, 49],
|
||||
[0, 52],
|
||||
[-19, 49],
|
||||
[-34, 38],
|
||||
[-42, 21],
|
||||
[-39, 2],
|
||||
[-28, -17],
|
||||
[-12, -35],
|
||||
],
|
||||
[
|
||||
[-42, -23],
|
||||
[-27, -40],
|
||||
[-7, -46],
|
||||
[14, -40],
|
||||
[33, -27],
|
||||
[46, -8],
|
||||
[46, 14],
|
||||
[35, 34],
|
||||
[14, 46],
|
||||
[-8, 46],
|
||||
[-29, 38],
|
||||
[-43, 21],
|
||||
[-48, 0],
|
||||
],
|
||||
Array.from({ length: 40 }, (_, index) => {
|
||||
const angle = (index / 40) * Math.PI * 2;
|
||||
const radius = 44 + Math.cos(angle * 5) * 5;
|
||||
return [Math.cos(angle) * radius, Math.sin(angle) * radius];
|
||||
}),
|
||||
Array.from({ length: 36 }, (_, index) => {
|
||||
const angle = (index / 36) * Math.PI * 2;
|
||||
const radius = 43 + Math.cos(angle * 6) * 7;
|
||||
return [Math.cos(angle) * radius, Math.sin(angle) * radius];
|
||||
}),
|
||||
Array.from({ length: 32 }, (_, index) => {
|
||||
const angle = (index / 32) * Math.PI * 2;
|
||||
const x = Math.cos(angle);
|
||||
const y = Math.sin(angle);
|
||||
return [
|
||||
Math.sign(x) * Math.sqrt(Math.abs(x)) * 46,
|
||||
Math.sign(y) * Math.sqrt(Math.abs(y)) * 43,
|
||||
];
|
||||
}),
|
||||
[
|
||||
[0, 48],
|
||||
[-14, 36],
|
||||
[-32, 20],
|
||||
[-45, 0],
|
||||
[-43, -20],
|
||||
[-29, -36],
|
||||
[-10, -39],
|
||||
[0, -27],
|
||||
[10, -39],
|
||||
[29, -36],
|
||||
[43, -20],
|
||||
[45, 0],
|
||||
[32, 20],
|
||||
[14, 36],
|
||||
],
|
||||
[
|
||||
[-48, 35],
|
||||
[-38, 22],
|
||||
[-34, -8],
|
||||
[-27, -31],
|
||||
[-12, -44],
|
||||
[0, -48],
|
||||
[12, -44],
|
||||
[27, -31],
|
||||
[34, -8],
|
||||
[38, 22],
|
||||
[48, 35],
|
||||
[26, 42],
|
||||
[0, 44],
|
||||
[-26, 42],
|
||||
],
|
||||
[
|
||||
[-45, -19],
|
||||
[-28, -38],
|
||||
[-4, -46],
|
||||
[22, -41],
|
||||
[42, -25],
|
||||
[49, -2],
|
||||
[42, 23],
|
||||
[22, 42],
|
||||
[-4, 47],
|
||||
[-29, 38],
|
||||
[-46, 18],
|
||||
[-50, 0],
|
||||
],
|
||||
[
|
||||
[-50, 0],
|
||||
[-45, -20],
|
||||
[-30, -38],
|
||||
[0, -48],
|
||||
[30, -38],
|
||||
[45, -20],
|
||||
[50, 0],
|
||||
[35, 10],
|
||||
[18, 12],
|
||||
[16, 38],
|
||||
[0, 46],
|
||||
[-16, 38],
|
||||
[-18, 12],
|
||||
[-35, 10],
|
||||
],
|
||||
] as const;
|
||||
}
|
||||
const templates = organicAvatarTemplates;
|
||||
const xScale = 1 + Math.sin(phase) * 0.035;
|
||||
const yScale = 1 + Math.cos(phase) * 0.025;
|
||||
const shear = Math.sin(phase * 1.7) * 0.025;
|
||||
let points = (templates[family] ?? templates[5]!).map(([x, y]) => ({
|
||||
x: x * xScale + y * shear,
|
||||
y: y * yScale,
|
||||
}));
|
||||
const smoothingPasses = family >= 3 && family <= 5 ? 1 : 2;
|
||||
for (let pass = 0; pass < smoothingPasses; pass++) {
|
||||
points = points.flatMap((point, index) => {
|
||||
const next = points[(index + 1) % points.length]!;
|
||||
return [
|
||||
{ x: point.x * 0.75 + next.x * 0.25, y: point.y * 0.75 + next.y * 0.25 },
|
||||
{ x: point.x * 0.25 + next.x * 0.75, y: point.y * 0.25 + next.y * 0.75 },
|
||||
];
|
||||
});
|
||||
}
|
||||
const pointCount = points.length;
|
||||
const round = (value: number) => Math.round(value * 100) / 100;
|
||||
const first = points[0]!;
|
||||
let path = `M${round(first.x)} ${round(first.y)}`;
|
||||
for (let index = 0; index < pointCount; index++) {
|
||||
const before = points[(index - 1 + pointCount) % pointCount]!;
|
||||
const current = points[index]!;
|
||||
const next = points[(index + 1) % pointCount]!;
|
||||
const after = points[(index + 2) % pointCount]!;
|
||||
path += `C${round(current.x + (next.x - before.x) / 6)} ${round(current.y + (next.y - before.y) / 6)} ${round(next.x - (after.x - current.x) / 6)} ${round(next.y - (after.y - current.y) / 6)} ${round(next.x)} ${round(next.y)}`;
|
||||
}
|
||||
return `${path}Z`;
|
||||
}
|
||||
@ -4,8 +4,6 @@ export * from "./ai-consent.js";
|
||||
export * from "./answerable-ask.js";
|
||||
export * from "./async.js";
|
||||
export * from "./attachments.js";
|
||||
export * from "./avatar-motion.js";
|
||||
export * from "./avatar-shape.js";
|
||||
export * from "./bot-messages.js";
|
||||
export * from "./bot-reply-sanitize.js";
|
||||
export * from "./bot-sections.js";
|
||||
|
||||
@ -1,36 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { WORKING_AVATAR_DURATIONS_MS } from "@rakazo/core";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
/** CSS `data-shape-family` → expected duration seconds (mirrors styles.css). */
|
||||
const FAMILY_DURATION_SECONDS = [1.8, 1.35, 1.6, 2.4, 2.4, 1.35, 1.1, 1.35, 1.6, 1.35] as const;
|
||||
|
||||
function workingDurationSecondsForFamily(css: string, family: number): number | null {
|
||||
// Split on rule closers so a wrong duration cannot match a later family's token.
|
||||
for (const chunk of css.split("}")) {
|
||||
if (!chunk.includes(`data-shape-family="${family}"]`)) continue;
|
||||
if (!chunk.includes(".rakazo-organic-avatar-body-working")) continue;
|
||||
const match = chunk.match(/animation:\s*[^;]*?\s([\d.]+)s\b/);
|
||||
if (match?.[1]) return Number(match[1]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
describe("organic working avatar CSS", () => {
|
||||
it("keeps each shape-family duration aligned with shared core choreography", () => {
|
||||
const css = readFileSync(
|
||||
resolve(dirname(fileURLToPath(import.meta.url)), "styles.css"),
|
||||
"utf8",
|
||||
);
|
||||
expect(WORKING_AVATAR_DURATIONS_MS).toHaveLength(FAMILY_DURATION_SECONDS.length);
|
||||
for (let family = 0; family < FAMILY_DURATION_SECONDS.length; family += 1) {
|
||||
const seconds = FAMILY_DURATION_SECONDS[family]!;
|
||||
const sharedMs = WORKING_AVATAR_DURATIONS_MS[family];
|
||||
expect(sharedMs).toBeTypeOf("number");
|
||||
expect(sharedMs! / 1000).toBe(seconds);
|
||||
expect(workingDurationSecondsForFamily(css, family)).toBe(seconds);
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -1,19 +0,0 @@
|
||||
import { createContext, type ReactNode, useContext } from "react";
|
||||
|
||||
export type AvatarStyle = "robot" | "organic";
|
||||
|
||||
const AvatarStyleContext = createContext<AvatarStyle>("robot");
|
||||
|
||||
export function AvatarStyleProvider({
|
||||
children,
|
||||
value,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
value: AvatarStyle;
|
||||
}) {
|
||||
return <AvatarStyleContext value={value}>{children}</AvatarStyleContext>;
|
||||
}
|
||||
|
||||
export function useAvatarStyle(): AvatarStyle {
|
||||
return useContext(AvatarStyleContext);
|
||||
}
|
||||
@ -67,7 +67,6 @@ export interface BotAvatarProps {
|
||||
/** Draw one still frame. */
|
||||
still?: boolean;
|
||||
className?: string;
|
||||
variant?: unknown;
|
||||
}
|
||||
|
||||
function WorkingRing({ size, color }: { size: number; color: string }) {
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
export { type AvatarStyle, AvatarStyleProvider, useAvatarStyle } from "./avatar-style.js";
|
||||
export {
|
||||
BotAvatar,
|
||||
type BotAvatarProps,
|
||||
|
||||
@ -106,445 +106,8 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.rakazo-bot-avatar-eyes {
|
||||
animation: var(--rakazo-eye-animation-name) var(--rakazo-eye-animation-duration)
|
||||
var(--rakazo-eye-animation-easing) var(--rakazo-eye-animation-delay) infinite;
|
||||
transition: opacity 180ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.rakazo-bot-avatar-eyes-working {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.rakazo-bot-avatar[data-working="true"] .rakazo-bot-avatar-eyes-idle {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.rakazo-bot-avatar[data-working="true"] .rakazo-bot-avatar-eyes-working {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.rakazo-organic-avatar-body {
|
||||
transform-origin: center;
|
||||
transition: opacity 180ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.rakazo-organic-avatar-body-idle {
|
||||
animation: rakazo-organic-idle 5.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.rakazo-organic-avatar-body-working {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.rakazo-organic-avatar[data-working="true"] .rakazo-organic-avatar-body-idle {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.rakazo-organic-avatar[data-working="true"] .rakazo-organic-avatar-body-working {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.rakazo-organic-avatar-eyes {
|
||||
transition: opacity 180ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.rakazo-organic-avatar-eyes-working {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.rakazo-organic-avatar[data-working="true"] .rakazo-organic-avatar-eyes-idle {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.rakazo-organic-avatar[data-working="true"] .rakazo-organic-avatar-eyes-working {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.rakazo-organic-avatar[data-eye-pattern="0"] .rakazo-organic-avatar-eyes-idle {
|
||||
animation: rakazo-eyes-idle-0 5.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.rakazo-organic-avatar[data-eye-pattern="1"] .rakazo-organic-avatar-eyes-idle {
|
||||
animation: rakazo-eyes-idle-1 6.4s ease-in-out -1.7s infinite;
|
||||
}
|
||||
|
||||
.rakazo-organic-avatar[data-eye-pattern="2"] .rakazo-organic-avatar-eyes-idle {
|
||||
animation: rakazo-eyes-idle-2 5.2s ease-in-out -3.1s infinite;
|
||||
}
|
||||
|
||||
.rakazo-organic-avatar[data-eye-pattern="3"] .rakazo-organic-avatar-eyes-idle {
|
||||
animation: rakazo-eyes-idle-3 6.8s ease-in-out -2.3s infinite;
|
||||
}
|
||||
|
||||
.rakazo-organic-avatar[data-eye-pattern="0"] .rakazo-organic-avatar-eyes-working {
|
||||
animation: rakazo-organic-eyes-loop-0 6.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.rakazo-organic-avatar[data-eye-pattern="1"] .rakazo-organic-avatar-eyes-working {
|
||||
animation: rakazo-organic-eyes-loop-1 7.2s ease-in-out -1.4s infinite;
|
||||
}
|
||||
|
||||
.rakazo-organic-avatar[data-eye-pattern="2"] .rakazo-organic-avatar-eyes-working {
|
||||
animation: rakazo-organic-eyes-loop-2 6.1s ease-in-out -3.2s infinite;
|
||||
}
|
||||
|
||||
.rakazo-organic-avatar[data-eye-pattern="3"] .rakazo-organic-avatar-eyes-working {
|
||||
animation: rakazo-organic-eyes-loop-3 7.8s ease-in-out -2.1s infinite;
|
||||
}
|
||||
|
||||
/* Durations mirror @rakazo/core WORKING_AVATAR_DURATIONS_MS (avatar-motion). */
|
||||
.rakazo-organic-avatar[data-shape-family="0"] .rakazo-organic-avatar-body-working {
|
||||
animation: rakazo-organic-float 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.rakazo-organic-avatar[data-shape-family="1"] .rakazo-organic-avatar-body-working {
|
||||
animation: rakazo-organic-stretch 1.35s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.rakazo-organic-avatar[data-shape-family="2"] .rakazo-organic-avatar-body-working,
|
||||
.rakazo-organic-avatar[data-shape-family="8"] .rakazo-organic-avatar-body-working {
|
||||
animation: rakazo-organic-sway 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.rakazo-organic-avatar[data-shape-family="3"] .rakazo-organic-avatar-body-working,
|
||||
.rakazo-organic-avatar[data-shape-family="4"] .rakazo-organic-avatar-body-working {
|
||||
animation: rakazo-organic-turn 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.rakazo-organic-avatar[data-shape-family="5"] .rakazo-organic-avatar-body-working,
|
||||
.rakazo-organic-avatar[data-shape-family="9"] .rakazo-organic-avatar-body-working {
|
||||
animation: rakazo-organic-squash 1.35s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.rakazo-organic-avatar[data-shape-family="6"] .rakazo-organic-avatar-body-working {
|
||||
animation: rakazo-organic-pulse 1.1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.rakazo-organic-avatar[data-shape-family="7"] .rakazo-organic-avatar-body-working {
|
||||
transform-origin: center 20%;
|
||||
animation: rakazo-organic-ring 1.35s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes rakazo-organic-float {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(2px) scaleX(1.02);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-3px) scaleX(0.98);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rakazo-organic-idle {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(1.2px) scale(1.012, 0.988);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-1.4px) scale(0.988, 1.012);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rakazo-organic-stretch {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(2px) scale(1.04, 0.96);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-3px) scale(0.96, 1.05);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rakazo-organic-sway {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(-3deg) translateX(-1px);
|
||||
}
|
||||
50% {
|
||||
transform: rotate(3deg) translateX(1px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rakazo-organic-turn {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(-4deg) scale(0.98);
|
||||
}
|
||||
50% {
|
||||
transform: rotate(4deg) scale(1.04);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rakazo-organic-squash {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1.04, 0.96);
|
||||
}
|
||||
50% {
|
||||
transform: scale(0.96, 1.04);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rakazo-organic-pulse {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
45% {
|
||||
transform: scale(1.06);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rakazo-organic-ring {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(-4deg);
|
||||
}
|
||||
35% {
|
||||
transform: rotate(5deg);
|
||||
}
|
||||
65% {
|
||||
transform: rotate(-3deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rakazo-organic-eyes-loop-0 {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(-9%, 1%) scaleY(0.94);
|
||||
}
|
||||
18% {
|
||||
transform: translate(9%, -2%) scaleY(1.04);
|
||||
}
|
||||
34% {
|
||||
transform: translate(0, -8%) scale(1.08);
|
||||
}
|
||||
48% {
|
||||
transform: scale(1.12, 0.82);
|
||||
}
|
||||
51% {
|
||||
transform: scale(1.05, 0.08);
|
||||
}
|
||||
54% {
|
||||
transform: scale(1.12, 0.9);
|
||||
}
|
||||
72% {
|
||||
transform: translate(-5%, 7%) scaleY(0.9);
|
||||
}
|
||||
86% {
|
||||
transform: translate(7%, 3%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rakazo-organic-eyes-loop-1 {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(0, -8%) scale(1.06);
|
||||
}
|
||||
16% {
|
||||
transform: translate(8%, 2%);
|
||||
}
|
||||
31% {
|
||||
transform: translate(0, 8%) scaleY(0.9);
|
||||
}
|
||||
46% {
|
||||
transform: translate(-8%, 2%);
|
||||
}
|
||||
61% {
|
||||
transform: scale(0.88, 1.1);
|
||||
}
|
||||
76% {
|
||||
transform: translateY(-7%) scaleY(1.08);
|
||||
}
|
||||
88% {
|
||||
transform: translateY(6%) scale(1.08, 0.86);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rakazo-organic-eyes-loop-2 {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(0.9, 1.08);
|
||||
}
|
||||
20% {
|
||||
transform: scale(1.14, 0.84);
|
||||
}
|
||||
24% {
|
||||
transform: scale(1.08, 0.08);
|
||||
}
|
||||
28% {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
45% {
|
||||
transform: translate(-9%, -3%) scaleY(0.94);
|
||||
}
|
||||
62% {
|
||||
transform: translate(9%, 2%) scaleY(1.05);
|
||||
}
|
||||
80% {
|
||||
transform: translate(0, -8%) scale(1.06);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rakazo-organic-eyes-loop-3 {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(7%) scaleY(0.9);
|
||||
}
|
||||
14% {
|
||||
transform: translateY(-9%) scaleY(1.08);
|
||||
}
|
||||
30% {
|
||||
transform: translate(8%, -2%);
|
||||
}
|
||||
47% {
|
||||
transform: translate(-8%, 3%) scaleY(0.94);
|
||||
}
|
||||
63% {
|
||||
transform: scale(1.12, 0.82);
|
||||
}
|
||||
78% {
|
||||
transform: translate(0, -7%) scale(0.9, 1.08);
|
||||
}
|
||||
91% {
|
||||
transform: translate(0, 5%) scale(1.06, 0.9);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rakazo-eyes-idle-0 {
|
||||
0%,
|
||||
32%,
|
||||
100% {
|
||||
transform: translate(0, 0) scaleY(1);
|
||||
}
|
||||
35% {
|
||||
transform: translate(0, 0) scaleY(0.08);
|
||||
}
|
||||
38% {
|
||||
transform: translate(0, 0) scaleY(1);
|
||||
}
|
||||
48%,
|
||||
62% {
|
||||
transform: translate(3px, -0.5px) scaleY(1);
|
||||
}
|
||||
70%,
|
||||
84% {
|
||||
transform: translate(-2.5px, 0.5px) scaleY(1);
|
||||
}
|
||||
89% {
|
||||
transform: translate(0, 0) scaleY(0.12);
|
||||
}
|
||||
91% {
|
||||
transform: translate(0, 0) scaleY(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rakazo-eyes-idle-1 {
|
||||
0%,
|
||||
25%,
|
||||
100% {
|
||||
transform: translate(0, 0) scaleY(1);
|
||||
}
|
||||
32%,
|
||||
48% {
|
||||
transform: translate(-3px, -1px) scaleY(1);
|
||||
}
|
||||
54% {
|
||||
transform: translate(0, 0) scaleY(0.08);
|
||||
}
|
||||
56% {
|
||||
transform: translate(0, 0) scaleY(1);
|
||||
}
|
||||
59% {
|
||||
transform: translate(0, 0) scaleY(0.08);
|
||||
}
|
||||
61% {
|
||||
transform: translate(0, 0) scaleY(1);
|
||||
}
|
||||
68%,
|
||||
82% {
|
||||
transform: translate(2.5px, 0.5px) scaleY(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rakazo-eyes-idle-2 {
|
||||
0%,
|
||||
28%,
|
||||
100% {
|
||||
transform: translate(0, 0) scaleY(1);
|
||||
}
|
||||
36%,
|
||||
52% {
|
||||
transform: translate(0.5px, -1.8px) scaleY(1.05);
|
||||
}
|
||||
58% {
|
||||
transform: translate(0, 0) scaleY(0.08);
|
||||
}
|
||||
60% {
|
||||
transform: translate(0, 0) scaleY(1);
|
||||
}
|
||||
68%,
|
||||
85% {
|
||||
transform: translate(3px, 0) scaleY(1);
|
||||
}
|
||||
92% {
|
||||
transform: translate(0, 0) scaleY(0.1);
|
||||
}
|
||||
94% {
|
||||
transform: translate(0, 0) scaleY(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rakazo-eyes-idle-3 {
|
||||
0%,
|
||||
24%,
|
||||
100% {
|
||||
transform: translate(0, 0) scaleY(1);
|
||||
}
|
||||
30%,
|
||||
46% {
|
||||
transform: translate(-2.5px, 1.2px) scaleY(0.95);
|
||||
}
|
||||
52% {
|
||||
transform: translate(0, 0) scaleY(0.08);
|
||||
}
|
||||
54% {
|
||||
transform: translate(0, 0) scaleY(1);
|
||||
}
|
||||
62%,
|
||||
78% {
|
||||
transform: translate(2.8px, -1px) scaleY(1);
|
||||
}
|
||||
86% {
|
||||
transform: translate(0, 0) scaleY(0.08);
|
||||
}
|
||||
88% {
|
||||
transform: translate(0, 0) scaleY(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rakazo-eyes-working {
|
||||
0% {
|
||||
transform: translate(-4px, 0) scale(0.92, 0.95);
|
||||
}
|
||||
25% {
|
||||
transform: translate(0, -2px) scale(1.08, 1.08);
|
||||
}
|
||||
50% {
|
||||
transform: translate(4px, 0) scale(0.92, 0.95);
|
||||
}
|
||||
75% {
|
||||
transform: translate(0, 2px) scale(1, 0.9);
|
||||
}
|
||||
100% {
|
||||
transform: translate(-4px, 0) scale(0.92, 0.95);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.rakazo-bot-avatar-ring,
|
||||
.rakazo-bot-avatar-eyes {
|
||||
.rakazo-bot-avatar-ring {
|
||||
animation: none;
|
||||
transition: none;
|
||||
}
|
||||
@ -552,19 +115,4 @@
|
||||
.rakazo-bot-avatar {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.rakazo-bot-avatar-visor {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.rakazo-organic-avatar[data-shape-family] .rakazo-organic-avatar-body {
|
||||
animation: none;
|
||||
d: var(--rakazo-organic-path);
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.rakazo-organic-avatar[data-eye-pattern] .rakazo-organic-avatar-eyes {
|
||||
animation: none;
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user