Add project structure with package.json, source code, tests, documentation, and GitHub workflows. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
68 KiB
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:
- Bookmarklet structure and organization
- Mobile-optimized overlay/modal patterns for YouTube integration
- 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
/**
* 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 = `
<div class="yt2ai-overlay-backdrop" role="dialog" aria-modal="true">
<div class="yt2ai-overlay-content">
<div class="yt2ai-overlay-body">
${typeof this.config.content === 'string' ? this.config.content : ''}
</div>
<button class="yt2ai-close-btn" aria-label="Close">×</button>
</div>
</div>
`;
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:
PascalCaseclasses incamelCasefiles (e.g.,MobileOverlayComponentinoverlayManager.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--modifierpattern - Mobile States:
yt2ai-mobile-*for mobile-specific classes
JavaScript Naming:
- Functions:
camelCasewith descriptive verbs (e.g.,extractVideoId,showMobileError) - Private Methods: Leading underscore
_privateMethodsconvention - Event Handlers:
handleprefix (e.g.,handleTouchEnd,handleAPIError)
Mobile-Specific Conventions:
- Touch Targets: Minimum 44px touch targets for mobile accessibility
- Viewport Units: Use
vw/vhsparingly, preferremfor 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
/**
* 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<WorkflowStep, 'status' | 'startTime' | 'endTime'>[] = [
{ 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<BookmarkletState> {
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<BookmarkletState>): 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
/**
* API Service Template for Bookmarklet
* Mobile-optimized with comprehensive error handling
*/
interface APIResponse<T> {
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<APIResponse<SubtitleData>> {
try {
stateManager.advanceToStep('subtitle-extraction');
const response = await this.makeRequest<SubtitleData>(
`${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<APIResponse<{ status: string; responseTime: number }>> {
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<T>(
url: string,
options: {
method: 'GET' | 'POST';
body?: string;
params?: Record<string, string>;
},
config: APIRequestConfig = this.DEFAULT_CONFIG
): Promise<APIResponse<T>> {
// 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, string>): 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<never> {
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<T>(
operation: () => Promise<APIResponse<T>>,
maxRetries: number = 2
): Promise<APIResponse<T>> {
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
/**
* 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<APIResponse<{ tabId?: string }>> {
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<void> {
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
/**
* 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<void>;
errorFallback?: (error: Error, context: RouteContext) => Promise<void>;
}
class BookmarkletRouter {
private handlers: Map<string, RouteHandler> = 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<void> {
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<void> {
console.error('[Router] Routing error:', error);
const errorOverlay = new MobileOverlayComponent({
id: 'routing-error',
type: 'error',
content: `
<h3>Unable to Process Video</h3>
<p>${error.message}</p>
<div class="yt2ai-error-actions">
<button class="yt2ai-btn-primary" onclick="window.location.reload()">
Refresh Page
</button>
<button class="yt2ai-btn-secondary" onclick="this.closest('.yt2ai-overlay').remove()">
Close
</button>
</div>
<style>
.yt2ai-error-actions {
margin-top: 20px;
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.yt2ai-btn-primary, .yt2ai-btn-secondary {
padding: 12px 20px;
border: none;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
min-height: 44px;
flex: 1;
min-width: 120px;
}
.yt2ai-btn-primary {
background: #1976d2;
color: white;
}
.yt2ai-btn-secondary {
background: #f5f5f5;
color: #333;
}
@media (max-width: 480px) {
.yt2ai-error-actions {
flex-direction: column;
}
}
</style>
`,
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<void> {
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
: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
/**
* 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 = `
<div id="movie_player"></div>
<div class="ytd-watch-flexy"></div>
`;
};
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
- Unit Tests: Test individual components in isolation with mobile-specific mocking
- Integration Tests: Test component interactions with realistic mobile environment simulation
- E2E Tests: Test critical user flows using manual testing protocols for mobile Chrome
- Coverage Goals: Aim for 80% code coverage with focus on mobile-critical paths
- Test Structure: Arrange-Act-Assert pattern with mobile environment setup
- Mock External Dependencies: API calls, routing, state management with mobile considerations
Environment Configuration
Runtime Environment Configuration
/**
* 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<EnvironmentConfig> {
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:
- Always use
constby default,letwhen reassignment needed - Prevents accidental mutations in bookmarklet scope - Prefix all CSS classes with
yt2ai-- Absolutely critical to avoid YouTube style conflicts - Use
z-index: 999999or higher for all overlays - Must render above YouTube's interface - Handle all async operations with try/catch - Network calls and API failures are common in mobile environment
- Always cleanup event listeners and DOM elements - Memory leaks are critical in bookmarklet context
- Never use
document.write()orinnerHTMLwith user content - XSS prevention in third-party context - 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:
# 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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.