# yt2ai Frontend Architecture Document ## Change Log | Date | Version | Description | Author | |------|---------|-------------|--------| | 2025-09-06 | v1.0 | Initial frontend architecture creation for bookmarklet | Winston (Architect) | ## Template and Framework Selection ### Architecture Decision Based on the PRD analysis, this is a **YouTube Subtitle Extraction & AI Summarization Bookmarklet** project with specific constraints: - **Platform:** Android Chrome browser exclusively - **Technology:** Vanilla JavaScript ES6+ (no frameworks or dependencies) - **Architecture:** Client-side only bookmarklet with external API integrations - **No Traditional UI Framework Needed:** This is a bookmarklet (single JavaScript file) ### Key Constraint Analysis The PRD specifically states "Vanilla JavaScript ES6+ pentru maximum mobile browser compatibility without build dependencies" and "No frameworks or libraries to maintain bookmarklet simplicity and avoid CSP restrictions." This is NOT a traditional frontend application requiring React/Vue/Angular. It's a bookmarklet - essentially a JavaScript snippet executed in the browser with minimal DOM manipulation overlays on existing YouTube pages. ### Architectural Approach Since this is a bookmarklet rather than a traditional frontend application, the architecture focuses on: 1. Bookmarklet structure and organization 2. Mobile-optimized overlay/modal patterns for YouTube integration 3. JavaScript module organization within constraint of single-file distribution ## Frontend Tech Stack ### Technology Stack Table | Category | Technology | Version | Purpose | Rationale | |----------|------------|---------|---------|-----------| | Framework | Vanilla JavaScript | ES6+ | Core bookmarklet logic and DOM manipulation | Maximum browser compatibility, zero build dependencies, CSP compliance | | UI Library | Native DOM APIs | Browser Standard | Overlay creation and mobile touch interactions | No external dependencies, optimized for bookmarklet constraints | | State Management | Module Pattern | ES6+ | Encapsulated state within bookmarklet scope | Simple, lightweight, no persistence needed for stateless operation | | Routing | N/A | - | No routing needed | Bookmarklet operates on current page context | | Build Tool | GitHub Actions | Latest | Minification and production bundling | Automated minification, zero-cost CI/CD | | Styling | Inline CSS + CSS-in-JS | Browser Standard | Mobile-optimized overlays and responsive feedback UI | Avoid external stylesheets, ensure mobile touch targets | | Testing | Jest + jsdom | ^29.0.0 | Unit testing for core functions | Standard JS testing with DOM simulation | | Component Library | Custom Overlay Components | ES6+ | Reusable modal, loading, and error UI patterns | Lightweight, mobile-first, YouTube-integrated design | | Form Handling | Native FormData + Fetch | Browser Standard | API integration with downloadyoutubesubtitles.com | No form libraries needed for simple API calls | | Animation | CSS Transitions + Web Animations API | Browser Standard | Loading states and mobile-friendly transitions | Smooth mobile performance without heavy libraries | | Dev Tools | ESLint + Prettier | Latest | Code quality and consistency | Standard development tooling | ## Project Structure ``` yt2ai-bookmarklet/ ├── src/ # Development source (modular) │ ├── core/ │ │ ├── videoExtractor.js # YouTube video ID extraction │ │ ├── subtitleFetcher.js # API integration with downloadyoutubesubtitles.com │ │ └── claudeIntegration.js # Claude.ai tab management & clipboard │ ├── ui/ │ │ ├── overlayManager.js # Mobile overlay creation and management │ │ ├── loadingStates.js # Progress indicators and mobile feedback │ │ ├── errorHandlers.js # Error display and recovery UX │ │ └── styles/ │ │ ├── overlay.css # Mobile-optimized overlay styles │ │ └── animations.css # Touch-friendly transitions │ ├── utils/ │ │ ├── domUtils.js # DOM manipulation utilities │ │ ├── mobileUtils.js # Mobile-specific helpers (touch, viewport) │ │ └── errorUtils.js # Error formatting and logging │ └── bookmarklet.js # Main development entry point ├── dist/ # Production builds │ ├── bookmarklet.min.js # Minified single-file bookmarklet │ └── bookmarklet-debug.js # Development version with logging ├── tests/ │ ├── unit/ │ │ ├── core/ # Core function tests │ │ ├── ui/ # UI component tests │ │ └── utils/ # Utility function tests │ ├── integration/ │ │ ├── youtube-urls.test.js # Real YouTube URL parsing tests │ │ └── api-integration.test.js # External API integration tests │ └── manual/ │ ├── mobile-test-cases.md # Manual mobile testing scenarios │ └── captcha-scenarios.md # CAPTCHA handling test cases ├── docs/ │ ├── installation.md # User installation instructions │ ├── development.md # Developer setup guide │ └── mobile-considerations.md # Mobile-specific implementation notes ├── build/ │ ├── webpack.config.js # Build configuration for minification │ ├── mobile-optimize.js # Mobile-specific build optimizations │ └── github-actions.yml # CI/CD pipeline configuration └── README.md # Project overview and quick start ``` ## Component Standards ### Component Template ```typescript /** * Mobile Overlay Component Template * Optimized for Android Chrome bookmarklet environment */ interface OverlayComponent { element: HTMLElement; isVisible: boolean; destroy(): void; } class MobileOverlayComponent implements OverlayComponent { public element: HTMLElement; public isVisible: boolean = false; private readonly containerId: string; private readonly mobileBreakpoint: number = 768; constructor( private readonly config: { id: string; className?: string; content: string | HTMLElement; type: 'loading' | 'error' | 'success' | 'info'; autoClose?: number; position?: 'top' | 'center' | 'bottom'; } ) { this.containerId = `yt2ai-${config.id}`; this.createElement(); this.attachStyles(); this.bindMobileEvents(); } private createElement(): void { this.element = document.createElement('div'); this.element.id = this.containerId; this.element.className = `yt2ai-overlay yt2ai-${this.config.type} ${this.config.className || ''}`; // Mobile-first responsive design this.element.innerHTML = ` `; if (typeof this.config.content !== 'string') { const bodyEl = this.element.querySelector('.yt2ai-overlay-body'); bodyEl?.appendChild(this.config.content); } } private attachStyles(): void { const styleId = 'yt2ai-overlay-styles'; if (document.getElementById(styleId)) return; const style = document.createElement('style'); style.id = styleId; style.textContent = ` .yt2ai-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; z-index: 999999; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; } .yt2ai-overlay-backdrop { background: rgba(0, 0, 0, 0.75); width: 100%; height: 100%; display: flex; align-items: ${this.config.position === 'top' ? 'flex-start' : this.config.position === 'bottom' ? 'flex-end' : 'center'}; justify-content: center; padding: 20px; box-sizing: border-box; } .yt2ai-overlay-content { background: white; border-radius: 12px; max-width: 90vw; max-height: 80vh; position: relative; overflow: hidden; box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3); } .yt2ai-overlay-body { padding: 24px; font-size: 16px; line-height: 1.5; color: #333; } .yt2ai-close-btn { position: absolute; top: 12px; right: 12px; background: none; border: none; font-size: 24px; cursor: pointer; padding: 8px; min-width: 44px; min-height: 44px; border-radius: 50%; display: flex; align-items: center; justify-content: center; } @media (max-width: 768px) { .yt2ai-overlay-content { max-width: 95vw; margin-top: ${this.config.position === 'top' ? '20px' : '0'}; } .yt2ai-overlay-body { padding: 20px; font-size: 14px; } } `; document.head.appendChild(style); } private bindMobileEvents(): void { // Touch-optimized close handling const closeBtn = this.element.querySelector('.yt2ai-close-btn'); const backdrop = this.element.querySelector('.yt2ai-overlay-backdrop'); closeBtn?.addEventListener('click', () => this.hide()); backdrop?.addEventListener('click', (e) => { if (e.target === backdrop) this.hide(); }); // Auto-close timer if (this.config.autoClose) { setTimeout(() => this.hide(), this.config.autoClose); } } public show(): void { if (this.isVisible) return; document.body.appendChild(this.element); this.isVisible = true; // Prevent body scroll on mobile document.body.style.overflow = 'hidden'; } public hide(): void { if (!this.isVisible) return; this.element.remove(); this.isVisible = false; // Restore body scroll document.body.style.overflow = ''; } public destroy(): void { this.hide(); // Cleanup styles if no other overlays exist if (!document.querySelector('.yt2ai-overlay')) { document.getElementById('yt2ai-overlay-styles')?.remove(); } } } // Export for modular development export { MobileOverlayComponent, type OverlayComponent }; ``` ### Naming Conventions **File Naming:** - **Modules:** `camelCase.js` (e.g., `videoExtractor.js`, `mobileUtils.js`) - **Classes:** `PascalCase` classes in `camelCase` files (e.g., `MobileOverlayComponent` in `overlayManager.js`) - **Constants:** `SCREAMING_SNAKE_CASE` (e.g., `API_ENDPOINTS`, `MOBILE_BREAKPOINTS`) **CSS Naming:** - **Prefix:** All CSS classes prefixed with `yt2ai-` to avoid YouTube conflicts - **BEM-inspired:** `yt2ai-block__element--modifier` pattern - **Mobile States:** `yt2ai-mobile-*` for mobile-specific classes **JavaScript Naming:** - **Functions:** `camelCase` with descriptive verbs (e.g., `extractVideoId`, `showMobileError`) - **Private Methods:** Leading underscore `_privateMethods` convention - **Event Handlers:** `handle` prefix (e.g., `handleTouchEnd`, `handleAPIError`) **Mobile-Specific Conventions:** - **Touch Targets:** Minimum 44px touch targets for mobile accessibility - **Viewport Units:** Use `vw`/`vh` sparingly, prefer `rem` for consistent scaling - **Z-index Ranges:** 999999+ for bookmarklet overlays to ensure YouTube override ## State Management ### Store Structure ``` src/state/ ├── BookmarkletState.js # Main state container ├── WorkflowState.js # Processing workflow status ├── ErrorState.js # Error handling and recovery └── types/ ├── WorkflowTypes.js # Workflow step definitions └── ErrorTypes.js # Error classification types ``` ### State Management Template ```typescript /** * Bookmarklet State Management * Lightweight module pattern for stateless bookmarklet operation */ interface WorkflowStep { id: string; name: string; status: 'pending' | 'active' | 'completed' | 'failed'; startTime?: number; endTime?: number; error?: BookmarkletError; } interface BookmarkletError { type: 'network' | 'api' | 'parsing' | 'captcha' | 'unknown'; message: string; recoverable: boolean; retryable: boolean; userAction?: string; } interface BookmarkletState { // Workflow tracking currentStep: string | null; steps: WorkflowStep[]; // Data state videoId: string | null; subtitleText: string | null; // UI state isProcessing: boolean; showOverlay: boolean; // Error state currentError: BookmarkletError | null; retryCount: number; } class BookmarkletStateManager { private state: BookmarkletState; private listeners: Set<(state: BookmarkletState) => void> = new Set(); // Workflow steps definition private readonly WORKFLOW_STEPS: Omit[] = [ { id: 'video-detection', name: 'Detecting YouTube Video' }, { id: 'subtitle-extraction', name: 'Extracting Subtitles' }, { id: 'claude-preparation', name: 'Preparing Claude.ai' }, { id: 'completion', name: 'Ready for Analysis' } ]; constructor() { this.state = this.createInitialState(); } private createInitialState(): BookmarkletState { return { currentStep: null, steps: this.WORKFLOW_STEPS.map(step => ({ ...step, status: 'pending' })), videoId: null, subtitleText: null, isProcessing: false, showOverlay: false, currentError: null, retryCount: 0 }; } // State getters public getState(): Readonly { return { ...this.state }; } public getCurrentStep(): WorkflowStep | null { return this.state.steps.find(step => step.id === this.state.currentStep) || null; } public getProgress(): { completed: number; total: number; percentage: number } { const completed = this.state.steps.filter(step => step.status === 'completed').length; const total = this.state.steps.length; return { completed, total, percentage: Math.round((completed / total) * 100) }; } // State mutations public startWorkflow(): void { this.updateState({ isProcessing: true, showOverlay: true, currentError: null, retryCount: 0 }); this.advanceToStep('video-detection'); } public advanceToStep(stepId: string): void { const stepIndex = this.state.steps.findIndex(step => step.id === stepId); if (stepIndex === -1) return; // Complete previous step if (this.state.currentStep) { this.completeStep(this.state.currentStep); } // Start new step const updatedSteps = [...this.state.steps]; updatedSteps[stepIndex] = { ...updatedSteps[stepIndex], status: 'active', startTime: Date.now() }; this.updateState({ currentStep: stepId, steps: updatedSteps }); } public completeStep(stepId: string): void { const updatedSteps = this.state.steps.map(step => step.id === stepId ? { ...step, status: 'completed' as const, endTime: Date.now() } : step ); this.updateState({ steps: updatedSteps }); } public failStep(stepId: string, error: BookmarkletError): void { const updatedSteps = this.state.steps.map(step => step.id === stepId ? { ...step, status: 'failed' as const, endTime: Date.now(), error } : step ); this.updateState({ steps: updatedSteps, currentError: error, isProcessing: false }); } public setVideoData(videoId: string): void { this.updateState({ videoId }); } public setSubtitleData(subtitleText: string): void { this.updateState({ subtitleText }); } public completeWorkflow(): void { this.completeStep(this.state.currentStep!); this.updateState({ isProcessing: false, currentStep: null }); } public retry(): void { if (!this.state.currentError?.retryable) return; this.updateState({ currentError: null, retryCount: this.state.retryCount + 1, isProcessing: true }); // Restart from failed step const failedStep = this.state.steps.find(step => step.status === 'failed'); if (failedStep) { this.advanceToStep(failedStep.id); } } public reset(): void { this.state = this.createInitialState(); this.notifyListeners(); } // State subscription public subscribe(listener: (state: BookmarkletState) => void): () => void { this.listeners.add(listener); return () => this.listeners.delete(listener); } private updateState(updates: Partial): void { this.state = { ...this.state, ...updates }; this.notifyListeners(); } private notifyListeners(): void { this.listeners.forEach(listener => listener(this.getState())); } // Utility methods public getDuration(stepId: string): number | null { const step = this.state.steps.find(s => s.id === stepId); if (!step?.startTime) return null; const endTime = step.endTime || Date.now(); return endTime - step.startTime; } public canRetry(): boolean { return !!(this.state.currentError?.retryable && this.state.retryCount < 3); } // Mobile-specific state helpers public shouldShowMobileOptimizedError(): boolean { return !!(this.state.currentError && window.innerWidth <= 768); } public getEstimatedTimeRemaining(): number { // Simple estimation based on current step and average durations const averageDurations = { 'video-detection': 2000, 'subtitle-extraction': 15000, 'claude-preparation': 3000, 'completion': 1000 }; let remaining = 0; let foundCurrent = false; for (const step of this.state.steps) { if (step.id === this.state.currentStep) { foundCurrent = true; const elapsed = step.startTime ? Date.now() - step.startTime : 0; remaining += Math.max(0, averageDurations[step.id as keyof typeof averageDurations] - elapsed); } else if (foundCurrent && step.status === 'pending') { remaining += averageDurations[step.id as keyof typeof averageDurations] || 5000; } } return remaining; } } // Singleton instance for bookmarklet use const stateManager = new BookmarkletStateManager(); // Export for modular development export { stateManager, type BookmarkletState, type WorkflowStep, type BookmarkletError }; ``` ## API Integration ### Service Template ```typescript /** * API Service Template for Bookmarklet * Mobile-optimized with comprehensive error handling */ interface APIResponse { success: boolean; data?: T; error?: BookmarkletError; } interface SubtitleData { videoId: string; subtitles: string; language: string; duration: number; } interface APIRequestConfig { timeout: number; retries: number; mobile: boolean; } class BookmarkletAPIService { private readonly BASE_ENDPOINTS = { subtitles: 'https://downloadyoutubesubtitles.com/api/subtitles', health: 'https://downloadyoutubesubtitles.com/api/health' }; private readonly DEFAULT_CONFIG: APIRequestConfig = { timeout: 30000, // 30s for mobile networks retries: 2, mobile: true }; private readonly MOBILE_HEADERS = { 'User-Agent': 'Mozilla/5.0 (Linux; Android 10; Mobile) AppleWebKit/537.36 Chrome/91.0', 'Accept': 'application/json, text/plain, */*', 'Accept-Language': 'en-US,en;q=0.9', 'Cache-Control': 'no-cache' }; /** * Extract subtitles for YouTube video */ public async extractSubtitles(videoId: string): Promise> { try { stateManager.advanceToStep('subtitle-extraction'); const response = await this.makeRequest( `${this.BASE_ENDPOINTS.subtitles}/${videoId}`, { method: 'GET', params: { lang: 'en', auto: 'true', // Auto-generated subtitles only format: 'txt' } } ); if (!response.success || !response.data) { return { success: false, error: { type: 'api', message: 'Failed to extract subtitles', recoverable: true, retryable: true, userAction: 'Check if video has English auto-generated subtitles' } }; } // Validate subtitle data quality const subtitleData = response.data; if (!subtitleData.subtitles || subtitleData.subtitles.length < 50) { return { success: false, error: { type: 'parsing', message: 'Subtitle content too short or empty', recoverable: false, retryable: false, userAction: 'Try a different video with longer content' } }; } stateManager.setSubtitleData(subtitleData.subtitles); stateManager.completeStep('subtitle-extraction'); return { success: true, data: subtitleData }; } catch (error) { return this.handleAPIError(error as Error, 'extractSubtitles'); } } /** * Check API health and availability */ public async checkAPIHealth(): Promise> { const startTime = Date.now(); try { const response = await this.makeRequest<{ status: string }>( this.BASE_ENDPOINTS.health, { method: 'GET' }, { ...this.DEFAULT_CONFIG, timeout: 5000 } // Quick health check ); return { success: true, data: { status: response.data?.status || 'unknown', responseTime: Date.now() - startTime } }; } catch (error) { return { success: false, error: { type: 'network', message: 'API service unavailable', recoverable: true, retryable: true, userAction: 'Try again in a few minutes' } }; } } /** * Generic HTTP request handler with mobile optimizations */ private async makeRequest( url: string, options: { method: 'GET' | 'POST'; body?: string; params?: Record; }, config: APIRequestConfig = this.DEFAULT_CONFIG ): Promise> { // Build URL with parameters const urlWithParams = this.buildURL(url, options.params); // Create AbortController for timeout handling const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), config.timeout); try { const response = await fetch(urlWithParams, { method: options.method, headers: { ...this.MOBILE_HEADERS, ...(options.body ? { 'Content-Type': 'application/json' } : {}) }, body: options.body, signal: controller.signal, mode: 'cors', // Handle CORS for bookmarklet credentials: 'omit' // No cookies needed }); clearTimeout(timeoutId); // Handle specific HTTP status codes if (response.status === 429) { throw new Error('Rate limit exceeded - too many requests'); } if (response.status === 503) { throw new Error('Service temporarily unavailable'); } if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } // Handle potential CAPTCHA response const contentType = response.headers.get('content-type'); if (contentType?.includes('text/html')) { // Likely CAPTCHA challenge throw new Error('CAPTCHA verification required'); } const data = await response.json(); return { success: true, data }; } catch (error) { clearTimeout(timeoutId); throw error; } } private buildURL(baseUrl: string, params?: Record): string { if (!params) return baseUrl; const url = new URL(baseUrl); Object.entries(params).forEach(([key, value]) => { url.searchParams.set(key, value); }); return url.toString(); } private handleAPIError(error: Error, context: string): APIResponse { console.error(`[BookmarkletAPI] Error in ${context}:`, error); // Network/timeout errors if (error.name === 'AbortError') { return { success: false, error: { type: 'network', message: 'Request timeout - slow network connection', recoverable: true, retryable: true, userAction: 'Check your internet connection and try again' } }; } // CAPTCHA detection if (error.message.includes('CAPTCHA')) { return { success: false, error: { type: 'captcha', message: 'CAPTCHA verification required', recoverable: true, retryable: true, userAction: 'Complete CAPTCHA verification and try again' } }; } // Rate limiting if (error.message.includes('Rate limit') || error.message.includes('429')) { return { success: false, error: { type: 'api', message: 'Too many requests - please wait', recoverable: true, retryable: true, userAction: 'Wait a few minutes before trying again' } }; } // Generic network error return { success: false, error: { type: 'network', message: `Network error: ${error.message}`, recoverable: true, retryable: true, userAction: 'Check your connection and try again' } }; } /** * Mobile-specific retry logic with exponential backoff */ public async withRetry( operation: () => Promise>, maxRetries: number = 2 ): Promise> { let lastError: BookmarkletError | null = null; for (let attempt = 0; attempt <= maxRetries; attempt++) { if (attempt > 0) { // Exponential backoff: 1s, 2s, 4s const delay = Math.pow(2, attempt - 1) * 1000; await new Promise(resolve => setTimeout(resolve, delay)); } const result = await operation(); if (result.success) { return result; } lastError = result.error!; // Don't retry non-retryable errors if (!result.error?.retryable) { break; } } return { success: false, error: { ...lastError!, message: `${lastError!.message} (after ${maxRetries + 1} attempts)` } }; } } // Singleton instance const apiService = new BookmarkletAPIService(); export { apiService, type APIResponse, type SubtitleData }; ``` ### API Client Configuration ```typescript /** * Claude.ai Integration Configuration * Handles tab management and clipboard operations */ interface ClaudeConfig { baseUrl: string; promptTemplate: string; tabManagement: { openInNewTab: boolean; focusTab: boolean; closeOnComplete: boolean; }; } class ClaudeIntegrationService { private readonly config: ClaudeConfig = { baseUrl: 'https://claude.ai/chat', promptTemplate: this.buildPromptTemplate(), tabManagement: { openInNewTab: true, focusTab: false, // Keep YouTube tab active on mobile closeOnComplete: false } }; /** * Open Claude.ai with formatted subtitle content */ public async openClaudeWithSubtitles(videoId: string, subtitles: string): Promise> { try { stateManager.advanceToStep('claude-preparation'); const formattedPrompt = this.formatPrompt(videoId, subtitles); // Copy to clipboard first (primary method) await this.copyToClipboard(formattedPrompt); // Open Claude.ai tab const claudeTab = this.openClaudeTab(); stateManager.completeStep('claude-preparation'); stateManager.completeWorkflow(); return { success: true, data: { tabId: claudeTab?.id } }; } catch (error) { return { success: false, error: { type: 'unknown', message: `Claude integration failed: ${(error as Error).message}`, recoverable: true, retryable: true, userAction: 'Try manually opening Claude.ai and pasting the content' } }; } } private async copyToClipboard(text: string): Promise { try { // Modern clipboard API (preferred) if (navigator.clipboard && navigator.clipboard.writeText) { await navigator.clipboard.writeText(text); return; } // Fallback for older browsers const textArea = document.createElement('textarea'); textArea.value = text; textArea.style.position = 'fixed'; textArea.style.left = '-999999px'; textArea.style.top = '-999999px'; document.body.appendChild(textArea); textArea.focus(); textArea.select(); const successful = document.execCommand('copy'); document.body.removeChild(textArea); if (!successful) { throw new Error('Clipboard copy failed'); } } catch (error) { throw new Error(`Clipboard operation failed: ${(error as Error).message}`); } } private openClaudeTab(): Window | null { return window.open( this.config.baseUrl, '_blank', 'noopener,noreferrer' ); } private formatPrompt(videoId: string, subtitles: string): string { const template = this.config.promptTemplate; return template .replace('{VIDEO_ID}', videoId) .replace('{SUBTITLES}', subtitles) .replace('{TIMESTAMP}', new Date().toISOString()); } private buildPromptTemplate(): string { return `YouTube Video Analysis Request Video ID: {VIDEO_ID} Generated: {TIMESTAMP} Please analyze the following YouTube video subtitles and provide a structured summary in exactly these four sections: ## Overview Brief summary of what this video covers and main topic ## Essential Points Key insights, facts, or concepts presented (bullet points) ## Value Proposition Why someone should watch this video - what they'll gain ## Beginner Summary Simplified explanation suitable for someone new to the topic --- SUBTITLES: {SUBTITLES} --- Please focus on actionable insights and practical value for educational content consumption decisions.`; } } // Singleton instance const claudeService = new ClaudeIntegrationService(); export { claudeService, type ClaudeConfig }; ``` ## Routing ### Route Configuration ```typescript /** * Bookmarklet Routing Configuration * Context-aware navigation for YouTube integration */ interface RouteContext { youtubeUrl: string; videoId: string | null; pageType: 'watch' | 'shorts' | 'embed' | 'playlist' | 'unknown'; isMobile: boolean; } interface RouteHandler { canHandle: (context: RouteContext) => boolean; execute: (context: RouteContext) => Promise; errorFallback?: (error: Error, context: RouteContext) => Promise; } class BookmarkletRouter { private handlers: Map = new Map(); private readonly YOUTUBE_PATTERNS = { watch: /(?:youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]{11})/, shorts: /youtube\.com\/shorts\/([a-zA-Z0-9_-]{11})/, embed: /youtube\.com\/embed\/([a-zA-Z0-9_-]{11})/, playlist: /youtube\.com\/playlist\?list=([a-zA-Z0-9_-]+)/ }; constructor() { this.registerDefaultHandlers(); } /** * Main routing entry point - analyzes current page and executes appropriate handler */ public async route(): Promise { try { const context = this.analyzeCurrentContext(); // Find matching handler const handler = this.findHandler(context); if (!handler) { throw new Error(`No handler found for context: ${context.pageType}`); } // Execute handler await handler.execute(context); } catch (error) { await this.handleRoutingError(error as Error); } } /** * Analyze current page context */ private analyzeCurrentContext(): RouteContext { const currentUrl = window.location.href; const isMobile = window.innerWidth <= 768 || /Mobile|Android/i.test(navigator.userAgent); // Determine page type and extract video ID let pageType: RouteContext['pageType'] = 'unknown'; let videoId: string | null = null; for (const [type, pattern] of Object.entries(this.YOUTUBE_PATTERNS)) { const match = currentUrl.match(pattern); if (match) { pageType = type as RouteContext['pageType']; if (type !== 'playlist') { videoId = match[1]; } break; } } return { youtubeUrl: currentUrl, videoId, pageType, isMobile }; } /** * Find appropriate handler for context */ private findHandler(context: RouteContext): RouteHandler | null { for (const [name, handler] of this.handlers) { if (handler.canHandle(context)) { console.log(`[Router] Using handler: ${name}`); return handler; } } return null; } /** * Register default handlers for different YouTube contexts */ private registerDefaultHandlers(): void { // Standard YouTube video watch page this.registerHandler('youtube-watch', { canHandle: (context) => context.pageType === 'watch' && !!context.videoId, execute: async (context) => { stateManager.startWorkflow(); stateManager.setVideoData(context.videoId!); // Standard workflow: extract → claude const subtitleResult = await apiService.extractSubtitles(context.videoId!); if (!subtitleResult.success) { throw new Error(subtitleResult.error!.message); } await claudeService.openClaudeWithSubtitles(context.videoId!, subtitleResult.data!.subtitles); } }); // YouTube Shorts this.registerHandler('youtube-shorts', { canHandle: (context) => context.pageType === 'shorts' && !!context.videoId, execute: async (context) => { // Shorts often have limited or no subtitles const confirmShorts = confirm( 'YouTube Shorts videos often have limited subtitles. Continue anyway?' ); if (!confirmShorts) return; // Same workflow as regular videos await this.handlers.get('youtube-watch')!.execute(context); } }); // Embedded YouTube videos this.registerHandler('youtube-embed', { canHandle: (context) => context.pageType === 'embed' && !!context.videoId, execute: async (context) => { // For embedded videos, we might need different handling stateManager.startWorkflow(); stateManager.setVideoData(context.videoId!); const subtitleResult = await apiService.extractSubtitles(context.videoId!); if (!subtitleResult.success) { throw new Error(subtitleResult.error!.message); } await claudeService.openClaudeWithSubtitles(context.videoId!, subtitleResult.data!.subtitles); } }); // Playlist pages (no specific video) this.registerHandler('youtube-playlist', { canHandle: (context) => context.pageType === 'playlist', execute: async (context) => { throw new Error('Please navigate to a specific video to analyze. Playlist analysis is not supported.'); } }); // Mobile-optimized fallback this.registerHandler('mobile-fallback', { canHandle: (context) => context.isMobile && !context.videoId, execute: async (context) => { throw new Error('Unable to detect video on this mobile page. Please ensure you\'re on a YouTube video page.'); } }); // Generic error handler this.registerHandler('error-fallback', { canHandle: () => true, // Always matches as last resort execute: async (context) => { throw new Error(`Unsupported YouTube page: ${context.pageType}. Please navigate to a video page.`); } }); } /** * Register a new route handler */ public registerHandler(name: string, handler: RouteHandler): void { this.handlers.set(name, handler); } /** * Handle routing errors with user-friendly messages */ private async handleRoutingError(error: Error): Promise { console.error('[Router] Routing error:', error); const errorOverlay = new MobileOverlayComponent({ id: 'routing-error', type: 'error', content: `

