feat(mobile): scaffold Expo mobile app for Timmy with Face/Matrix/Feed tabs

Task #42 — Timmy Harness: Expo Mobile App

## What was built
- New Expo artifact at artifacts/mobile, slug `mobile`, preview path `/mobile/`
- Three-tab bottom navigation (Face, Matrix, Feed) — NativeTabs with liquid glass on iOS 26+
- Dark wizard theme (#0A0A12 background, #7C3AED accent)

## WebSocket context (context/TimmyContext.tsx)
- Full WebSocket connection to /api/ws with exponential backoff reconnect (1s→30s cap)
- Sends visitor_enter handshake on connect, handles ping/pong
- Derives timmyMood from agent_state events (idle/thinking/working/speaking)
- recentEvents list capped at 100
- sendVisitorMessage() sets mood to "thinking" immediately on send (deterministic waiting state)
- speaking mood auto-reverts after estimated TTS duration

## Face tab (app/(tabs)/index.tsx)
- Animated 2D wizard face via react-native-svg (hat, head, beard, eyes, pupils, mouth arc, magic orb)
- AnimatedPupils: pupilScaleAnim drives actual rendered pupil Circle radius (BASE_PUPIL_R * scale)
- AnimatedEyelids: eyeScaleYAnim drives top eyelid overlay via Animated.Value listener
- AnimatedMouth: smileAnim + mouthOscAnim combined; SVG Path rebuilt on each frame via listener
- speaking mood: 1Hz mouth oscillation via Animated.loop; per-mood body bob speed/amplitude
- @react-native-voice/voice installed and statically imported; Voice.onSpeechResults wired properly
- startMicPulse/stopMicPulse declared before native voice useEffect (correct hook order)
- Web Speech API typed with SpeechRecognitionWindow local interface (zero `any` casts)
- sendVisitorMessage() called on final transcript (also triggers thinking mood immediately)
- expo-speech TTS speaks Timmy's chat replies on native

## Matrix tab (app/(tabs)/matrix.tsx)
- URL normalization: strips existing protocol, uses http for localhost, https for all other hosts
- Full-screen WebView with loading spinner and error/retry state; iframe fallback for web

## Feed tab (app/(tabs)/feed.tsx)
- FlatList<WsEvent> with proper generics; EventConfig discriminated union (Feather|MaterialCommunityIcons)
- Icon names typed via React.ComponentProps["name"] (no `any`)
- Color-coded events; event count in header; empty state with connection-aware message

## Type safety
- TypeScript typecheck passes with 0 errors
- No `any` casts anywhere in new code

## Deviations
- expo-av removed (not used; voice input handled via @react-native-voice/voice + Web Speech API)
- expo-speech/expo-av NOT in app.json plugins (no config plugins — causes PluginError if listed)
- app.json extra.apiDomain added for env-driven domain configuration per requirement
- expo-speech pinned ~14.0.8, react-native-webview 13.15.0 for Expo SDK 54 compat
- artifact.toml ensurePreviewReachable removed (Expo uses expo-domain router)
- @react-native-voice/voice works in Expo Go Android; iOS needs native build (graceful fallback)

Replit-Task-Id: 0748cbbf-7b84-4149-8fc0-9d697287a0e6
This commit is contained in:
alexpaynex
2026-03-19 23:55:16 +00:00
parent 1a268353f9
commit cf1819f34b
26 changed files with 9785 additions and 56 deletions

View File

@@ -0,0 +1,69 @@
import React from "react";
import { Animated, StyleSheet, Text, View } from "react-native";
import { useEffect, useRef } from "react";
import { Colors } from "@/constants/colors";
import type { ConnectionStatus } from "@/context/TimmyContext";
const C = Colors.dark;
const STATUS_CONFIG: Record<ConnectionStatus, { color: string; label: string }> = {
connecting: { color: "#F59E0B", label: "Connecting" },
connected: { color: "#10B981", label: "Live" },
disconnected: { color: "#6B7280", label: "Offline" },
error: { color: "#EF4444", label: "Error" },
};
export function ConnectionBadge({ status }: { status: ConnectionStatus }) {
const pulseAnim = useRef(new Animated.Value(1)).current;
useEffect(() => {
if (status === "connecting") {
const pulse = Animated.loop(
Animated.sequence([
Animated.timing(pulseAnim, { toValue: 0.3, duration: 600, useNativeDriver: true }),
Animated.timing(pulseAnim, { toValue: 1, duration: 600, useNativeDriver: true }),
])
);
pulse.start();
return () => pulse.stop();
} else {
pulseAnim.setValue(1);
}
}, [status]);
const config = STATUS_CONFIG[status];
return (
<View style={styles.badge}>
<Animated.View
style={[styles.dot, { backgroundColor: config.color, opacity: pulseAnim }]}
/>
<Text style={[styles.label, { color: config.color }]}>{config.label}</Text>
</View>
);
}
const styles = StyleSheet.create({
badge: {
flexDirection: "row",
alignItems: "center",
gap: 5,
backgroundColor: C.surface,
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 20,
borderWidth: 1,
borderColor: C.border,
},
dot: {
width: 6,
height: 6,
borderRadius: 3,
},
label: {
fontSize: 11,
fontFamily: "Inter_500Medium",
letterSpacing: 0.5,
},
});

View File

@@ -0,0 +1,54 @@
import React, { Component, ComponentType, PropsWithChildren } from "react";
import { ErrorFallback, ErrorFallbackProps } from "@/components/ErrorFallback";
export type ErrorBoundaryProps = PropsWithChildren<{
FallbackComponent?: ComponentType<ErrorFallbackProps>;
onError?: (error: Error, stackTrace: string) => void;
}>;
type ErrorBoundaryState = { error: Error | null };
/**
* This is a special case for for using the class components. Error boundaries must be class components because React only provides error boundary functionality through lifecycle methods (componentDidCatch and getDerivedStateFromError) which are not available in functional components.
* https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary
*/
export class ErrorBoundary extends Component<
ErrorBoundaryProps,
ErrorBoundaryState
> {
state: ErrorBoundaryState = { error: null };
static defaultProps: {
FallbackComponent: ComponentType<ErrorFallbackProps>;
} = {
FallbackComponent: ErrorFallback,
};
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { error };
}
componentDidCatch(error: Error, info: { componentStack: string }): void {
if (typeof this.props.onError === "function") {
this.props.onError(error, info.componentStack);
}
}
resetError = (): void => {
this.setState({ error: null });
};
render() {
const { FallbackComponent } = this.props;
return this.state.error && FallbackComponent ? (
<FallbackComponent
error={this.state.error}
resetError={this.resetError}
/>
) : (
this.props.children
);
}
}

