diff --git a/.agents/skills/create-ui-components/references/component-architecture.md b/.agents/skills/create-ui-components/references/component-architecture.md
new file mode 100644
index 000000000..ce76aaeed
--- /dev/null
+++ b/.agents/skills/create-ui-components/references/component-architecture.md
@@ -0,0 +1,134 @@
+
+Component architecture patterns for building composable, accessible, reusable UI in React Native. Follows the components.build specification and Radix composition pattern.
+
+
+
+Understand the hierarchy:
+
+1. **Primitive** - Lowest-level building block providing behavior and accessibility without styling (e.g., Radix UI Primitives)
+2. **Component** - Styled, reusable UI unit that adds visual design to primitives (e.g., shadcn/ui components)
+3. **Pattern** - Specific composition solving a UI/UX problem (e.g., form validation with inline errors)
+
+
+
+**Decision order when building features:**
+
+1. **Check existing UI components** (in order):
+ - [React Native Reusables](https://reactnativereusables.com/docs) - UI components
+ - [React Native Primitives](https://rn-primitives.vercel.app/) - Radix primitives
+ - [RNR Community Resources](https://github.com/founded-labs/react-native-reusables/blob/main/COMMUNITY_RESOURCES.md)
+
+2. **Check Expo SDK** - [Expo SDK docs](https://docs.expo.dev/versions/latest/) for native APIs
+
+3. **Only then** consider third-party libraries or custom components.
+
+
+
+
+
+- **Children** (implicit slot): JSX between opening/closing tags
+- **Named slots**: Props like `icon`, `footer`, or `` subcomponents
+- **Slot forwarding**: Pass DOM attributes/className/refs through to underlying element
+
+
+
+Use when parent must own data/behavior but consumer controls markup:
+
+```tsx
+
+ {(item) => }
+
+```
+
+
+
+Use separate component imports to compose complex UI (shadcn-style):
+
+```tsx
+import {
+ Card,
+ CardHeader,
+ CardContent,
+ CardFooter
+} from '@/components/ui/card';
+
+
+ Title
+ Body
+ Actions
+;
+```
+
+
+
+- **Controlled**: Value driven by props, emits `onChange` (source of truth is parent)
+- **Uncontrolled**: Holds internal state, may expose `defaultValue` and imperative reset
+- Many inputs should support both patterns
+
+
+
+Use `asChild` prop to render as a different element:
+
+```tsx
+
+```
+
+Renders as `` instead of `
+
+
+
+
+
+
+- Document and implement keyboard map for every interactive component
+- Support standard patterns: `Tab`, `Arrow keys`, `Home/End`, `Escape`
+- All interactive elements must be keyboard accessible
+
+
+
+- Rules for initial focus, roving focus, focus trapping
+- Focus return on teardown (e.g., modals)
+- Focus indicators visible and clear
+
+
+
+- Use semantic HTML elements (`
+
+
+- Ensure sufficient contrast for text and interactive elements
+- Don't rely solely on color to convey information
+
+
+
+
+
+- **TypeScript**: Ship with comprehensive types for safety and autocomplete
+- **Stable, typed, documented** with defaults and a11y ramifications
+- Support both **controlled** and **uncontrolled** patterns where applicable
+- Document all props: name, type, default, required, description
+- Document purpose, usage, accessibility notes, and customization options
+
+
+
+Use data attributes for styling hooks and state:
+
+- `data-slot` - Identify component parts for styling
+- `data-state` - Indicate component state (open, closed, checked, etc.)
+- `data-disabled`, `data-selected`, etc. - State indicators
+
+```tsx
+
+```
+
+
+
+Use **variants** for discrete style/behavior permutations (e.g., `size="sm|md|lg"`, `tone="neutral|destructive"`). Variants are not separate components.
+
diff --git a/.agents/skills/create-ui-components/references/form-patterns.md b/.agents/skills/create-ui-components/references/form-patterns.md
new file mode 100644
index 000000000..49c0f84d9
--- /dev/null
+++ b/.agents/skills/create-ui-components/references/form-patterns.md
@@ -0,0 +1,565 @@
+
+Complete form handling patterns using react-hook-form, Zod validation, and TanStack Query mutations. Covers structure, rendering, validation, drawer forms, keyboard handling, and advanced patterns.
+
+
+
+```typescript
+import { zodResolver } from '@hookform/resolvers/zod';
+import { useMutation } from '@tanstack/react-query';
+import { useForm } from 'react-hook-form';
+import { z } from 'zod';
+
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+ FormSubmit,
+ transformInputProps,
+ transformSwitchProps
+} from '@/components/ui/form';
+import { Input } from '@/components/ui/input';
+import { Textarea } from '@/components/ui/textarea';
+```
+
+
+
+**1. Define Zod Schema with localized messages:**
+
+```typescript
+const { t } = useLocalization();
+
+const formSchema = z.object({
+ name: z.string(t('nameRequired')).nonempty(t('nameRequired')).trim(),
+ email: z.string().email(t('enterValidEmail')).nonempty(t('emailRequired')).toLowerCase().trim(),
+ description: z.string().max(196, t('descriptionTooLong', { max: 196 })).trim().optional(),
+ isPrivate: z.boolean()
+});
+
+type FormData = z.infer;
+```
+
+**2. Define default values:**
+
+```typescript
+const defaultValues = {
+ name: '',
+ email: '',
+ description: '',
+ isPrivate: false
+} as const;
+```
+
+**3. Initialize form:**
+
+```typescript
+const form = useForm({
+ defaultValues,
+ resolver: zodResolver(formSchema),
+ disabled: !currentUser?.id // optional
+});
+```
+
+**4. Create mutation:**
+
+```typescript
+const { mutateAsync: submitForm, isPending } = useMutation({
+ mutationFn: async (values: FormData) => {
+ await someService.create(values);
+ },
+ onSuccess: () => {
+ form.reset(defaultValues);
+ setIsOpen(false);
+ },
+ onError: (error) => {
+ console.error('Failed to submit:', error);
+ RNAlert.alert(t('error'), error.message);
+ }
+});
+```
+
+**5. Handle submission:**
+
+```typescript
+const handleFormSubmit = form.handleSubmit((data) => submitForm(data));
+```
+
+
+
+
+
+```tsx
+
+```
+
+
+
+```tsx
+ (
+
+ {t('template')}
+
+
+ {options.map((option) => (
+
+ ))}
+
+
+
+
+ )}
+/>
+```
+
+
+
+```tsx
+
+ {t('private')}
+ (
+
+
+
+
+
+
+ )}
+ />
+
+```
+
+
+
+```tsx
+ (
+
+
+
+
+
+
+ )}
+/>
+```
+
+
+
+```tsx
+ (
+
+
+ void handleFormSubmit()}
+ returnKeyType="done"
+ autoCapitalize="none"
+ autoCorrect={false}
+ autoComplete="password"
+ prefix={LockIcon}
+ placeholder={t('password')}
+ secureTextEntry
+ />
+
+
+
+ )}
+/>
+```
+
+
+
+
+
+Use `mask` prop on inputs with sensitive/PII data to prevent PostHog capture (sets `accessibilityLabel="ph-no-capture"`):
+
+```tsx
+
+```
+
+**Must mask:** Email, usernames, any PII. `secureTextEntry` fields are auto-masked.
+
+
+
+```tsx
+ {
+ setIsOpen(open);
+ if (!open) form.reset(defaultValues);
+ }}
+ dismissible={!isPending}
+>
+
+
+
+
+```
+
+**Custom snap points for complex forms:**
+
+```tsx
+ !open && handleClose()}
+ snapPoints={['80%']}
+ enableDynamicSizing={false}
+>
+```
+
+**Scrollable drawer content:**
+
+```tsx
+import { DrawerScrollView } from '@/components/ui/drawer';
+
+
+ {/* Form fields */}
+;
+```
+
+
+
+**Full-page forms:**
+
+```tsx
+import { KeyboardAwareScrollView } from 'react-native-keyboard-controller';
+
+;
+```
+
+**Sequential field navigation:**
+
+```tsx
+
+```
+
+**Last field triggers submission:**
+
+```tsx
+ void handleFormSubmit()}
+ returnKeyType="done"
+/>
+```
+
+
+
+
+
+```typescript
+const formSchema = z
+ .object({
+ password: z.string().min(6, t('passwordMinLength')),
+ confirmPassword: z.string()
+ })
+ .refine((data) => data.password === data.confirmPassword, {
+ message: t('passwordsNoMatch'),
+ path: ['confirmPassword']
+ });
+```
+
+
+
+```typescript
+const formSchema = z
+ .object({
+ currentPassword: z.string().optional(),
+ newPassword: z.string().optional()
+ })
+ .superRefine((data, ctx) => {
+ if (data.newPassword && data.newPassword.length > 0) {
+ if (!data.currentPassword) {
+ ctx.addIssue({
+ code: 'custom',
+ message: 'currentPasswordRequired',
+ path: ['currentPassword']
+ });
+ }
+ if (data.newPassword.length < 6) {
+ ctx.addIssue({
+ code: 'custom',
+ message: 'passwordMinLength',
+ path: ['newPassword']
+ });
+ }
+ }
+ });
+```
+
+
+
+
+
+```typescript
+const { mutateAsync: createItem, isPending } = useMutation({
+ mutationFn: async (values: FormData) => {
+ return await db.insert(table).values(values).returning();
+ },
+ onMutate: async (values) => {
+ await queryClient.cancelQueries({ queryKey: ['items'] });
+ const previous = queryClient.getQueryData(['items']);
+ queryClient.setQueryData(['items'], (old) => [
+ ...(old || []),
+ { ...values, id: `temp-${Date.now()}` }
+ ]);
+ return { previous };
+ },
+ onSuccess: () => {
+ form.reset(defaultValues);
+ setIsOpen(false);
+ },
+ onError: (error, _values, context) => {
+ if (context?.previous) {
+ queryClient.setQueryData(['items'], context.previous);
+ }
+ RNAlert.alert(t('error'), error.message);
+ },
+ onSettled: () => {
+ queryClient.invalidateQueries({ queryKey: ['items'] });
+ }
+});
+```
+
+
+
+**Reset on drawer open:**
+
+```tsx
+useEffect(() => {
+ if (isOpen) form.reset(defaultValues);
+}, [isOpen, form]);
+```
+
+**Reset with pre-filled values:**
+
+```tsx
+const resetForm = () => {
+ form.reset(defaultValues);
+ if (savedValue) form.setValue('fieldName', savedValue);
+};
+```
+
+**Partial reset:**
+
+```tsx
+form.reset({ ...form.getValues(), password: '', confirmPassword: '' });
+```
+
+
+
+```tsx
+const isOnline = useNetworkStatus();
+
+const { mutateAsync: submit } = useMutation({
+ mutationFn: async (data) => {
+ if (!isOnline) throw new Error(t('internetConnectionRequired'));
+ // ... proceed
+ }
+});
+
+// Disable submit when offline
+;
+
+{!isOnline && (
+
+ {t('internetConnectionRequired')}
+
+)}
+```
+
+
+
+**Alert-based:**
+
+```typescript
+onError: (error) => {
+ RNAlert.alert(
+ t('error'),
+ error instanceof Error ? error.message : t('genericError'),
+ [{ text: t('ok') }, { text: t('retry'), onPress: () => handleFormSubmit() }]
+ );
+};
+```
+
+**Inline:** `FormMessage` automatically displays field-level errors from Zod validation.
+
+
+
+
+
+Reactive form value subscriptions without re-rendering entire form:
+
+```tsx
+import { useWatch } from 'react-hook-form';
+
+const subscription = useWatch({ control: form.control });
+const isValid =
+ (mode === 'text' && !!subscription.text) ||
+ (mode === 'audio' && !!subscription.audioUri);
+```
+
+
+
+Real-time validation as user types:
+
+```typescript
+const form = useForm({
+ defaultValues,
+ resolver: zodResolver(formSchema),
+ mode: 'onChange'
+});
+```
+
+
+
+For non-standard inputs (audio recorders, file pickers), use `field.onChange` directly:
+
+```tsx
+ (
+
+
+ field.onChange(null)}
+ />
+
+
+
+ )}
+/>
+```
+
+
+
+```tsx
+ setInputMode(v as InputMode)}>
+
+ Text
+ Audio
+
+
+
+
+
+
+
+
+```
+
+
+
+```tsx
+
+```
+
+
+
+```typescript
+import { Keyboard } from 'react-native';
+
+onSuccess: () => {
+ Keyboard.dismiss();
+ RNAlert.alert(t('success'), t('message'), [
+ { text: t('ok'), onPress: () => safeNavigate(() => navigateAway()) }
+ ]);
+};
+```
+
+
+
+
+
+| Pattern | Usage |
+|---|---|
+| `transformInputProps(field)` | Spread on `Input`, `Textarea` |
+| `transformSwitchProps(field)` | Spread on `Switch` |
+| `form.handleSubmit(fn)` | Wraps submission with validation |
+| `isPending` | Loading state from mutation |
+| `form.reset()` | Clear form or restore defaults |
+| `drawerInput` prop | Inputs inside drawers for keyboard handling |
+| `type="next"` | Chain keyboard focus between fields |
+| `mask` prop | Prevent analytics capture for PII |
+
diff --git a/.agents/skills/create-ui-components/workflows/build-form.md b/.agents/skills/create-ui-components/workflows/build-form.md
index d995ba358..7f048b768 100644
--- a/.agents/skills/create-ui-components/workflows/build-form.md
+++ b/.agents/skills/create-ui-components/workflows/build-form.md
@@ -1,9 +1,9 @@
# Workflow: Build a Form
-**Read these NOW:**
-1. The cursor rule `/.cursor/rules/form-handling.mdc` — for react-hook-form + Zod + TanStack Query patterns, field rendering, drawer forms, keyboard handling, validation, and advanced patterns
-2. references/project-conventions.md — for project-specific styling, icons, theming, and React 19/Compiler rules
+**Read these reference files NOW:**
+1. references/form-patterns.md
+2. references/project-conventions.md
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 000000000..93e1febb2
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,15 @@
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+insert_final_newline = true
+indent_style = space
+indent_size = 2
+trim_trailing_whitespace = true
+
+[*.md]
+trim_trailing_whitespace = false
+
+[*.{bat,cmd,ps1}]
+end_of_line = crlf
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 000000000..1ec875276
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,6 @@
+* text=auto eol=lf
+
+# Keep Windows-native script endings for better local tooling compatibility.
+*.bat text eol=crlf
+*.cmd text eol=crlf
+*.ps1 text eol=crlf
diff --git a/.gitignore b/.gitignore
index a4fc5863c..bc2e7f9d8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -43,7 +43,6 @@ langquest types.xlsx
*.vsix
llm_supp_files
-/docs
/.venv
# include all version db files (1.0.db, 2.0.db, etc.)
diff --git a/.vscode/settings.json b/.vscode/settings.json
index be018b0d9..0c3f9f28e 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -1,5 +1,6 @@
{
"editor.defaultFormatter": "esbenp.prettier-vscode",
+ "files.eol": "\n",
"tailwindCSS.classAttributes": [
"class",
"className",
diff --git a/app/_layout.tsx b/app/_layout.tsx
index 33672aab4..dcf6cde28 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -143,12 +143,12 @@ export default function RootLayout() {
return (
-
-
-
-
-
-
+
+
+
+
+
+
@@ -164,12 +164,12 @@ export default function RootLayout() {
-
-
-
-
-
-
+
+
+
+
+
+
);
}
diff --git a/components/AudioRecorder.tsx b/components/AudioRecorder.tsx
index 0ce621d3c..bfbd67861 100644
--- a/components/AudioRecorder.tsx
+++ b/components/AudioRecorder.tsx
@@ -22,7 +22,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Platform, Pressable, View } from 'react-native';
// Maximum file size in bytes (50MB)
-const MAX_FILE_SIZE = 50 * 1024 * 1024;
+const MAX_FILE_SIZE = 10 * 1024 * 1024;
interface ButtonConfig {
icon: LucideIcon;
diff --git a/components/WalkieTalkieRecorder.tsx b/components/WalkieTalkieRecorder.tsx
index ce0caa858..985d904dc 100644
--- a/components/WalkieTalkieRecorder.tsx
+++ b/components/WalkieTalkieRecorder.tsx
@@ -1,13 +1,9 @@
import { useAuth } from '@/contexts/AuthContext';
import { useHaptic } from '@/hooks/useHaptic';
import { useLocalization } from '@/hooks/useLocalization';
+import MicrophoneEnergyModule from '@/modules/microphone-energy';
+import { convertWavUriToM4a } from '@/utils/audioConversion';
import { cn } from '@/utils/styleUtils';
-import {
- RecordingPresets,
- setAudioModeAsync,
- useAudioRecorder,
- useAudioRecorderState
-} from 'expo-audio';
import { MicIcon, Square } from 'lucide-react-native';
import React, { useEffect, useRef, useState } from 'react';
import { Pressable, View, useWindowDimensions } from 'react-native';
@@ -97,55 +93,7 @@ const WalkieTalkieRecorder: React.FC = ({
// Energy range tracking for logging
const energyRangeRef = useRef({ min: Infinity, max: -Infinity });
-
- // Audio recorder from expo-audio (manages lifecycle automatically)
- const recorder = useAudioRecorder(RecordingPresets.HIGH_QUALITY);
-
- // Poll recording state for duration and metering data
- const recorderState = useAudioRecorderState(recorder, 50);
-
- // Previous duration ref to avoid duplicate processing
- const lastProcessedDurationRef = useRef(0);
-
- // React to recorder state changes for duration tracking and metering
- useEffect(() => {
- if (!recorderState.isRecording) return;
-
- const duration = recorderState.durationMillis || 0;
-
- // Skip if we already processed this duration (avoid duplicate work)
- if (duration === lastProcessedDurationRef.current) return;
- lastProcessedDurationRef.current = duration;
-
- setRecordingDuration(duration);
- // Notify parent of duration updates for progress bar
- onRecordingDurationUpdate?.(duration);
-
- let amplitude: number;
- if (typeof recorderState.metering === 'number') {
- const db = recorderState.metering;
- const normalizedDb = Math.max(-60, Math.min(0, db));
- amplitude = Math.pow(10, normalizedDb / 20);
- appendLiveSample(amplitude);
- } else {
- const t = duration / 1000;
- const base = 0.3 + Math.sin(t * 24) * 0.15;
- const noise = (Math.random() - 0.5) * 0.1;
- amplitude = Math.max(0.02, Math.min(0.8, base + noise));
- appendLiveSample(amplitude);
- }
-
- // Track energy range
- energyRangeRef.current.min = Math.min(
- energyRangeRef.current.min,
- amplitude
- );
- energyRangeRef.current.max = Math.max(
- energyRangeRef.current.max,
- amplitude
- );
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [recorderState]);
+ const recordingStartTimestampRef = useRef(null);
// ============================================================================
// BUSY LOCK - Prevents race conditions during state transitions
@@ -243,7 +191,37 @@ const WalkieTalkieRecorder: React.FC = ({
}
};
- // Cleanup timers on unmount (recorder cleanup handled by useAudioRecorder hook)
+ // Listen to native energy updates while manual recording is active.
+ useEffect(() => {
+ const energySubscription = MicrophoneEnergyModule.addListener(
+ 'onEnergyResult',
+ ({ energy }) => {
+ if (!hasActiveRecording) return;
+
+ const startedAt = recordingStartTimestampRef.current;
+ const duration = startedAt ? Math.max(0, Date.now() - startedAt) : 0;
+ setRecordingDuration(duration);
+ onRecordingDurationUpdate?.(duration);
+
+ const amplitude = Math.max(0.01, Math.min(1, energy));
+ appendLiveSample(amplitude);
+ energyRangeRef.current.min = Math.min(
+ energyRangeRef.current.min,
+ amplitude
+ );
+ energyRangeRef.current.max = Math.max(
+ energyRangeRef.current.max,
+ amplitude
+ );
+ }
+ );
+
+ return () => {
+ energySubscription.remove();
+ };
+ }, [hasActiveRecording, onRecordingDurationUpdate]);
+
+ // Cleanup timers and native recording on unmount
useEffect(() => {
return () => {
if (activationTimer.current) {
@@ -252,7 +230,9 @@ const WalkieTalkieRecorder: React.FC = ({
if (releaseDelayTimer.current) {
clearTimeout(releaseDelayTimer.current);
}
- // Recorder cleanup is handled automatically by useAudioRecorder hook
+ void MicrophoneEnergyModule.stopEnergyDetection().catch(() => {
+ // Ignore cleanup errors on unmount
+ });
};
}, []);
@@ -303,7 +283,6 @@ const WalkieTalkieRecorder: React.FC = ({
);
recordedSamplesRef.current = [];
- lastProcessedDurationRef.current = 0;
// Permission check removed - parent RecordingControls ensures canRecord=true
// before this component is even rendered/interactive
@@ -316,44 +295,31 @@ const WalkieTalkieRecorder: React.FC = ({
// where user releases before async setup completes
onRecordingStart();
- // Heavy operations - but user already sees feedback
- await setAudioModeAsync({
- allowsRecording: true,
- playsInSilentMode: true
- });
-
- // Prepare and start recording with metering-enabled options
- const highQuality = RecordingPresets.HIGH_QUALITY;
- const options = {
- ...highQuality,
- ios: {
- ...(highQuality?.ios ?? {}),
- isMeteringEnabled: true
- },
- android: {
- ...(highQuality?.android ?? {}),
- isMeteringEnabled: true
- }
- };
-
- await recorder.prepareToRecordAsync(options);
+ // Start native audio pipeline and begin manual segment (WAV first).
+ await MicrophoneEnergyModule.startEnergyDetection();
// Check if we were cancelled during async setup (user released early)
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (shouldCancelRecordingRef.current) {
+ await MicrophoneEnergyModule.stopEnergyDetection();
shouldCancelRecordingRef.current = false;
return;
}
- recorder.record();
+ await MicrophoneEnergyModule.startSegment({ prerollMs: 0 });
setHasActiveRecording(true);
setRecordingDuration(0);
+ recordingStartTimestampRef.current = Date.now();
+ onRecordingDurationUpdate?.(0);
// Reset energy range for this recording
energyRangeRef.current = { min: Infinity, max: -Infinity };
} catch (error) {
console.error('❌ Failed to start recording:', error);
+ void MicrophoneEnergyModule.stopEnergyDetection().catch(() => {
+ // Ignore cleanup errors after failed start.
+ });
onRecordingStop(); // Clean up
}
};
@@ -368,13 +334,16 @@ const WalkieTalkieRecorder: React.FC = ({
}
try {
- await recorder.stop();
- const uri = recorder.uri;
+ const uri = await MicrophoneEnergyModule.stopSegment();
+ await MicrophoneEnergyModule.stopEnergyDetection();
+ const startedAt = recordingStartTimestampRef.current;
+ const finalDuration = startedAt ? Math.max(0, Date.now() - startedAt) : 0;
if (uri) {
- if (recordingDuration >= MIN_RECORDING_DURATION) {
+ if (finalDuration >= MIN_RECORDING_DURATION) {
+ const finalUri = await convertWavUriToM4a(uri);
const waveformData = [...recordedSamplesRef.current];
- onRecordingComplete(uri, recordingDuration, waveformData);
+ onRecordingComplete(finalUri, finalDuration, waveformData);
} else {
onRecordingDiscarded?.();
}
@@ -382,13 +351,18 @@ const WalkieTalkieRecorder: React.FC = ({
setHasActiveRecording(false);
setRecordingDuration(0);
+ recordingStartTimestampRef.current = null;
recordedSamplesRef.current = [];
onRecordingStop();
} catch (error) {
console.error('Failed to stop recording:', error);
+ void MicrophoneEnergyModule.stopEnergyDetection().catch(() => {
+ // Ignore cleanup errors after failed stop.
+ });
setHasActiveRecording(false);
setRecordingDuration(0);
+ recordingStartTimestampRef.current = null;
recordedSamplesRef.current = [];
onRecordingStop();
}
diff --git a/components/WaveformVisualizer.tsx b/components/WaveformVisualizer.tsx
index d09f417da..ab282d712 100644
--- a/components/WaveformVisualizer.tsx
+++ b/components/WaveformVisualizer.tsx
@@ -10,6 +10,8 @@ interface WaveformVisualizerProps {
height?: number;
color?: string;
backgroundColor?: string;
+ borderColor?: string;
+ borderWidth?: number;
barCount?: number; // When provided, render at most this many bars (last N → scrolling)
}
@@ -20,6 +22,8 @@ const WaveformVisualizer: React.FC = ({
height = 60,
color = colors.primary,
backgroundColor = colors.inputBackground,
+ borderColor,
+ borderWidth,
barCount
}) => {
const displayedData = React.useMemo(() => {
@@ -31,8 +35,22 @@ const WaveformVisualizer: React.FC = ({
const barWidth = width / Math.max(1, displayedData.length);
const maxHeight = height - 4; // Leave some padding
+ const resolvedBorderColor = borderColor ?? colors.inputBorder;
+ const resolvedBorderWidth = borderWidth ?? styles.container.borderWidth ?? 0;
+
return (
-
+