Files
voicebox/mobile/src/components/ui/Button.tsx
T
James Pine f4d21504e3 Mobile companion app + paired-device backend
New iOS-first companion (Expo SDK 54 + NativeWind v4) with three tabs:
Captures (the hero — floating gold mic, live mic-meter waveform,
expand-row playback), Generate (profile picker + speak + autoplay +
recent), and Voices (searchable profile list).

Pairing (V0): backend mints a one-time token, mobile scans/pastes the
voicebox:// URL, server returns a long-lived bearer it stores only as a
SHA-256 hash. Bearer-or-loopback auth applied to every user-data router
so binding 0.0.0.0 doesn't leak existing endpoints. Loopback callers
(the desktop app) keep their friction-free access.

Desktop Settings → Mobile pane: live host picker (LAN / Tailscale auto-
detected via the App-bundle binary path on macOS), QR rendering,
5-minute expiry countdown, copyable URL fallback, paired-device list
with revoke. Auto-closes when a new device pairs.

just dev now binds the backend to 0.0.0.0 so paired phones can reach
it — and just setup-python pins mlx-audio==0.4.1 + mlx-lm so fresh
Apple Silicon worktrees get a working STT path on first install.
2026-04-25 17:09:32 -07:00

109 lines
2.5 KiB
TypeScript

import { forwardRef } from 'react';
import {
ActivityIndicator,
Pressable,
type PressableProps,
Text,
View,
} from 'react-native';
import { colors } from '@/lib/colors';
type Variant = 'primary' | 'secondary' | 'ghost';
type Size = 'sm' | 'md' | 'lg';
type Props = Omit<PressableProps, 'children'> & {
label: string;
variant?: Variant;
size?: Size;
loading?: boolean;
leftSlot?: React.ReactNode;
rightSlot?: React.ReactNode;
};
const containerBase =
'flex-row items-center justify-center rounded-full active:opacity-90';
const variantContainer: Record<Variant, string> = {
primary: 'bg-accent',
secondary: 'bg-card border border-border',
ghost: 'bg-transparent',
};
const variantLabel: Record<Variant, string> = {
primary: 'text-accent-foreground',
secondary: 'text-foreground',
ghost: 'text-muted-foreground',
};
const sizeContainer: Record<Size, string> = {
sm: 'px-4 py-2 gap-1.5',
md: 'px-6 py-3 gap-2',
lg: 'px-8 py-4 gap-2.5',
};
const sizeLabel: Record<Size, string> = {
sm: 'text-xs',
md: 'text-sm',
lg: 'text-base',
};
// Mirrors the desktop CTA in landing/src/components/SponsorPromo.tsx —
// rounded-full, gold accent, uppercase semibold label with wide tracking.
export const Button = forwardRef<View, Props>(function Button(
{
label,
variant = 'primary',
size = 'md',
loading = false,
leftSlot,
rightSlot,
disabled,
style,
...rest
},
ref,
) {
const isPrimary = variant === 'primary';
const isDisabled = disabled || loading;
return (
<Pressable
ref={ref}
accessibilityRole="button"
accessibilityState={{ disabled: isDisabled, busy: loading }}
disabled={isDisabled}
style={[
isPrimary && {
shadowColor: colors.accent,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.35,
shadowRadius: 16,
elevation: 6,
},
isDisabled && { opacity: 0.5 },
style as object,
]}
className={`${containerBase} ${variantContainer[variant]} ${sizeContainer[size]}`}
{...rest}
>
{loading ? (
<ActivityIndicator
size="small"
color={isPrimary ? colors.accentForeground : colors.foreground}
/>
) : (
<>
{leftSlot}
<Text
className={`font-semibold uppercase ${variantLabel[variant]} ${sizeLabel[size]}`}
style={{ letterSpacing: 1.2 }}
>
{label}
</Text>
{rightSlot}
</>
)}
</Pressable>
);
});