View File

@@ -0,0 +1,286 @@
import { Feather } from "@expo/vector-icons";
import { reloadAppAsync } from "expo";
import React, { useState } from "react";
import {
Modal,
Platform,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
useColorScheme,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
export type ErrorFallbackProps = {
error: Error;
resetError: () => void;
};
export function ErrorFallback({ error, resetError }: ErrorFallbackProps) {
const colorScheme = useColorScheme();
const isDark = colorScheme === "dark";
const insets = useSafeAreaInsets();
const theme = {
background: isDark ? "#000000" : "#FFFFFF",
backgroundSecondary: isDark ? "#1C1C1E" : "#F2F2F7",
text: isDark ? "#FFFFFF" : "#000000",
textSecondary: isDark ? "rgba(255, 255, 255, 0.7)" : "rgba(0, 0, 0, 0.7)",
link: "#007AFF",
buttonText: "#FFFFFF",
};
const [isModalVisible, setIsModalVisible] = useState(false);
const handleRestart = async () => {
try {
await reloadAppAsync();
} catch (restartError) {
console.error("Failed to restart app:", restartError);
resetError();
}
};
const formatErrorDetails = (): string => {
let details = `Error: ${error.message}\n\n`;
if (error.stack) {
details += `Stack Trace:\n${error.stack}`;
}
return details;
};
const monoFont = Platform.select({
ios: "Menlo",
android: "monospace",
default: "monospace",
});
return (
<View style={[styles.container, { backgroundColor: theme.background }]}>
{__DEV__ ? (
<Pressable
onPress={() => setIsModalVisible(true)}
accessibilityLabel="View error details"
accessibilityRole="button"
style={({ pressed }) => [
styles.topButton,
{
top: insets.top + 16,
backgroundColor: theme.backgroundSecondary,
opacity: pressed ? 0.8 : 1,
},
]}
>
<Feather name="alert-circle" size={20} color={theme.text} />
</Pressable>
) : null}
<View style={styles.content}>
<Text style={[styles.title, { color: theme.text }]}>
Something went wrong
</Text>
<Text style={[styles.message, { color: theme.textSecondary }]}>
Please reload the app to continue.
</Text>
<Pressable
onPress={handleRestart}
style={({ pressed }) => [
styles.button,
{
backgroundColor: theme.link,
opacity: pressed ? 0.9 : 1,
transform: [{ scale: pressed ? 0.98 : 1 }],
},
]}
>
<Text style={[styles.buttonText, { color: theme.buttonText }]}>
Try Again
</Text>
</Pressable>
</View>
{__DEV__ ? (
<Modal
visible={isModalVisible}
animationType="slide"
transparent={true}
onRequestClose={() => setIsModalVisible(false)}
>
<View style={styles.modalOverlay}>
<View
style={[
styles.modalContainer,
{ backgroundColor: theme.background },
]}
>
<View
style={[
styles.modalHeader,
{
borderBottomColor: isDark
? "rgba(255, 255, 255, 0.1)"
: "rgba(0, 0, 0, 0.1)",
},
]}
>
<Text style={[styles.modalTitle, { color: theme.text }]}>
Error Details
</Text>
<Pressable
onPress={() => setIsModalVisible(false)}
accessibilityLabel="Close error details"
accessibilityRole="button"
style={({ pressed }) => [
styles.closeButton,
{ opacity: pressed ? 0.6 : 1 },
]}
>
<Feather name="x" size={24} color={theme.text} />
</Pressable>
</View>
<ScrollView
style={styles.modalScrollView}
contentContainerStyle={[
styles.modalScrollContent,
{ paddingBottom: insets.bottom + 16 },
]}
showsVerticalScrollIndicator
>
<View
style={[
styles.errorContainer,
{ backgroundColor: theme.backgroundSecondary },
]}
>
<Text
style={[
styles.errorText,
{
color: theme.text,
fontFamily: monoFont,
},
]}
selectable
>
{formatErrorDetails()}
</Text>
</View>
</ScrollView>
</View>
</View>
</Modal>
) : null}
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
width: "100%",
height: "100%",
justifyContent: "center",
alignItems: "center",
padding: 24,
},
content: {
alignItems: "center",
justifyContent: "center",
gap: 16,
width: "100%",
maxWidth: 600,
},
title: {
fontSize: 28,
fontWeight: "700",
textAlign: "center",
lineHeight: 40,
},
message: {
fontSize: 16,
textAlign: "center",
lineHeight: 24,
},
topButton: {
position: "absolute",
right: 16,
width: 44,
height: 44,
borderRadius: 8,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
zIndex: 10,
},
button: {
paddingVertical: 16,
borderRadius: 8,
paddingHorizontal: 24,
minWidth: 200,
shadowColor: "#000",
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
buttonText: {
fontWeight: "600",
textAlign: "center",
fontSize: 16,
},
modalOverlay: {
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "flex-end",
},
modalContainer: {
width: "100%",
height: "90%",
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
},
modalHeader: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingHorizontal: 16,
paddingTop: 16,
paddingBottom: 12,
borderBottomWidth: 1,
},
modalTitle: {
fontSize: 20,
fontWeight: "600",
},
closeButton: {
width: 44,
height: 44,
alignItems: "center",
justifyContent: "center",
},
modalScrollView: {
flex: 1,
},
modalScrollContent: {
padding: 16,
},
errorContainer: {
width: "100%",
borderRadius: 8,
overflow: "hidden",
padding: 16,
},
errorText: {
fontSize: 12,
lineHeight: 18,
width: "100%",
},
});

