diff --git a/frontend/dist/index.html b/frontend/dist/index.html
index bd667ff..f360e48 100644
--- a/frontend/dist/index.html
+++ b/frontend/dist/index.html
@@ -4,6 +4,7 @@
BabelBridge
+
@@ -12,7 +13,32 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/index.html b/frontend/index.html
index 2118d42..64677e8 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -4,6 +4,7 @@
BabelBridge
+
@@ -12,6 +13,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json
index 1711025..5025b83 100644
--- a/frontend/public/manifest.json
+++ b/frontend/public/manifest.json
@@ -1,21 +1,38 @@
{
- "name": "BabelBridge",
+ "name": "BabelBridge - Modern Translation Tool",
"short_name": "BabelBridge",
- "description": "A modern web-based translation tool with live language identification and pluggable AI backends",
+ "description": "A modern web-based translation tool with live language identification and pluggable AI backends. Translate text seamlessly with intelligent context preservation.",
"start_url": "/",
"display": "standalone",
- "background_color": "#0f1220",
+ "background_color": "#0f172a",
"theme_color": "#6366F1",
"orientation": "portrait-primary",
+ "scope": "/",
+ "lang": "en",
"icons": [
{
"src": "/favicon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any maskable"
+ },
+ {
+ "src": "/og-image.svg",
+ "sizes": "1200x630",
+ "type": "image/svg+xml",
+ "purpose": "any"
+ }
+ ],
+ "categories": ["productivity", "utilities", "education"],
+ "keywords": ["translation", "AI", "language", "translate", "babel", "bridge", "multilingual"],
+ "screenshots": [
+ {
+ "src": "/og-image.svg",
+ "sizes": "1200x630",
+ "type": "image/svg+xml",
+ "label": "BabelBridge main interface"
}
],
- "categories": ["productivity", "utilities"],
"shortcuts": [
{
"name": "New Translation",
@@ -30,6 +47,8 @@
}
]
}
- ]
+ ],
+ "related_applications": [],
+ "prefer_related_applications": false
}
diff --git a/frontend/public/og-image.svg b/frontend/public/og-image.svg
new file mode 100644
index 0000000..8d27947
--- /dev/null
+++ b/frontend/public/og-image.svg
@@ -0,0 +1,73 @@
+
+
+
diff --git a/frontend/src/components/AddToAppButton.tsx b/frontend/src/components/AddToAppButton.tsx
index cb03671..1dbd155 100644
--- a/frontend/src/components/AddToAppButton.tsx
+++ b/frontend/src/components/AddToAppButton.tsx
@@ -11,16 +11,18 @@ export function AddToAppButton() {
const [deferredPrompt, setDeferredPrompt] = useState(null)
const [showIOSInstructions, setShowIOSInstructions] = useState(false)
const [isInstallable, setIsInstallable] = useState(false)
+ const [showDebugInfo, setShowDebugInfo] = useState(false)
useEffect(() => {
const handleBeforeInstallPrompt = (e: Event) => {
- // Prevent the mini-infobar from appearing on mobile
+ console.log('PWA: beforeinstallprompt event fired')
e.preventDefault()
setDeferredPrompt(e as BeforeInstallPromptEvent)
setIsInstallable(true)
}
const handleAppInstalled = () => {
+ console.log('PWA: app installed')
setIsInstallable(false)
setDeferredPrompt(null)
}
@@ -28,9 +30,32 @@ export function AddToAppButton() {
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
window.addEventListener('appinstalled', handleAppInstalled)
+ // Debug logging
+ console.log('PWA: Component mounted, checking conditions...')
+ console.log('PWA: isIOS =', isIOS())
+ console.log('PWA: isInStandaloneMode =', isInStandaloneMode())
+ console.log('PWA: hasBeforeInstallPrompt =', 'onbeforeinstallprompt' in window)
+ console.log('PWA: hostname =', window.location.hostname)
+
+ // For localhost development: if no beforeinstallprompt after 2 seconds,
+ // assume it's available for testing (but only on localhost)
+ const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
+ const isDev = process.env.NODE_ENV === 'development'
+
+ let timeoutId: NodeJS.Timeout | null = null
+ if (isDev && isLocalhost) {
+ timeoutId = setTimeout(() => {
+ if (!isInstallable && !isIOS()) {
+ console.log('PWA: No beforeinstallprompt after 2s, enabling for localhost testing')
+ setIsInstallable(true)
+ }
+ }, 2000)
+ }
+
return () => {
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
window.removeEventListener('appinstalled', handleAppInstalled)
+ if (timeoutId) clearTimeout(timeoutId)
}
}, [])
@@ -39,8 +64,14 @@ export function AddToAppButton() {
}
const isInStandaloneMode = () => {
- return window.matchMedia('(display-mode: standalone)').matches ||
- (window.navigator as any).standalone === true
+ try {
+ const matchMediaResult = window.matchMedia('(display-mode: standalone)')
+ return (matchMediaResult && matchMediaResult.matches) ||
+ (window.navigator as any).standalone === true
+ } catch (e) {
+ // Fallback for test environments or browsers without matchMedia
+ return (window.navigator as any).standalone === true
+ }
}
const handleInstallClick = async () => {
@@ -59,11 +90,27 @@ export function AddToAppButton() {
}
}
- // Don't show the button if already installed or not installable
- if (isInStandaloneMode() || (!isInstallable && !isIOS())) {
+ // Don't show the button if already installed
+ if (isInStandaloneMode()) {
+ console.log('PWA: Button hidden - already in standalone mode')
return null
}
+ // Show button if:
+ // 1. Actually installable (beforeinstallprompt fired), OR
+ // 2. iOS device (can always install via Safari), OR
+ // 3. Development mode AND localhost (for testing)
+ const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
+ const isDev = process.env.NODE_ENV === 'development'
+ const shouldShowButton = isInstallable || isIOS() || (isDev && isLocalhost)
+
+ if (!shouldShowButton) {
+ console.log('PWA: Button hidden - not installable, not iOS, not dev+localhost')
+ return null
+ }
+
+ console.log('PWA: Button will show - installable:', isInstallable, 'iOS:', isIOS(), 'dev+localhost:', isDev && isLocalhost)
+
return (
<>
({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+}))
+
+// Set up global mocks before all tests
+Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ value: mockMatchMedia,
+})
+
+describe('AddToAppButton', () => {
+ let mockUserAgent: string
+
+ beforeEach(() => {
+ // Reset mocks
+ vi.clearAllMocks()
+ mockMatchMedia.mockClear()
+
+ // Store original userAgent
+ mockUserAgent = navigator.userAgent
+
+ // Mock console.log to avoid test noise
+ vi.spyOn(console, 'log').mockImplementation(() => {})
+
+ // Reset event listeners
+ window.removeEventListener = vi.fn()
+ window.addEventListener = vi.fn()
+
+ // Set NODE_ENV for testing
+ process.env.NODE_ENV = 'development'
+ })
+
+ afterEach(() => {
+ // Restore userAgent
+ Object.defineProperty(navigator, 'userAgent', {
+ value: mockUserAgent,
+ configurable: true
+ })
+
+ // Restore NODE_ENV
+ process.env.NODE_ENV = originalEnv
+
+ vi.restoreAllMocks()
+ })
+
+ const mockUserAgentAs = (userAgent: string) => {
+ Object.defineProperty(navigator, 'userAgent', {
+ value: userAgent,
+ configurable: true
+ })
+ }
+
+ const mockStandaloneMode = (isStandalone: boolean) => {
+ mockMatchMedia.mockImplementation(query => {
+ if (query === '(display-mode: standalone)') {
+ return {
+ matches: isStandalone,
+ media: query,
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ }
+ }
+ return {
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ }
+ })
+ }
+
+ describe('Component Rendering', () => {
+ it('renders without crashing', () => {
+ expect(() => render()).not.toThrow()
+ })
+
+ it('shows button in development mode', () => {
+ // Ensure development mode is set
+ process.env.NODE_ENV = 'development'
+
+ render()
+
+ expect(screen.getByRole('button')).toBeInTheDocument()
+ expect(screen.getByText(/Add to Desktop/)).toBeInTheDocument()
+ })
+
+ it('does not show button when in standalone mode', () => {
+ mockStandaloneMode(true)
+ render()
+
+ expect(screen.queryByRole('button')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('iOS Detection', () => {
+ it('shows iOS-specific button text and icon for iPhone', () => {
+ mockUserAgentAs('Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X)')
+ process.env.NODE_ENV = 'development'
+
+ render()
+
+ expect(screen.getByText(/Add to Home Screen/)).toBeInTheDocument()
+ expect(screen.getByTestId('PhoneIphoneIcon')).toBeInTheDocument()
+ })
+
+ it('shows iOS-specific button text for iPad', () => {
+ mockUserAgentAs('Mozilla/5.0 (iPad; CPU OS 15_0 like Mac OS X)')
+ process.env.NODE_ENV = 'development'
+
+ render()
+
+ expect(screen.getByText(/Add to Home Screen/)).toBeInTheDocument()
+ })
+
+ it('shows desktop button text for non-iOS devices', () => {
+ mockUserAgentAs('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36')
+ process.env.NODE_ENV = 'development'
+
+ render()
+
+ expect(screen.getByText(/Add to Desktop/)).toBeInTheDocument()
+ expect(screen.getByTestId('LaptopMacIcon')).toBeInTheDocument()
+ })
+ })
+
+ describe('Install Prompt Handling', () => {
+ let mockBeforeInstallPromptEvent: any
+
+ beforeEach(() => {
+ mockBeforeInstallPromptEvent = {
+ preventDefault: vi.fn(),
+ prompt: vi.fn().mockResolvedValue(undefined),
+ userChoice: Promise.resolve({ outcome: 'accepted' })
+ }
+ })
+
+ it('sets installable state when beforeinstallprompt fires', async () => {
+ const { rerender } = render()
+
+ // Simulate the beforeinstallprompt event
+ const eventHandler = vi.mocked(window.addEventListener).mock.calls
+ .find(call => call[0] === 'beforeinstallprompt')?.[1] as EventListener
+
+ if (eventHandler) {
+ eventHandler(mockBeforeInstallPromptEvent)
+ }
+
+ rerender()
+
+ expect(screen.getByRole('button')).toBeInTheDocument()
+ })
+
+ it('calls preventDefault on beforeinstallprompt event', () => {
+ render()
+
+ const eventHandler = vi.mocked(window.addEventListener).mock.calls
+ .find(call => call[0] === 'beforeinstallprompt')?.[1] as EventListener
+
+ if (eventHandler) {
+ eventHandler(mockBeforeInstallPromptEvent)
+ }
+
+ expect(mockBeforeInstallPromptEvent.preventDefault).toHaveBeenCalled()
+ })
+ })
+
+ describe('iOS Instructions', () => {
+ it('shows iOS installation instructions when clicked on iOS', async () => {
+ mockUserAgentAs('Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X)')
+ process.env.NODE_ENV = 'development'
+
+ render()
+
+ const button = screen.getByRole('button')
+ fireEvent.click(button)
+
+ await waitFor(() => {
+ expect(screen.getByText(/To install: tap the Share button/)).toBeInTheDocument()
+ })
+ })
+
+ it('closes iOS instructions when close button is clicked', async () => {
+ mockUserAgentAs('Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X)')
+ process.env.NODE_ENV = 'development'
+
+ render()
+
+ const button = screen.getByRole('button')
+ fireEvent.click(button)
+
+ await waitFor(() => {
+ expect(screen.getByText(/To install: tap the Share button/)).toBeInTheDocument()
+ })
+
+ // The close button doesn't have accessible text, so we'll find it by test-id
+ const closeButton = screen.getByTestId('CloseIcon').closest('button')
+ fireEvent.click(closeButton!)
+
+ await waitFor(() => {
+ expect(screen.queryByText(/To install: tap the Share button/)).not.toBeInTheDocument()
+ })
+ })
+ })
+
+ describe('Desktop Install Prompt', () => {
+ it('triggers install prompt when button is clicked on desktop', async () => {
+ mockUserAgentAs('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36')
+ process.env.NODE_ENV = 'development'
+
+ const mockPrompt = vi.fn().mockResolvedValue(undefined)
+ const mockUserChoice = Promise.resolve({ outcome: 'accepted' })
+
+ const mockEvent = {
+ preventDefault: vi.fn(),
+ prompt: mockPrompt,
+ userChoice: mockUserChoice
+ }
+
+ const { rerender } = render()
+
+ // Simulate beforeinstallprompt event after component is mounted
+ const eventHandler = vi.mocked(window.addEventListener).mock.calls
+ .find(call => call[0] === 'beforeinstallprompt')?.[1] as EventListener
+
+ if (eventHandler) {
+ eventHandler(mockEvent as any)
+ }
+
+ // Rerender to reflect state change
+ rerender()
+
+ const button = screen.getByRole('button')
+ fireEvent.click(button)
+
+ await waitFor(() => {
+ expect(mockPrompt).toHaveBeenCalled()
+ })
+ })
+ })
+
+ describe('Event Listeners', () => {
+ it('adds event listeners on mount', () => {
+ render()
+
+ expect(window.addEventListener).toHaveBeenCalledWith('beforeinstallprompt', expect.any(Function))
+ expect(window.addEventListener).toHaveBeenCalledWith('appinstalled', expect.any(Function))
+ })
+
+ it('removes event listeners on unmount', () => {
+ const { unmount } = render()
+
+ unmount()
+
+ expect(window.removeEventListener).toHaveBeenCalledWith('beforeinstallprompt', expect.any(Function))
+ expect(window.removeEventListener).toHaveBeenCalledWith('appinstalled', expect.any(Function))
+ })
+ })
+
+ describe('Button Styling', () => {
+ it('applies correct styling to the button', () => {
+ process.env.NODE_ENV = 'development'
+ render()
+
+ const button = screen.getByRole('button')
+ expect(button).toHaveClass('MuiButton-outlined')
+ expect(button).toHaveStyle({ textTransform: 'none' })
+ })
+
+ it('centers the button in its container', () => {
+ process.env.NODE_ENV = 'development'
+ render()
+
+ const button = screen.getByRole('button')
+ const container = button.closest('div')
+
+ expect(container).toHaveStyle({
+ display: 'flex',
+ justifyContent: 'center'
+ })
+ })
+ })
+
+ describe('Accessibility', () => {
+ it('has accessible button text', () => {
+ process.env.NODE_ENV = 'development'
+ render()
+
+ const button = screen.getByRole('button')
+ expect(button).toHaveAccessibleName(/Add to Desktop/)
+ })
+
+ it('has accessible close button in iOS instructions', async () => {
+ mockUserAgentAs('Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X)')
+ process.env.NODE_ENV = 'development'
+
+ render()
+
+ const button = screen.getByRole('button')
+ fireEvent.click(button)
+
+ await waitFor(() => {
+ const closeIcon = screen.getByTestId('CloseIcon')
+ const closeButton = closeIcon.closest('button')
+ expect(closeButton).toBeInTheDocument()
+ expect(closeButton).toHaveAttribute('type', 'button')
+ })
+ })
+ })
+})