Unable to Process Video

${error.message}

`, autoClose: 10000 }); errorOverlay.show(); stateManager.reset(); } /** * Utility: Extract video ID from various YouTube URL formats */ public static extractVideoId(url: string): string | null { const patterns = [ /(?:youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]{11})/, /youtube\.com\/shorts\/([a-zA-Z0-9_-]{11})/, /youtube\.com\/embed\/([a-zA-Z0-9_-]{11})/ ]; for (const pattern of patterns) { const match = url.match(pattern); if (match) return match[1]; } return null; } /** * Utility: Check if current page is a valid YouTube video page */ public static isValidYouTubeVideoPage(): boolean { const videoId = BookmarkletRouter.extractVideoId(window.location.href); return !!videoId; } } // Singleton router instance const router = new BookmarkletRouter(); // Main bookmarklet entry point async function initializeBookmarklet(): Promise { try { // Verify we're on YouTube if (!window.location.hostname.includes('youtube.com')) { throw new Error('This bookmarklet only works on YouTube pages'); } // Route based on current context await router.route(); } catch (error) { console.error('[Bookmarklet] Initialization failed:', error); // Show user-friendly error alert(`YT2AI Error: ${(error as Error).message}`); } } export { router, initializeBookmarklet, BookmarkletRouter }; ``` ## Styling Guidelines ### Styling Approach The bookmarklet uses **Inline CSS-in-JS** approach with mobile-first responsive design principles. This ensures zero external dependencies while providing YouTube-compatible styling that works across different mobile Chrome versions. **Key Methodologies:** - **Mobile-First Progressive Enhancement:** Base styles for mobile (320px+), enhanced for larger screens - **CSS-in-JS with Template Literals:** Dynamic styling based on context and state - **YouTube Integration-Safe:** High z-index values and namespaced classes to avoid conflicts - **Touch-Optimized:** Minimum 44px touch targets, gesture-friendly interactions ### Global Theme Variables ```css :root { /* === COLOR SYSTEM === */ /* Primary Colors - YouTube Compatible */ --yt2ai-primary: #1976d2; --yt2ai-primary-hover: #1565c0; --yt2ai-primary-light: #e3f2fd; --yt2ai-primary-dark: #0d47a1; /* Semantic Colors */ --yt2ai-success: #4caf50; --yt2ai-success-light: #e8f5e8; --yt2ai-warning: #ff9800; --yt2ai-warning-light: #fff3e0; --yt2ai-error: #f44336; --yt2ai-error-light: #ffebee; --yt2ai-info: #2196f3; --yt2ai-info-light: #e3f2fd; /* Neutral Colors */ --yt2ai-white: #ffffff; --yt2ai-gray-50: #fafafa; --yt2ai-gray-100: #f5f5f5; --yt2ai-gray-200: #eeeeee; --yt2ai-gray-300: #e0e0e0; --yt2ai-gray-400: #bdbdbd; --yt2ai-gray-500: #9e9e9e; --yt2ai-gray-600: #757575; --yt2ai-gray-700: #616161; --yt2ai-gray-800: #424242; --yt2ai-gray-900: #212121; --yt2ai-black: #000000; /* === SPACING SYSTEM === */ /* Base spacing unit: 4px */ --yt2ai-space-1: 4px; /* xs */ --yt2ai-space-2: 8px; /* sm */ --yt2ai-space-3: 12px; /* md */ --yt2ai-space-4: 16px; /* lg */ --yt2ai-space-5: 20px; /* xl */ --yt2ai-space-6: 24px; /* 2xl */ --yt2ai-space-8: 32px; /* 3xl */ --yt2ai-space-10: 40px; /* 4xl */ --yt2ai-space-12: 48px; /* 5xl */ --yt2ai-space-16: 64px; /* 6xl */ /* Mobile-specific spacing */ --yt2ai-mobile-padding: var(--yt2ai-space-4); --yt2ai-mobile-margin: var(--yt2ai-space-3); --yt2ai-touch-target: 44px; /* Minimum touch target */ /* === TYPOGRAPHY === */ /* Font families */ --yt2ai-font-system: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; --yt2ai-font-mono: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace; /* Font sizes - Mobile first */ --yt2ai-text-xs: 12px; /* Small text, captions */ --yt2ai-text-sm: 14px; /* Body text mobile */ --yt2ai-text-base: 16px; /* Body text desktop */ --yt2ai-text-lg: 18px; /* Large body text */ --yt2ai-text-xl: 20px; /* Small headings */ --yt2ai-text-2xl: 24px; /* Medium headings */ --yt2ai-text-3xl: 30px; /* Large headings */ /* Line heights */ --yt2ai-leading-tight: 1.25; --yt2ai-leading-normal: 1.5; --yt2ai-leading-relaxed: 1.625; /* Font weights */ --yt2ai-font-normal: 400; --yt2ai-font-medium: 500; --yt2ai-font-semibold: 600; --yt2ai-font-bold: 700; /* === SHADOWS === */ /* Elevation system */ --yt2ai-shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05); --yt2ai-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); --yt2ai-shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); --yt2ai-shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); --yt2ai-shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1); /* Mobile-optimized shadows */ --yt2ai-shadow-mobile: 0 4px 12px rgb(0 0 0 / 0.15); --yt2ai-shadow-modal: 0 25px 50px -12px rgb(0 0 0 / 0.25); /* === BORDERS === */ --yt2ai-border-width: 1px; --yt2ai-border-color: var(--yt2ai-gray-300); --yt2ai-border-radius-sm: 4px; --yt2ai-border-radius: 8px; --yt2ai-border-radius-md: 12px; --yt2ai-border-radius-lg: 16px; --yt2ai-border-radius-full: 9999px; /* Mobile-optimized border radius */ --yt2ai-border-radius-mobile: var(--yt2ai-border-radius-md); /* === TRANSITIONS === */ --yt2ai-transition-fast: 150ms ease; --yt2ai-transition: 200ms ease; --yt2ai-transition-slow: 300ms ease; /* Mobile-optimized transitions */ --yt2ai-transition-mobile: 200ms cubic-bezier(0.4, 0, 0.2, 1); --yt2ai-transition-spring: 300ms cubic-bezier(0.68, -0.55, 0.265, 1.55); /* === Z-INDEX LAYERS === */ --yt2ai-z-dropdown: 1000; --yt2ai-z-sticky: 1020; --yt2ai-z-fixed: 1030; --yt2ai-z-modal-backdrop: 1040; --yt2ai-z-modal: 1050; --yt2ai-z-popover: 1060; --yt2ai-z-tooltip: 1070; --yt2ai-z-bookmarklet: 999999; /* Override YouTube */ /* === DARK MODE SUPPORT === */ /* Auto dark mode based on system preference */ --yt2ai-bg-primary: var(--yt2ai-white); --yt2ai-bg-secondary: var(--yt2ai-gray-50); --yt2ai-text-primary: var(--yt2ai-gray-900); --yt2ai-text-secondary: var(--yt2ai-gray-600); --yt2ai-border-primary: var(--yt2ai-gray-300); } /* Dark mode overrides */ @media (prefers-color-scheme: dark) { :root { --yt2ai-bg-primary: var(--yt2ai-gray-900); --yt2ai-bg-secondary: var(--yt2ai-gray-800); --yt2ai-text-primary: var(--yt2ai-gray-100); --yt2ai-text-secondary: var(--yt2ai-gray-400); --yt2ai-border-primary: var(--yt2ai-gray-600); /* Adjust shadows for dark mode */ --yt2ai-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.3), 0 1px 2px -1px rgb(0 0 0 / 0.3); --yt2ai-shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.3), 0 2px 4px -2px rgb(0 0 0 / 0.3); --yt2ai-shadow-mobile: 0 4px 12px rgb(0 0 0 / 0.4); --yt2ai-shadow-modal: 0 25px 50px -12px rgb(0 0 0 / 0.5); } } /* === MOBILE BREAKPOINTS === */ /* Extra small devices */ @media (max-width: 374px) { :root { --yt2ai-text-sm: 13px; --yt2ai-text-base: 15px; --yt2ai-mobile-padding: var(--yt2ai-space-3); } } /* Small devices (landscape phones) */ @media (min-width: 375px) and (max-width: 667px) { :root { --yt2ai-mobile-padding: var(--yt2ai-space-4); } } /* Medium devices (tablets) */ @media (min-width: 668px) and (max-width: 1024px) { :root { --yt2ai-text-base: 16px; --yt2ai-mobile-padding: var(--yt2ai-space-5); } } /* Large devices (desktop) */ @media (min-width: 1025px) { :root { --yt2ai-text-base: 16px; --yt2ai-mobile-padding: var(--yt2ai-space-6); } } /* === UTILITY CLASSES === */ /* Mobile-first responsive utilities */ .yt2ai-mobile-only { display: block; } .yt2ai-desktop-only { display: none; } @media (min-width: 768px) { .yt2ai-mobile-only { display: none; } .yt2ai-desktop-only { display: block; } } /* Touch-optimized utilities */ .yt2ai-touch-target { min-height: var(--yt2ai-touch-target); min-width: var(--yt2ai-touch-target); display: flex; align-items: center; justify-content: center; } .yt2ai-touch-friendly { padding: var(--yt2ai-space-3) var(--yt2ai-space-4); margin: var(--yt2ai-space-2); border-radius: var(--yt2ai-border-radius-mobile); } /* Accessibility utilities */ .yt2ai-sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } .yt2ai-focus-visible:focus-visible { outline: 2px solid var(--yt2ai-primary); outline-offset: 2px; } /* High contrast mode support */ @media (prefers-contrast: high) { :root { --yt2ai-border-width: 2px; } .yt2ai-overlay { border: var(--yt2ai-border-width) solid var(--yt2ai-border-primary); } } /* Reduced motion support */ @media (prefers-reduced-motion: reduce) { :root { --yt2ai-transition-fast: 0ms; --yt2ai-transition: 0ms; --yt2ai-transition-slow: 0ms; --yt2ai-transition-mobile: 0ms; --yt2ai-transition-spring: 0ms; } } ``` ## Testing Requirements ### Component Test Template ```typescript /** * Mobile-Optimized Component Testing Template * Jest + jsdom for bookmarklet testing */ import { jest } from '@jest/globals'; import { MobileOverlayComponent } from '../src/ui/overlayManager.js'; import { stateManager } from '../src/state/BookmarkletState.js'; import { apiService } from '../src/core/subtitleFetcher.js'; // Mock mobile environment const mockMobileEnvironment = () => { // Mock mobile user agent Object.defineProperty(navigator, 'userAgent', { writable: true, value: 'Mozilla/5.0 (Linux; Android 10; SM-G975F) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.120 Mobile Safari/537.36' }); // Mock mobile viewport Object.defineProperty(window, 'innerWidth', { writable: true, value: 375 }); Object.defineProperty(window, 'innerHeight', { writable: true, value: 667 }); // Mock touch events window.TouchEvent = class TouchEvent extends Event { constructor(type: string, options: any) { super(type, options); } }; // Mock clipboard API Object.defineProperty(navigator, 'clipboard', { value: { writeText: jest.fn().mockResolvedValue(undefined), readText: jest.fn().mockResolvedValue('test content') } }); }; // Mock YouTube environment const mockYouTubeEnvironment = () => { // Mock YouTube URL Object.defineProperty(window, 'location', { value: { href: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', hostname: 'youtube.com' } }); // Mock YouTube DOM elements document.body.innerHTML = `
`; }; describe('MobileOverlayComponent', () => { beforeEach(() => { mockMobileEnvironment(); document.body.innerHTML = ''; jest.clearAllMocks(); }); afterEach(() => { // Cleanup any created overlays document.querySelectorAll('.yt2ai-overlay').forEach(el => el.remove()); }); describe('Component Creation', () => { test('creates mobile-optimized overlay with correct structure', () => { const overlay = new MobileOverlayComponent({ id: 'test-overlay', type: 'loading', content: 'Test content' }); expect(overlay.element).toBeDefined(); expect(overlay.element.className).toContain('yt2ai-overlay'); expect(overlay.element.className).toContain('yt2ai-loading'); expect(overlay.isVisible).toBe(false); }); test('applies mobile-specific styling', () => { const overlay = new MobileOverlayComponent({ id: 'mobile-test', type: 'info', content: 'Mobile test' }); overlay.show(); const content = overlay.element.querySelector('.yt2ai-overlay-content'); const styles = window.getComputedStyle(content); // Should have mobile-optimized max-width expect(styles.maxWidth).toBe('95vw'); }); test('handles touch events correctly', async () => { const overlay = new MobileOverlayComponent({ id: 'touch-test', type: 'error', content: 'Touch test' }); overlay.show(); const closeBtn = overlay.element.querySelector('.yt2ai-close-btn'); expect(closeBtn).toBeDefined(); // Mock touch event const touchEvent = new TouchEvent('touchend', { bubbles: true, cancelable: true, touches: [] }); closeBtn.dispatchEvent(touchEvent); // Should close on touch expect(overlay.isVisible).toBe(false); }); }); describe('Mobile Responsiveness', () => { test('adapts to small mobile screens', () => { // Mock very small screen Object.defineProperty(window, 'innerWidth', { value: 320 }); const overlay = new MobileOverlayComponent({ id: 'small-screen', type: 'success', content: 'Small screen test' }); overlay.show(); const content = overlay.element.querySelector('.yt2ai-overlay-content'); const bodyEl = overlay.element.querySelector('.yt2ai-overlay-body'); // Should adjust padding for small screens expect(window.getComputedStyle(bodyEl).padding).toBe('20px'); }); test('provides adequate touch targets', () => { const overlay = new MobileOverlayComponent({ id: 'touch-target-test', type: 'warning', content: 'Touch target test' }); overlay.show(); const closeBtn = overlay.element.querySelector('.yt2ai-close-btn'); const styles = window.getComputedStyle(closeBtn); // Should meet minimum touch target size (44px) expect(parseInt(styles.minWidth)).toBeGreaterThanOrEqual(44); expect(parseInt(styles.minHeight)).toBeGreaterThanOrEqual(44); }); }); describe('Error Handling', () => { test('handles invalid content gracefully', () => { expect(() => { new MobileOverlayComponent({ id: 'invalid-test', type: 'error', content: null as any }); }).not.toThrow(); }); test('cleans up properly on destroy', () => { const overlay = new MobileOverlayComponent({ id: 'cleanup-test', type: 'info', content: 'Cleanup test' }); overlay.show(); expect(document.querySelector('.yt2ai-overlay')).toBeDefined(); overlay.destroy(); expect(document.querySelector('.yt2ai-overlay')).toBeNull(); expect(document.body.style.overflow).toBe(''); }); }); }); ``` ### Testing Best Practices 1. **Unit Tests**: Test individual components in isolation with mobile-specific mocking 2. **Integration Tests**: Test component interactions with realistic mobile environment simulation 3. **E2E Tests**: Test critical user flows using manual testing protocols for mobile Chrome 4. **Coverage Goals**: Aim for 80% code coverage with focus on mobile-critical paths 5. **Test Structure**: Arrange-Act-Assert pattern with mobile environment setup 6. **Mock External Dependencies**: API calls, routing, state management with mobile considerations ## Environment Configuration ### Runtime Environment Configuration ```typescript /** * Bookmarklet Environment Configuration * Runtime detection and feature flags for different contexts */ interface EnvironmentConfig { // Runtime environment detection isDevelopment: boolean; isProduction: boolean; isMobile: boolean; isAndroid: boolean; isChrome: boolean; // Feature flags features: { enableDebugLogging: boolean; enablePerformanceTracking: boolean; enableErrorReporting: boolean; enableRPAAutomation: boolean; enableCAPTCHAHandling: boolean; enableRetryLogic: boolean; }; // API configuration api: { subtitleService: { baseUrl: string; timeout: number; retries: number; }; claude: { baseUrl: string; features: string[]; }; }; // Mobile-specific settings mobile: { touchTargetSize: number; maxOverlayWidth: string; animationDuration: number; networkTimeout: number; }; // Debug configuration debug: { logLevel: 'error' | 'warn' | 'info' | 'debug'; enableStateLogging: boolean; enableAPILogging: boolean; enableUILogging: boolean; }; } class EnvironmentManager { private config: EnvironmentConfig; constructor() { this.config = this.detectEnvironment(); } /** * Detect runtime environment and configure accordingly */ private detectEnvironment(): EnvironmentConfig { // Development detection (presence of specific debug markers) const isDevelopment = this.isDevelopmentMode(); const isProduction = !isDevelopment; // Mobile/device detection const isMobile = this.isMobileDevice(); const isAndroid = this.isAndroidDevice(); const isChrome = this.isChromeeBrowser(); return { isDevelopment, isProduction, isMobile, isAndroid, isChrome, features: { enableDebugLogging: isDevelopment, enablePerformanceTracking: isDevelopment || this.hasURLFlag('perf'), enableErrorReporting: isProduction, enableRPAAutomation: false, // Phase 2 feature enableCAPTCHAHandling: true, enableRetryLogic: true }, api: { subtitleService: { baseUrl: 'https://downloadyoutubesubtitles.com/api', timeout: isMobile ? 30000 : 15000, retries: isMobile ? 3 : 2 }, claude: { baseUrl: 'https://claude.ai/chat', features: ['new-tab', 'clipboard-fallback'] } }, mobile: { touchTargetSize: 44, // WCAG AA compliance maxOverlayWidth: isMobile ? '95vw' : '80vw', animationDuration: this.hasReducedMotion() ? 0 : 200, networkTimeout: isAndroid ? 35000 : 30000 // Android can be slower }, debug: { logLevel: isDevelopment ? 'debug' : 'error', enableStateLogging: isDevelopment, enableAPILogging: isDevelopment || this.hasURLFlag('api-debug'), enableUILogging: isDevelopment || this.hasURLFlag('ui-debug') } }; } /** * Environment detection methods */ private isDevelopmentMode(): boolean { // Check for development indicators return !!( // Debug query parameter this.hasURLFlag('debug') || // Development console exists (window as any).__YT2AI_DEBUG__ || // Local file protocol (for local development) window.location.protocol === 'file:' || // Localhost detection window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1' ); } private isMobileDevice(): boolean { return !!( window.innerWidth <= 768 || /Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) || ('ontouchstart' in window) || (navigator.maxTouchPoints > 0) ); } private isAndroidDevice(): boolean { return /Android/i.test(navigator.userAgent); } private isChromeeBrowser(): boolean { return /Chrome/i.test(navigator.userAgent) && !!/Google Inc/.test(navigator.vendor); } private hasReducedMotion(): boolean { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; } private hasURLFlag(flag: string): boolean { const url = new URL(window.location.href); return url.searchParams.has(flag) || url.hash.includes(`#${flag}`); } /** * Public API for accessing configuration */ public getConfig(): Readonly { return { ...this.config }; } public isFeatureEnabled(feature: keyof EnvironmentConfig['features']): boolean { return this.config.features[feature]; } public getAPIConfig(service: 'subtitleService' | 'claude') { return this.config.api[service]; } public getMobileConfig() { return this.config.mobile; } public getDebugConfig() { return this.config.debug; } /** * Runtime feature flag updates (for testing/debugging) */ public updateFeature(feature: keyof EnvironmentConfig['features'], enabled: boolean): void { if (this.config.isDevelopment) { this.config.features[feature] = enabled; console.log(`[Environment] Feature ${feature} ${enabled ? 'enabled' : 'disabled'}`); } } /** * Compatibility checks */ public checkCompatibility(): { compatible: boolean; issues: string[] } { const issues: string[] = []; // Check required browser features if (!window.fetch) { issues.push('Fetch API not supported - bookmarklet requires modern browser'); } if (!navigator.clipboard) { issues.push('Clipboard API not available - fallback methods will be used'); } if (!this.config.isChrome && this.config.isMobile) { issues.push('Non-Chrome mobile browser detected - functionality may be limited'); } if (!this.config.isAndroid && this.config.isMobile) { issues.push('Non-Android mobile device - bookmarklet optimized for Android Chrome'); } // Check YouTube context if (!window.location.hostname.includes('youtube.com')) { issues.push('Not on YouTube - bookmarklet only works on YouTube pages'); } // Check network connectivity (basic) if (!navigator.onLine) { issues.push('No internet connection detected'); } return { compatible: issues.length === 0, issues }; } /** * Performance monitoring setup */ public setupPerformanceTracking(): void { if (!this.config.features.enablePerformanceTracking) return; // Track bookmarklet initialization time performance.mark('yt2ai-start'); // Track critical user metrics if (window.performance && window.performance.observer) { const observer = new PerformanceObserver((list) => { list.getEntries().forEach((entry) => { if (entry.name.startsWith('yt2ai-')) { console.log(`[Performance] ${entry.name}: ${entry.duration}ms`); } }); }); observer.observe({ entryTypes: ['measure', 'navigation'] }); } } /** * Debug logging setup */ public setupLogging(): void { if (!this.config.isDevelopment) return; // Enhanced console logging for development (window as any).__YT2AI_LOG__ = { state: (message: string, data?: any) => { if (this.config.debug.enableStateLogging) { console.log(`[YT2AI:State] ${message}`, data); } }, api: (message: string, data?: any) => { if (this.config.debug.enableAPILogging) { console.log(`[YT2AI:API] ${message}`, data); } }, ui: (message: string, data?: any) => { if (this.config.debug.enableUILogging) { console.log(`[YT2AI:UI] ${message}`, data); } } }; } } // Singleton environment manager const environment = new EnvironmentManager(); // Initialize environment features environment.setupPerformanceTracking(); environment.setupLogging(); // Export for use throughout bookmarklet export { environment, type EnvironmentConfig }; // Global debug access in development if (environment.getConfig().isDevelopment) { (window as any).__YT2AI_ENV__ = environment; } ``` ## Frontend Developer Standards ### Critical Coding Rules **Universal JavaScript Rules:** 1. **Always use `const` by default, `let` when reassignment needed** - Prevents accidental mutations in bookmarklet scope 2. **Prefix all CSS classes with `yt2ai-`** - Absolutely critical to avoid YouTube style conflicts 3. **Use `z-index: 999999` or higher for all overlays** - Must render above YouTube's interface 4. **Handle all async operations with try/catch** - Network calls and API failures are common in mobile environment 5. **Always cleanup event listeners and DOM elements** - Memory leaks are critical in bookmarklet context 6. **Never use `document.write()` or `innerHTML` with user content** - XSS prevention in third-party context 7. **Always check for feature availability before use** - Mobile Chrome versions may vary in API support **Mobile-Specific Rules:** 8. **All touch targets must be minimum 44px** - WCAG AA compliance for mobile accessibility 9. **Use `touchend` events, not `click` for mobile optimization** - Better mobile responsiveness 10. **Always prevent body scroll when showing modals** - Essential mobile UX pattern 11. **Test viewport changes during overlay display** - Android keyboards and orientation changes 12. **Use `rem` units for scalable mobile design** - Better cross-device consistency than `px` 13. **Implement loading states for all network operations** - Mobile networks are unpredictable 14. **Always provide retry mechanisms for failed API calls** - Essential for mobile network reliability **Bookmarklet-Specific Rules:** 15. **Never rely on external dependencies in production** - Must be completely self-contained 16. **Always namespace global variables with `__YT2AI_`** - Prevent conflicts with YouTube/page JavaScript 17. **Use feature detection, never user agent sniffing** - More reliable than parsing user agent strings 18. **Always cleanup on error or completion** - Bookmarklet should leave no traces when finished 19. **Never store sensitive data in localStorage/sessionStorage** - Privacy and security in third-party context 20. **Always validate YouTube context before execution** - Ensure running on valid YouTube pages **Anti-Patterns to Avoid:** ❌ **Never modify YouTube's existing DOM elements** - Can break their functionality ❌ **Never use `alert()`, `confirm()`, `prompt()`** - Poor mobile UX and blocks execution ❌ **Never use `setTimeout` without clearTimeout** - Memory leaks in long-running bookmarklets ❌ **Never assume APIs will always be available** - External services can fail ❌ **Never use `position: fixed` without z-index consideration** - Will render behind YouTube elements ❌ **Never use `eval()` or `Function()` constructor** - Security risk and CSP violations ❌ **Never rely on specific YouTube DOM structure** - YouTube frequently updates their interface ### Quick Reference **Common Commands:** ```bash # Development server (if using local development) npm run dev # Start development server with hot reload npm run build # Build minified production bookmarklet npm run test # Run Jest test suite npm run test:mobile # Run mobile-specific test suite npm run lint # Run ESLint with mobile-first rules npm run format # Format code with Prettier ``` **Key Import Patterns:** ```javascript // Modular development imports (build process inlines these) import { stateManager } from './state/BookmarkletState.js'; import { apiService } from './core/subtitleFetcher.js'; import { MobileOverlayComponent } from './ui/overlayManager.js'; import { environment } from './utils/environment.js'; // Always use dynamic imports for optional features const rpaModule = await import('./features/rpaAutomation.js').catch(() => null); ``` **File Naming Conventions:** ``` ✅ Good: - videoExtractor.js - mobileUtils.js - overlayManager.js - BookmarkletState.js ❌ Avoid: - VideoExtractor.js (PascalCase files) - mobile_utils.js (snake_case) - overlay-manager.js (kebab-case) - bookmarkletstate.js (no capitals) ``` **Project-Specific Patterns:** **Error Handling Pattern:** ```javascript // Always use this error handling pattern try { stateManager.advanceToStep('subtitle-extraction'); const result = await apiService.extractSubtitles(videoId); if (!result.success) { throw new BookmarkletError(result.error); } stateManager.completeStep('subtitle-extraction'); return result.data; } catch (error) { stateManager.failStep('subtitle-extraction', { type: 'api', message: error.message, recoverable: true, retryable: true }); throw error; } ``` **Mobile Overlay Creation Pattern:** ```javascript // Standard mobile overlay creation const overlay = new MobileOverlayComponent({ id: 'unique-identifier', type: 'loading' | 'error' | 'success' | 'info', content: 'User-friendly message', position: 'center', // 'top' | 'center' | 'bottom' autoClose: 5000 // Optional auto-close timer }); overlay.show(); // Always cleanup setTimeout(() => { overlay.destroy(); }, 10000); ``` **API Call Pattern:** ```javascript // Standard API call with mobile optimization const makeAPICall = async (endpoint, options = {}) => { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 30000); try { const response = await fetch(endpoint, { ...options, signal: controller.signal, headers: { 'User-Agent': 'Mozilla/5.0 (Linux; Android 10; Mobile) Chrome/91.0', ...options.headers } }); clearTimeout(timeoutId); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } return await response.json(); } catch (error) { clearTimeout(timeoutId); if (error.name === 'AbortError') { throw new Error('Request timeout - mobile network too slow'); } throw error; } }; ``` **State Management Pattern:** ```javascript // Always update state through stateManager const updateWorkflowData = (videoId, subtitles) => { stateManager.setVideoData(videoId); stateManager.setSubtitleData(subtitles); // Subscribe to state changes for UI updates const unsubscribe = stateManager.subscribe((state) => { if (state.isProcessing) { showLoadingOverlay(); } else { hideLoadingOverlay(); } }); // Always cleanup subscriptions setTimeout(unsubscribe, 60000); // Max bookmarklet lifetime }; ``` **Mobile-Optimized CSS Pattern:** ```javascript // CSS-in-JS with mobile-first approach const mobileStyles = ` .yt2ai-component { /* Mobile base styles */ font-size: 14px; padding: 12px; touch-action: manipulation; -webkit-tap-highlight-color: transparent; } @media (min-width: 768px) { .yt2ai-component { /* Desktop enhancements */ font-size: 16px; padding: 16px; } } @media (max-width: 374px) { .yt2ai-component { /* Very small mobile adjustments */ font-size: 13px; padding: 10px; } } `; ``` **Performance Monitoring Pattern:** ```javascript // Track critical performance metrics const trackPerformance = (operationName, fn) => { if (environment.isFeatureEnabled('enablePerformanceTracking')) { performance.mark(`yt2ai-${operationName}-start`); } const result = await fn(); if (environment.isFeatureEnabled('enablePerformanceTracking')) { performance.mark(`yt2ai-${operationName}-end`); performance.measure( `yt2ai-${operationName}`, `yt2ai-${operationName}-start`, `yt2ai-${operationName}-end` ); } return result; }; ``` --- 🏗️ **Frontend Architecture Complete** This comprehensive frontend architecture document provides the complete technical foundation for the YouTube Subtitle Extraction & AI Summarization Bookmarklet, optimized for Android Chrome mobile environment with robust error handling, state management, and user experience considerations.