Files
yt2ai/docs/ui-architecture.md
Marius Mutu 064899eb95
Some checks failed
Build and Test YT2AI Bookmarklet / build-and-test (16.x) (push) Has been cancelled
Build and Test YT2AI Bookmarklet / build-and-test (18.x) (push) Has been cancelled
Build and Test YT2AI Bookmarklet / build-and-test (20.x) (push) Has been cancelled
Build and Test YT2AI Bookmarklet / release (push) Has been cancelled
Build and Test YT2AI Bookmarklet / security-scan (push) Has been cancelled
Initial project setup
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>
2025-09-08 15:51:19 +03:00

2344 lines
68 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 = `
<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:** `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<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
```typescript
/**
* 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
```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<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
```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<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
```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 = `
<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
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<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:**
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.