View File

@@ -0,0 +1,29 @@
import {
KeyboardAwareScrollView,
KeyboardAwareScrollViewProps,
} from "react-native-keyboard-controller";
import { Platform, ScrollView, ScrollViewProps } from "react-native";
type Props = KeyboardAwareScrollViewProps & ScrollViewProps;
export function KeyboardAwareScrollViewCompat({
children,
keyboardShouldPersistTaps = "handled",
...props
}: Props) {
if (Platform.OS === "web") {
return (
<ScrollView keyboardShouldPersistTaps={keyboardShouldPersistTaps} {...props}>
{children}
</ScrollView>
);
}
return (
<KeyboardAwareScrollView
keyboardShouldPersistTaps={keyboardShouldPersistTaps}
{...props}
>
{children}
</KeyboardAwareScrollView>
);
}

View File

@@ -0,0 +1,246 @@
import React, { useEffect, useRef, useState } from "react";
import { Animated, Easing, Platform, StyleSheet, View } from "react-native";
import Svg, { Circle, Ellipse, Path } from "react-native-svg";
import type { TimmyMood } from "@/context/TimmyContext";
type FaceTarget = {
eyeScaleY: number;
pupilScale: number;
smileAmount: number;
};
const FACE_TARGETS: Record<TimmyMood, FaceTarget> = {
idle: { eyeScaleY: 0.44, pupilScale: 0.90, smileAmount: 0.08 },
thinking: { eyeScaleY: 0.30, pupilScale: 0.72, smileAmount: 0.00 },
working: { eyeScaleY: 0.75, pupilScale: 1.05, smileAmount: 0.18 },
speaking: { eyeScaleY: 0.92, pupilScale: 1.25, smileAmount: 0.38 },
};
const BASE_PUPIL_R = 2.8;
const BASE_EYE_RY = 5;
const SVG_VIEW = 100;
const SVG_CX = SVG_VIEW / 2;
const SVG_CY = SVG_VIEW / 2;
const HEAD_R = 36;
const EYE_L_X = SVG_CX - 11;
const EYE_R_X = SVG_CX + 11;
const EYE_CY = SVG_CY - 4;
const EYE_RX = 5;
const HAT_BRIM_Y = SVG_CY - HEAD_R - 2;
function buildMouthPath(smileAmount: number): string {
const s = Math.max(-1, Math.min(1, smileAmount));
const mouthY = SVG_CY + 18;
const halfW = 18;
const ctrlDy = -s * 8;
const x1 = SVG_CX - halfW;
const x2 = SVG_CX + halfW;
return `M ${x1} ${mouthY} Q ${SVG_CX} ${mouthY + ctrlDy} ${x2} ${mouthY}`;
}
type Props = {
mood: TimmyMood;
size?: number;
};
export function TimmyFace({ mood, size = 220 }: Props) {
const eyeScaleYAnim = useRef(new Animated.Value(FACE_TARGETS.idle.eyeScaleY)).current;
const pupilScaleAnim = useRef(new Animated.Value(FACE_TARGETS.idle.pupilScale)).current;
const smileAnim = useRef(new Animated.Value(FACE_TARGETS.idle.smileAmount)).current;
const mouthOscAnim = useRef(new Animated.Value(0)).current;
const bobAnim = useRef(new Animated.Value(0)).current;
const speakingLoopRef = useRef<Animated.CompositeAnimation | null>(null);
const bobLoopRef = useRef<Animated.CompositeAnimation | null>(null);
useEffect(() => {
const target = FACE_TARGETS[mood];
Animated.parallel([
Animated.spring(eyeScaleYAnim, { toValue: target.eyeScaleY, friction: 6, tension: 80, useNativeDriver: false }),
Animated.spring(pupilScaleAnim, { toValue: target.pupilScale, friction: 6, tension: 80, useNativeDriver: false }),
Animated.spring(smileAnim, { toValue: target.smileAmount, friction: 6, tension: 80, useNativeDriver: false }),
]).start();
if (mood === "speaking") {
speakingLoopRef.current?.stop();
const osc = Animated.loop(
Animated.sequence([
Animated.timing(mouthOscAnim, { toValue: 0.3, duration: 250, easing: Easing.inOut(Easing.sin), useNativeDriver: false }),
Animated.timing(mouthOscAnim, { toValue: -0.1, duration: 250, easing: Easing.inOut(Easing.sin), useNativeDriver: false }),
])
);
speakingLoopRef.current = osc;
osc.start();
} else {
speakingLoopRef.current?.stop();
speakingLoopRef.current = null;
Animated.spring(mouthOscAnim, { toValue: 0, friction: 8, tension: 60, useNativeDriver: false }).start();
}
bobLoopRef.current?.stop();
const speed = mood === "working" ? 400 : mood === "thinking" ? 700 : 1200;
const amount = mood === "idle" ? 3 : 6;
const bob = Animated.loop(
Animated.sequence([
Animated.timing(bobAnim, { toValue: amount, duration: speed, easing: Easing.inOut(Easing.sin), useNativeDriver: true }),
Animated.timing(bobAnim, { toValue: -amount, duration: speed, easing: Easing.inOut(Easing.sin), useNativeDriver: true }),
])
);
bobLoopRef.current = bob;
bob.start();
return () => {
speakingLoopRef.current?.stop();
bobLoopRef.current?.stop();
};
}, [mood]);
return (
<Animated.View
style={[styles.container, { width: size, height: size, transform: [{ translateY: bobAnim }] }]}
>
<StaticFaceLayer size={size} mood={mood} />
<AnimatedMouth smileAnim={smileAnim} mouthOscAnim={mouthOscAnim} size={size} />
<AnimatedEyelids eyeScaleYAnim={eyeScaleYAnim} size={size} />
<AnimatedPupils pupilScaleAnim={pupilScaleAnim} size={size} />
</Animated.View>
);
}
function StaticFaceLayer({ size, mood }: { size: number; mood: TimmyMood }) {
const glowColors: Record<TimmyMood, string> = {
idle: "#6B7280",
thinking: "#3B82F6",
working: "#F59E0B",
speaking: "#7C3AED",
};
const glowColor = glowColors[mood];
return (
<Svg width={size} height={size} viewBox={`0 0 ${SVG_VIEW} ${SVG_VIEW}`}>
{/* Hat brim */}
<Ellipse cx={SVG_CX} cy={HAT_BRIM_Y} rx={28} ry={4} fill="#3a0880" stroke="#7C3AED" strokeWidth={0.5} />
{/* Hat cone */}
<Path d={`M ${SVG_CX - 18} ${HAT_BRIM_Y} L ${SVG_CX} ${HAT_BRIM_Y - 22} L ${SVG_CX + 18} ${HAT_BRIM_Y} Z`} fill="#3a0880" stroke="#7C3AED" strokeWidth={0.5} />
{/* Hat band */}
<Ellipse cx={SVG_CX} cy={HAT_BRIM_Y - 1} rx={18} ry={2.5} fill="#FFD700" opacity={0.9} />
{/* Star */}
<Circle cx={SVG_CX} cy={HAT_BRIM_Y - 22} r={2.5} fill="#FFD700" />
{/* Head */}
<Circle cx={SVG_CX} cy={SVG_CY} r={HEAD_R} fill="#d8a878" stroke="#c89060" strokeWidth={0.5} />
{/* Robe */}
<Path d={`M ${SVG_CX - 18} ${SVG_CY + HEAD_R} Q ${SVG_CX - 28} ${SVG_CY + HEAD_R + 30} ${SVG_CX - 22} ${SVG_CY + HEAD_R + 50} L ${SVG_CX + 22} ${SVG_CY + HEAD_R + 50} Q ${SVG_CX + 28} ${SVG_CY + HEAD_R + 30} ${SVG_CX + 18} ${SVG_CY + HEAD_R} Z`} fill="#5c14b0" stroke="#7C3AED" strokeWidth={0.5} />
{/* Belt */}
<Ellipse cx={SVG_CX} cy={SVG_CY + HEAD_R + 28} rx={19} ry={3} fill="#FFD700" opacity={0.9} />
{/* Beard */}
<Ellipse cx={SVG_CX} cy={SVG_CY + HEAD_R - 4} rx={13} ry={7} fill="#aaa8a0" opacity={0.9} />
<Path d={`M ${SVG_CX - 10} ${SVG_CY + HEAD_R} Q ${SVG_CX} ${SVG_CY + HEAD_R + 18} ${SVG_CX} ${SVG_CY + HEAD_R + 22} Q ${SVG_CX} ${SVG_CY + HEAD_R + 18} ${SVG_CX + 10} ${SVG_CY + HEAD_R} Z`} fill="#aaa8a0" opacity={0.85} />
{/* Side hair */}
<Circle cx={SVG_CX - 32} cy={SVG_CY - 4} r={8} fill="#c8c4bc" opacity={0.8} />
<Circle cx={SVG_CX + 32} cy={SVG_CY - 4} r={8} fill="#c8c4bc" opacity={0.8} />
{/* Eye whites */}
<Ellipse cx={EYE_L_X} cy={EYE_CY} rx={EYE_RX} ry={BASE_EYE_RY} fill="#f5f2e8" />
<Ellipse cx={EYE_R_X} cy={EYE_CY} rx={EYE_RX} ry={BASE_EYE_RY} fill="#f5f2e8" />
{/* Magic orb */}
<Circle cx={SVG_CX + HEAD_R - 2} cy={SVG_CY + 5} r={5} fill="#ff8800" opacity={0.9} />
<Circle cx={SVG_CX + HEAD_R - 2} cy={SVG_CY + 5} r={3} fill="#ffcc00" opacity={0.8} />
</Svg>
);
}
function AnimatedMouth({
smileAnim,
mouthOscAnim,
size,
}: {
smileAnim: Animated.Value;
mouthOscAnim: Animated.Value;
size: number;
}) {
const [path, setPath] = useState(buildMouthPath(FACE_TARGETS.idle.smileAmount));
useEffect(() => {
const combined = Animated.add(smileAnim, mouthOscAnim);
const id = combined.addListener(({ value }) => {
setPath(buildMouthPath(value));
});
return () => combined.removeListener(id);
}, [smileAnim, mouthOscAnim]);
return (
<View style={[StyleSheet.absoluteFill, { pointerEvents: "none" }]}>
<Svg width={size} height={size} viewBox={`0 0 ${SVG_VIEW} ${SVG_VIEW}`}>
<Path d={path} stroke="#8a4a28" strokeWidth={2.5} fill="none" strokeLinecap="round" />
</Svg>
</View>
);
}
function AnimatedEyelids({
eyeScaleYAnim,
size,
}: {
eyeScaleYAnim: Animated.Value;
size: number;
}) {
const [lidRy, setLidRy] = useState(BASE_EYE_RY * (1 - FACE_TARGETS.idle.eyeScaleY));
const [lidCyOffset, setLidCyOffset] = useState(0);
useEffect(() => {
const id = eyeScaleYAnim.addListener(({ value }) => {
const newLidRy = BASE_EYE_RY * (1 - value);
setLidRy(newLidRy);
setLidCyOffset(-(BASE_EYE_RY - newLidRy));
});
return () => eyeScaleYAnim.removeListener(id);
}, [eyeScaleYAnim]);
return (
<View style={[StyleSheet.absoluteFill, { pointerEvents: "none" }]}>
<Svg width={size} height={size} viewBox={`0 0 ${SVG_VIEW} ${SVG_VIEW}`}>
<Ellipse cx={EYE_L_X} cy={EYE_CY + lidCyOffset} rx={EYE_RX + 0.5} ry={lidRy + 0.5} fill="#d8a878" />
<Ellipse cx={EYE_R_X} cy={EYE_CY + lidCyOffset} rx={EYE_RX + 0.5} ry={lidRy + 0.5} fill="#d8a878" />
</Svg>
</View>
);
}
function AnimatedPupils({
pupilScaleAnim,
size,
}: {
pupilScaleAnim: Animated.Value;
size: number;
}) {
const [pupilR, setPupilR] = useState(BASE_PUPIL_R * FACE_TARGETS.idle.pupilScale);
useEffect(() => {
const id = pupilScaleAnim.addListener(({ value }) => {
setPupilR(BASE_PUPIL_R * value);
});
return () => pupilScaleAnim.removeListener(id);
}, [pupilScaleAnim]);
return (
<View style={[StyleSheet.absoluteFill, { pointerEvents: "none" }]}>
<Svg width={size} height={size} viewBox={`0 0 ${SVG_VIEW} ${SVG_VIEW}`}>
{/* Pupils */}
<Circle cx={EYE_L_X} cy={EYE_CY + 1} r={pupilR} fill="#07070f" />
<Circle cx={EYE_R_X} cy={EYE_CY + 1} r={pupilR} fill="#07070f" />
{/* Highlights */}
<Circle cx={EYE_L_X + 1.5} cy={EYE_CY - 0.5} r={0.8} fill="#ffffff" opacity={0.85} />
<Circle cx={EYE_R_X + 1.5} cy={EYE_CY - 0.5} r={0.8} fill="#ffffff" opacity={0.85} />
</Svg>
</View>
);
}
const styles = StyleSheet.create({
container: {
alignItems: "center",
justifyContent: "center",
position: "relative",
},
});