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
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>
2344 lines
68 KiB
Markdown
2344 lines
68 KiB
Markdown
# 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. |