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:
cynarAI 2026-10-05 00:36:46 +02:00 committed by GitHub
parent 47988602e2
commit 7db3a56fe8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 319 additions and 1425 deletions

View File

@ -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,74 +56,72 @@ export default function Layout() {
<GestureHandlerRootView style={{ flex: 1 }}>
<KeyboardProvider>
{ready ? (
<AvatarStyleProvider>
<ThemeProvider value={navigationTheme}>
<StatusBar style={resolved === "light" ? "dark" : "light"} />
<Stack
screenOptions={{
headerStyle: { backgroundColor: navigationTheme.colors.background },
headerTintColor: navigationTheme.colors.text,
headerShadowVisible: false,
headerBackButtonDisplayMode: "minimal",
contentStyle: { backgroundColor: String(native.page) },
<ThemeProvider value={navigationTheme}>
<StatusBar style={resolved === "light" ? "dark" : "light"} />
<Stack
screenOptions={{
headerStyle: { backgroundColor: navigationTheme.colors.background },
headerTintColor: navigationTheme.colors.text,
headerShadowVisible: false,
headerBackButtonDisplayMode: "minimal",
contentStyle: { backgroundColor: String(native.page) },
}}
>
<Stack.Screen name="index" options={{ headerShown: false, title: "Rakazo" }} />
<Stack.Screen name="sign-in" options={{ headerShown: false }} />
<Stack.Screen
name="integration-setup"
options={{ title: t("Server integrations") }}
/>
<Stack.Screen name="ai-data-sharing" options={{ title: "AI data sharing" }} />
<Stack.Screen name="account" options={{ title: t("Account") }} />
<Stack.Screen
name="change-password"
options={{
title: t("Change password"),
presentation: "formSheet",
sheetAllowedDetents: [0.6, 1],
sheetGrabberVisible: true,
}}
>
<Stack.Screen name="index" options={{ headerShown: false, title: "Rakazo" }} />
<Stack.Screen name="sign-in" options={{ headerShown: false }} />
<Stack.Screen
name="integration-setup"
options={{ title: t("Server integrations") }}
/>
<Stack.Screen name="ai-data-sharing" options={{ title: "AI data sharing" }} />
<Stack.Screen name="account" options={{ title: t("Account") }} />
<Stack.Screen
name="change-password"
options={{
title: t("Change password"),
presentation: "formSheet",
sheetAllowedDetents: [0.6, 1],
sheetGrabberVisible: true,
}}
/>
<Stack.Screen name="models" options={{ title: t("Models") }} />
<Stack.Screen name="voice" options={{ title: t("Voice") }} />
<Stack.Screen name="integrations" options={{ title: t("Integrations") }} />
<Stack.Screen
name="new"
options={{
title: t("New bot"),
presentation: "modal",
gestureEnabled: true,
headerBackVisible: false,
}}
/>
<Stack.Screen
name="new-group"
options={{
title: t("New group"),
presentation: "modal",
gestureEnabled: true,
}}
/>
<Stack.Screen
name="new-space"
options={{
title: t("New space"),
presentation: "modal",
gestureEnabled: true,
headerBackVisible: false,
}}
/>
<Stack.Screen name="group-thread" options={{ title: t("Group") }} />
<Stack.Screen name="group-settings" options={{ title: t("Group settings") }} />
<Stack.Screen name="bot-settings" options={{ title: t("Chat settings") }} />
<Stack.Screen name="thread" options={{ title: t("Thread") }} />
<Stack.Screen name="routine" options={{ title: t("Routine") }} />
<Stack.Screen name="computer" options={{ title: t("Computer") }} />
</Stack>
<ComputerUpdateProgress />
</ThemeProvider>
</AvatarStyleProvider>
/>
<Stack.Screen name="models" options={{ title: t("Models") }} />
<Stack.Screen name="voice" options={{ title: t("Voice") }} />
<Stack.Screen name="integrations" options={{ title: t("Integrations") }} />
<Stack.Screen
name="new"
options={{
title: t("New bot"),
presentation: "modal",
gestureEnabled: true,
headerBackVisible: false,
}}
/>
<Stack.Screen
name="new-group"
options={{
title: t("New group"),
presentation: "modal",
gestureEnabled: true,
}}
/>
<Stack.Screen
name="new-space"
options={{
title: t("New space"),
presentation: "modal",
gestureEnabled: true,
headerBackVisible: false,
}}
/>
<Stack.Screen name="group-thread" options={{ title: t("Group") }} />
<Stack.Screen name="group-settings" options={{ title: t("Group settings") }} />
<Stack.Screen name="bot-settings" options={{ title: t("Chat settings") }} />
<Stack.Screen name="thread" options={{ title: t("Thread") }} />
<Stack.Screen name="routine" options={{ title: t("Routine") }} />
<Stack.Screen name="computer" options={{ title: t("Computer") }} />
</Stack>
<ComputerUpdateProgress />
</ThemeProvider>
) : (
<View style={{ flex: 1, backgroundColor: String(native.page) }} />
)}

View File

@ -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,

View File

@ -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);
}

View File

@ -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} />
{picture ? (
<Image
source={{ uri: picture }}
style={{ width: size, height: size, borderRadius: size / 2 }}
/>
) : (
<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",
}}
/>
))}
</View>
</View>
<OoFigure
avatar={look}
color={body}
fallbackShape={look.shape ?? ooIdentityShapeIndex(seed)}
moment={moment ?? ooMomentForStatus(status)}
size={size}
still={still}
/>
)}
{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>
);
}

View 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>
);
}

View File

@ -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);
});
});

View File

@ -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";

View File

@ -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: "Выполняется",

View File

@ -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: "运行中",

View File

@ -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"

View File

@ -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"

View File

@ -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 ""

View File

@ -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 ""

View File

@ -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 ""

View File

@ -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 ""

View File

@ -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 "Инструкции для комнаты"

View File

@ -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 ""

View File

@ -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 ""

View File

@ -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>

View File

@ -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

View File

@ -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;
}

View File

@ -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);
});
});

View File

@ -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;
}

View File

@ -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);
});
});

View File

@ -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`;
}

View File

@ -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";

View File

@ -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);
}
});
});

View File

@ -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);
}

View File

@ -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 }) {

View File

@ -1,4 +1,3 @@
export { type AvatarStyle, AvatarStyleProvider, useAvatarStyle } from "./avatar-style.js";
export {
BotAvatar,
type BotAvatarProps,

View File

@ -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;
}
}