Files
frontend-svelte/GLYPHDIFF_PROJECT_PLAN.md

2998 lines
79 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.
# GlyphDiff.com - Font Comparison Project Plan
> **Status:** Learning-Focused MVP | **Tech Stack:** SvelteKit + TypeScript + Tailwind CSS | **Transition Path:** React → Svelte
---
## Table of Contents
1. [Project Overview](#project-overview)
2. [Executive Summary](#executive-summary)
3. [Architecture Decisions](#architecture-decisions)
4. [Font Provider Integration Strategy](#font-provider-integration-strategy)
5. [Performance Optimization](#performance-optimization)
6. [Frontend Architecture](#frontend-architecture)
7. [Data Models](#data-models)
8. [Implementation Roadmap](#implementation-roadmap)
9. [TanStack Query Research](#tanstack-query-research)
10. [Learning Guide: React → Svelte](#learning-guide-react--svelte)
11. [Scalability & Commercialization](#scalability--commercialization)
12. [Deployment](#deployment)
13. [Risk Assessment](#risk-assessment)
14. [Success Metrics](#success-metrics)
15. [Appendix](#appendix)
---
## Project Overview
### Vision
Create a modern, fast, and intuitive web application for comparing fonts side-by-side. The tool will help designers, developers, and typographers make informed decisions about font choices by providing real-time visual comparisons, filtering, and customization options.
### Target Audience
- **UI/UX Designers:** Selecting typefaces for web and mobile applications
- **Web Developers:** Choosing fonts for implementation in projects
- **Graphic Designers:** Finding complementary fonts for branding
- **Typography Enthusiasts:** Exploring and comparing font characteristics
### Core Features (MVP)
1. **Font Catalog:** Browse available fonts from multiple providers
2. **Side-by-Side Comparison:** Compare up to 4 fonts simultaneously
3. **Custom Preview Text:** Enter custom text for realistic comparison
4. **Filter & Search:** Filter by category, popularity, weight, width
5. **Responsive Design:** Seamless experience across all devices
6. **Dark Mode:** Built-in theme toggle for comfortable viewing
7. **URL Sharing:** Share comparison sessions via shareable URLs
### Tech Stack
| Layer | Technology | Purpose |
|-------|-----------|---------|
| Framework | SvelteKit 2.x | Full-stack framework with SSR/SSG support |
| UI Language | Svelte 5 | Reactive component framework |
| Styling | Tailwind CSS 4.x | Utility-first CSS framework |
| Component Library | Bits UI | Accessible Svelte components (Radix UI port) |
| Type Safety | TypeScript 5.x | Static type checking |
| Hosting | Vercel | Edge deployment & preview environments |
### Project Goals
#### Learning Goals (Primary)
- Gain proficiency in Svelte 5 and its reactive primitives (`$state`, `$derived`, `$effect`)
- Understand SvelteKit's routing and data loading architecture
- Learn client-side state management patterns in Svelte ecosystem
- Explore performance optimization techniques for static web apps
#### Project Goals (Secondary)
- Build a functional, visually appealing font comparison tool
- Create a performant, accessible user interface
- Establish a foundation for future commercialization
- Demonstrate mastery of modern frontend development practices
---
## Executive Summary
### Recommended Approach: Pure Client-Side Architecture
**Recommendation:** Build the MVP as a pure client-side application using SvelteKit's static site generation (SSG) capabilities, with no backend infrastructure.
### Key Rationale
1. **Learning Focus:** Minimize backend complexity to concentrate on Svelte/SvelteKit mastery
2. **Data Availability:** Font metadata is publicly available via Google Fonts API and Fontshare CDN
3. **Performance:** Static generation provides instant page loads and optimal Core Web Vitals
4. **Cost Efficiency:** No server costs during MVP phase
5. **Future Flexibility:** Can easily add backend later when user data collection becomes necessary
### Architecture at a Glance
```
┌─────────────────────────────────────────────────────────────┐
│ User Browser │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ SvelteKit Client Application │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ Font Store │ │ Filter Store│ │ Comp Store │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │
│ │ ┌─────────────────────────────────────────────────┐ │ │
│ │ │ Font Provider Services │ │ │
│ │ │ ┌──────────────┐ ┌──────────────┐ │ │ │
│ │ │ │ Google Fonts │ │ Fontshare │ │ │ │
│ │ │ │ API │ │ CDN │ │ │ │
│ │ │ └──────────────┘ └──────────────┘ │ │ │
│ │ └─────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│ │
│ HTTP Requests │ CSS Font Files
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ Google Fonts API │ │ Fontshare CDN │
└──────────────────┘ └──────────────────┘
```
---
## Architecture Decisions
### Backend Necessity Analysis
#### Option A: Pure Client-Side (Recommended for MVP)
**Pros:**
- **Simplicity:** No server code, database, or API endpoints to maintain
- **Performance:** Static sites load faster and score better on Core Web Vitals
- **Cost:** Zero hosting costs on Vercel free tier
- **Learning Focus:** Maximum time spent on Svelte/SvelteKit fundamentals
- **Deployment:** Instant deployments with zero configuration
- **Scalability:** Vercel Edge handles CDN distribution automatically
**Cons:**
- **No User Data:** Cannot save user preferences or comparison history
- **No Analytics:** Limited to client-side analytics (e.g., Google Analytics)
- **Rate Limiting:** Google Fonts API has usage limits (1,000 requests/day)
- **Caching:** Cannot implement server-side caching strategies
- **Data Freshness:** Font metadata fetched on each session (mitigated by browser caching)
**Use Cases This Supports:**
- ✅ Browse and compare fonts
- ✅ Share comparisons via URL
- ✅ Filter and search fonts
- ✅ Save preferences locally (localStorage)
- ✅ Responsive, dark mode UI
**Use Cases This Does NOT Support:**
- ❌ User accounts and saved comparisons
- ❌ Custom font uploads
- ❌ Collaborative comparisons
- ❌ Advanced analytics
---
#### Option B: Hybrid with Serverless Backend
**Pros:**
- **Caching Layer:** Server-side caching reduces API calls and improves load times
- **Rate Limit Management:** Proxy requests to avoid Google Fonts API limits
- **User Data:** Save user preferences and comparison history
- **Custom Endpoints:** Create specialized API endpoints for font data
- **Preprocessing:** Transform and normalize font data before sending to client
**Cons:**
- **Complexity:** Additional infrastructure to build and maintain
- **Cost:** Vercel Pro plan may be required for serverless functions
- **Latency:** Server round-trip adds latency to initial page load
- **Development Overhead:** Testing backend code requires additional considerations
- **Learning Curve:** Takes time away from Svelte learning goals
**Recommended Backend Stack (if needed later):**
- **API Routes:** SvelteKit server endpoints (`+page.server.ts`, `+server.ts`)
- **Database:** Vercel Postgres (Neon) or SQLite (better-sqlite3)
- **Caching:** Vercel KV (Redis) or in-memory caching
- **Auth:** Lucia Auth or Clerk for user authentication
---
### Recommendation
**Build Option A (Pure Client-Side) for MVP with these considerations:**
1. **Design for future backend:** Structure data fetching and state management to be easily adapted to server-side later
2. **Implement smart caching:** Use browser localStorage for font metadata cache with TTL
3. **Optimize API usage:** Batch requests and use Google Fonts API efficiently
4. **Monitor usage:** Track API usage and user engagement to inform backend decision
5. **Migration path:** Keep server components clear of logic that could be moved to `+page.server.ts` later
### When to Add Backend
Consider adding backend when:
- ✅ User growth exceeds 1,000 daily active users
- ✅ Analytics show users repeatedly saving comparisons manually
- ✅ Feedback indicates desire for user accounts
- ✅ Performance degrades due to repeated API calls
- ✅ Planning to add premium features requiring payment processing
---
## Font Provider Integration Strategy
### Provider Overview
| Provider | API Type | License | Font Count | Integration Complexity |
|----------|----------|---------|------------|------------------------|
| Google Fonts | REST API | Open Source (SIL/OFL) | 1,600+ | Low - Official API available |
| Fontshare | GitHub/CDN | Free License | 100+ | Very Low - Static JSON available |
---
### Google Fonts Integration
#### API Endpoint
```
https://www.googleapis.com/webfonts/v1/webfonts?key=YOUR_API_KEY
```
#### TypeScript Implementation
```typescript
// src/lib/services/google-fonts.ts
import type { FontMetadata, FontVariant, FontCategory } from '$lib/types';
export interface GoogleFontsResponse {
kind: string;
items: GoogleFontItem[];
}
export interface GoogleFontItem {
family: string;
variants: string[];
subsets: string[];
version: string;
lastModified: string;
files: Record<string, string>;
category: 'sans-serif' | 'serif' | 'display' | 'handwriting' | 'monospace';
}
export async function fetchGoogleFonts(apiKey: string): Promise<FontMetadata[]> {
const response = await fetch(
`https://www.googleapis.com/webfonts/v1/webfonts?key=${apiKey}`
);
if (!response.ok) {
throw new Error(`Google Fonts API error: ${response.statusText}`);
}
const data: GoogleFontsResponse = await response.json();
return data.items.map(mapGoogleFontToMetadata);
}
function mapGoogleFontToMetadata(item: GoogleFontItem): FontMetadata {
const category = mapGoogleCategory(item.category);
return {
id: `google-${item.family.toLowerCase().replace(/\s+/g, '-')}`,
provider: 'google-fonts',
family: item.family,
displayName: item.family,
category,
variants: mapGoogleVariants(item.variants),
license: {
type: 'open-source',
url: 'https://fonts.google.com/attribution',
attribution: 'Google Fonts',
commercial: true,
embedding: 'webfont'
},
languages: item.subsets,
previewUrl: getGoogleFontPreviewUrl(item.family),
cssUrl: getGoogleFontCssUrl(item.family, '400'),
metadata: {
version: item.version,
lastModified: item.lastModified,
files: item.files
}
};
}
function mapGoogleCategory(
category: GoogleFontItem['category']
): FontCategory {
const mapping = {
'sans-serif': 'sans-serif',
'serif': 'serif',
'display': 'display',
'handwriting': 'handwriting',
'monospace': 'monospace'
} as const;
return mapping[category] || 'display';
}
function mapGoogleVariants(variants: string[]): FontVariant[] {
return variants.map(v => {
const weight = parseInt(v) || 400;
const style = v.includes('italic') ? 'italic' : 'normal';
return { weight, style, name: v };
});
}
function getGoogleFontPreviewUrl(family: string): string {
return `https://fonts.googleapis.com/css2?family=${encodeURIComponent(family)}:wght@400&display=swap`;
}
function getGoogleFontCssUrl(family: string, weight: string): string {
return `https://fonts.googleapis.com/css2?family=${encodeURIComponent(family)}:wght@${weight}&display=swap`;
}
```
#### Dynamic Font Loading
```typescript
// src/lib/utils/font-loader.ts
let loadedFonts = new Set<string>();
export async function loadFont(family: string, weight: number = 400): Promise<void> {
const fontKey = `${family}-${weight}`;
if (loadedFonts.has(fontKey)) {
return;
}
const cssUrl = `https://fonts.googleapis.com/css2?family=${encodeURIComponent(family)}:wght@${weight}&display=swap`;
try {
// Create and inject link element
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = cssUrl;
link.crossOrigin = 'anonymous';
await new Promise<void>((resolve, reject) => {
link.onload = () => {
loadedFonts.add(fontKey);
resolve();
};
link.onerror = () => reject(new Error(`Failed to load font: ${family}`));
document.head.appendChild(link);
});
} catch (error) {
console.error(`Failed to load font ${family}:`, error);
throw error;
}
}
export function unloadFont(family: string, weight: number = 400): void {
const cssUrl = `https://fonts.googleapis.com/css2?family=${encodeURIComponent(family)}:wght@${weight}&display=swap`;
const links = document.querySelectorAll(`link[href="${cssUrl}"]`);
links.forEach(link => link.remove());
loadedFonts.delete(`${family}-${weight}`);
}
export function preloadFonts(fonts: { family: string; weight: number }[]): void {
fonts.forEach(({ family, weight }) => {
const link = document.createElement('link');
link.rel = 'preload';
link.as = 'font';
link.type = 'font/woff2';
link.href = `https://fonts.gstatic.com/s/${family.toLowerCase()}/v${weight}`;
link.crossOrigin = 'anonymous';
document.head.appendChild(link);
});
}
```
---
### Fontshare Integration
#### Data Source
Fontshare provides a static JSON file with all font metadata:
```
https://api.fontshare.com/v2/css?f[]=<font-family-name>
```
Alternative: Use the GitHub repository data or scrape from the website.
#### TypeScript Implementation
```typescript
// src/lib/services/fontshare.ts
import type { FontMetadata } from '$lib/types';
export interface FontshareMetadata {
family: string;
slug: string;
styles: FontshareStyle[];
subsets: string[];
category: string;
}
export interface FontshareStyle {
name: string;
weight: number;
file: string;
unicodeRange?: string;
}
// Fontshare static font list (maintained manually or fetched from their docs)
const FONTHARE_FONTS: FontshareMetadata[] = [
{
family: 'Albert Sans',
slug: 'albert-sans',
styles: [
{ name: 'Regular', weight: 400, file: 'https://api.fontshare.com/v2/css?f[]=albert-sans@400' },
{ name: 'Medium', weight: 500, file: 'https://api.fontshare.com/v2/css?f[]=albert-sans@500' },
{ name: 'Bold', weight: 700, file: 'https://api.fontshare.com/v2/css?f[]=albert-sans@700' }
],
subsets: ['latin', 'latin-ext'],
category: 'sans-serif'
},
// Add more Fontshare fonts as needed...
];
export async function fetchFontshareFonts(): Promise<FontMetadata[]> {
// In a real implementation, you might fetch this from a maintained JSON file
// For MVP, we use the static list or fetch from Fontshare's API
// Option 1: Use static list
return FONTHARE_FONTS.map(mapFontshareToMetadata);
// Option 2: Fetch from Fontshare API (if available)
// const response = await fetch('https://api.fontshare.com/v2/fonts');
// const data = await response.json();
// return data.map(mapFontshareToMetadata);
}
function mapFontshareToMetadata(font: FontshareMetadata): FontMetadata {
return {
id: `fontshare-${font.slug}`,
provider: 'fontshare',
family: font.family,
displayName: font.family,
category: mapFontshareCategory(font.category),
variants: font.styles.map(style => ({
weight: style.weight,
style: 'normal',
name: style.name,
url: style.file
})),
license: {
type: 'open-source',
url: 'https://www.fontshare.com/license',
attribution: 'Fontshare',
commercial: true,
embedding: 'webfont'
},
languages: font.subsets,
previewUrl: font.styles[0]?.file || '',
cssUrl: font.styles[0]?.file || '',
metadata: {
slug: font.slug,
styles: font.styles
}
};
}
function mapFontshareCategory(category: string): FontCategory {
const validCategories: FontCategory[] = ['sans-serif', 'serif', 'display', 'handwriting', 'monospace'];
const normalized = category.toLowerCase().replace(' ', '-');
return validCategories.includes(normalized as FontCategory)
? (normalized as FontCategory)
: 'display';
}
```
---
### Data Normalization
Both providers return different data structures. Normalize to a unified interface:
```typescript
// src/lib/types/fonts.ts
export type FontCategory = 'sans-serif' | 'serif' | 'display' | 'handwriting' | 'monospace';
export type FontProvider = 'google-fonts' | 'fontshare';
export interface LicenseInfo {
type: 'open-source' | 'commercial' | 'free-for-personal';
url: string;
attribution: string;
commercial: boolean;
embedding: 'webfont' | 'self-host' | 'both';
}
export interface FontVariant {
weight: number;
style: 'normal' | 'italic';
name: string;
url?: string;
}
export interface FontMetadata {
id: string;
provider: FontProvider;
family: string;
displayName: string;
category: FontCategory;
variants: FontVariant[];
license: LicenseInfo;
languages: string[];
previewUrl: string;
cssUrl: string;
popularity?: number; // For sorting
trending?: boolean;
metadata: Record<string, unknown>;
}
export interface ComparisonFont {
font: FontMetadata;
variant: FontVariant;
size: number;
lineHeight: number;
letterSpacing: number;
color: string;
text?: string; // Override default preview text
}
export interface ComparisonSettings {
text: string;
backgroundColor: string;
showGrid: boolean;
watermark: boolean;
}
```
---
## Performance Optimization
### Lazy Loading Strategy
#### Font Lazy Loading
```typescript
// src/lib/hooks/useLazyFontLoader.ts
import { tick } from 'svelte';
import { loadFont } from '$lib/utils/font-loader';
export function useLazyFontLoader() {
let observer: IntersectionObserver | null = null;
let elementsToLoad = new Map<HTMLElement, { family: string; weight: number }>();
function observe(
element: HTMLElement,
family: string,
weight: number = 400
) {
if (!observer) {
observer = new IntersectionObserver(
(entries) => {
entries.forEach(async (entry) => {
if (entry.isIntersecting) {
const data = elementsToLoad.get(entry.target as HTMLElement);
if (data) {
await loadFont(data.family, data.weight);
observer?.unobserve(entry.target);
elementsToLoad.delete(entry.target as HTMLElement);
}
}
});
},
{
rootMargin: '200px', // Load before element enters viewport
threshold: 0.01
}
);
}
elementsToLoad.set(element, { family, weight });
observer.observe(element);
}
function destroy() {
observer?.disconnect();
observer = null;
elementsToLoad.clear();
}
return { observe, destroy };
}
```
#### Component-Level Usage
```svelte
<!-- src/lib/components/FontPreview.svelte -->
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { useLazyFontLoader } from '$lib/hooks/useLazyFontLoader';
export let family: string;
export let weight: number = 400;
export let text: string = 'The quick brown fox jumps over the lazy dog';
let element: HTMLElement;
const { observe, destroy } = useLazyFontLoader();
onMount(() => {
observe(element, family, weight);
});
onDestroy(() => {
destroy();
});
</script>
<div
bind:this={element}
class="font-preview"
style:font-family="{family}"
style:font-weight="{weight}"
>
{text}
</div>
<style>
.font-preview {
/* Ensure element has height for intersection observer */
min-height: 48px;
}
</style>
```
### Caching Strategy
#### localStorage Caching with TTL
```typescript
// src/lib/utils/cache.ts
const CACHE_KEY = 'glyphdiff:font-cache';
const CACHE_VERSION = 1;
const CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours
interface CacheEntry {
version: number;
timestamp: number;
data: unknown;
}
export function setCache<T>(key: string, data: T, ttl: number = CACHE_TTL): void {
const entry: CacheEntry = {
version: CACHE_VERSION,
timestamp: Date.now(),
data
};
const cache = getCacheData();
cache[key] = entry;
try {
localStorage.setItem(CACHE_KEY, JSON.stringify(cache));
} catch (error) {
console.warn('Failed to set cache:', error);
}
}
export function getCache<T>(key: string): T | null {
const cache = getCacheData();
const entry = cache[key];
if (!entry) {
return null;
}
// Check version
if (entry.version !== CACHE_VERSION) {
delete cache[key];
saveCacheData(cache);
return null;
}
// Check TTL
if (Date.now() - entry.timestamp > CACHE_TTL) {
delete cache[key];
saveCacheData(cache);
return null;
}
return entry.data as T;
}
export function clearCache(key?: string): void {
if (key) {
const cache = getCacheData();
delete cache[key];
saveCacheData(cache);
} else {
localStorage.removeItem(CACHE_KEY);
}
}
function getCacheData(): Record<string, CacheEntry> {
try {
const cached = localStorage.getItem(CACHE_KEY);
return cached ? JSON.parse(cached) : {};
} catch {
return {};
}
}
function saveCacheData(data: Record<string, CacheEntry>): void {
try {
localStorage.setItem(CACHE_KEY, JSON.stringify(data));
} catch (error) {
console.warn('Failed to save cache:', error);
}
}
```
#### Service Worker Caching (Optional for Enhanced PWA)
```typescript
// src/lib/service-worker.ts
const CACHE_NAME = 'glyphdiff-v1';
const FONT_CACHE_PREFIX = 'font-';
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll([
'/',
'/fonts',
'/compare',
// Add other static assets
]);
})
);
});
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
// Cache Google Fonts
if (url.hostname === 'fonts.googleapis.com' || url.hostname === 'fonts.gstatic.com') {
event.respondWith(
caches.open(`${FONT_CACHE_PREFIX}${CACHE_NAME}`).then((cache) => {
return cache.match(event.request).then((response) => {
if (response) {
return response;
}
return fetch(event.request).then((networkResponse) => {
cache.put(event.request, networkResponse.clone());
return networkResponse;
});
});
})
);
return;
}
// Network-first for API calls
if (url.pathname.includes('/api/')) {
event.respondWith(
fetch(event.request)
.then((response) => {
const responseClone = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseClone);
});
return response;
})
.catch(() => caches.match(event.request))
);
return;
}
// Cache-first for static assets
event.respondWith(
caches.match(event.request).then((response) => {
return response || fetch(event.request);
})
);
});
```
### Performance Metrics
#### Target Core Web Vitals
| Metric | Target | Why It Matters |
|--------|--------|----------------|
| LCP (Largest Contentful Paint) | < 2.5s | Main content loads quickly |
| FID (First Input Delay) | < 100ms | Responsive to user input |
| CLS (Cumulative Layout Shift) | < 0.1 | Visual stability |
| TTFB (Time to First Byte) | < 600ms | Fast initial response |
#### Performance Monitoring
```typescript
// src/lib/utils/performance.ts
export function reportWebVitals(): void {
if (typeof window === 'undefined' || !('PerformanceObserver' in window)) {
return;
}
// LCP
const lcpObserver = new PerformanceObserver((list) => {
const entries = list.getEntries();
const lastEntry = entries[entries.length - 1] as any;
console.log('LCP:', lastEntry.startTime);
// Send to analytics
});
lcpObserver.observe({ entryTypes: ['largest-contentful-paint'] });
// FID
const fidObserver = new PerformanceObserver((list) => {
const entries = list.getEntries();
const fid = entries[0] as any;
console.log('FID:', fid.processingStart - fid.startTime);
// Send to analytics
});
fidObserver.observe({ entryTypes: ['first-input'] });
// CLS
const clsObserver = new PerformanceObserver((list) => {
let clsValue = 0;
for (const entry of list.getEntries() as any[]) {
if (!entry.hadRecentInput) {
clsValue += entry.value;
}
}
console.log('CLS:', clsValue);
// Send to analytics
});
clsObserver.observe({ entryTypes: ['layout-shift'] });
}
```
---
## Frontend Architecture
### Project Structure
```
glyphdiff/
├── src/
│ ├── lib/
│ │ ├── components/
│ │ │ ├── FontCard.svelte
│ │ │ ├── FontPreview.svelte
│ │ │ ├── ComparisonGrid.svelte
│ │ │ ├── FilterBar.svelte
│ │ │ └── DarkModeToggle.svelte
│ │ ├── stores/
│ │ │ ├── fontStore.ts
│ │ │ ├── comparisonStore.ts
│ │ │ ├── filterStore.ts
│ │ │ └── uiStore.ts
│ │ ├── services/
│ │ │ ├── google-fonts.ts
│ │ │ ├── fontshare.ts
│ │ │ └── font-loader.ts
│ │ ├── hooks/
│ │ │ ├── useLazyFontLoader.ts
│ │ │ ├── useKeyboardShortcuts.ts
│ │ │ └── useLocalStorage.ts
│ │ ├── utils/
│ │ │ ├── cache.ts
│ │ │ ├── format.ts
│ │ │ └── url.ts
│ │ ├── types/
│ │ │ ├── fonts.ts
│ │ │ ├── comparison.ts
│ │ │ └── index.ts
│ │ └── constants.ts
│ ├── routes/
│ │ ├── +layout.svelte
│ │ ├── +layout.ts
│ │ ├── +page.svelte # Home page
│ │ ├── fonts/
│ │ │ ├── +page.svelte # Font catalog
│ │ │ ├── +page.ts # Font data loading
│ │ │ └── [slug]/
│ │ │ └── +page.svelte # Font detail page
│ │ └── compare/
│ │ ├── +page.svelte # Comparison page
│ │ └── +page.ts # URL state parsing
│ ├── app.css
│ └── app.d.ts
├── static/
│ ├── favicon.ico
│ └── og-image.png
├── tests/
│ ├── unit/
│ └── e2e/
├── package.json
├── svelte.config.js
├── tailwind.config.js
├── tsconfig.json
└── vite.config.ts
```
### Component Architecture
```
┌────────────────────────────────────────────────────────────┐
│ +layout.svelte │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Header │ │
│ │ Logo | Search | DarkModeToggle │ │
│ └──────────────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Main │ │
│ │ ┌────────────────────────────────────────────────┐ │ │
│ │ │ +page.svelte (Home) │ │ │
│ │ │ Hero section → Featured fonts → CTA │ │ │
│ │ └────────────────────────────────────────────────┘ │ │
│ │ ┌────────────────────────────────────────────────┐ │ │
│ │ │ fonts/+page.svelte │ │ │
│ │ │ FilterBar → FontGrid → FontCard │ │ │
│ │ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │ │
│ │ │ │FontCard │ │FontCard │ │FontCard │ ... │ │ │
│ │ │ └─────────┘ └─────────┘ └─────────┘ │ │ │
│ │ └────────────────────────────────────────────────┘ │ │
│ │ ┌────────────────────────────────────────────────┐ │ │
│ │ │ compare/+page.svelte │ │ │
│ │ │ ComparisonGrid → FontPreview × 4 │ │ │
│ │ │ Toolbar (text, size, color, settings) │ │ │
│ │ └────────────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Footer │ │
│ └──────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────┘
```
### Routing Strategy
#### SvelteKit File-Based Routing
| Route | File | Purpose | SSR/CSR |
|-------|------|---------|---------|
| `/` | `routes/+page.svelte` | Home/Landing page | SSR |
| `/fonts` | `routes/fonts/+page.svelte` | Font catalog | SSG |
| `/fonts/[slug]` | `routes/fonts/[slug]/+page.svelte` | Font details | SSG |
| `/compare` | `routes/compare/+page.svelte` | Comparison tool | CSR |
| `/api/...` | `routes/api/+server.ts` | Future API routes | Serverless |
#### URL State for Comparisons
```typescript
// src/routes/compare/+page.ts
import type { PageLoad } from './$types';
export const load: PageLoad = ({ url }) => {
const searchParams = url.searchParams;
// Parse comparison state from URL
const fontsParam = searchParams.get('fonts');
const text = searchParams.get('text') || 'The quick brown fox...';
const size = parseInt(searchParams.get('size') || '24');
const darkMode = searchParams.get('dark') === 'true';
let comparisonFonts: Array<{ family: string; weight: number }> = [];
if (fontsParam) {
try {
comparisonFonts = JSON.parse(decodeURIComponent(fontsParam));
} catch (e) {
console.error('Failed to parse fonts param:', e);
}
}
return {
initialFonts: comparisonFonts,
settings: { text, size, darkMode }
};
};
```
```svelte
<!-- src/routes/compare/+page.svelte -->
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { comparisonStore } from '$lib/stores/comparisonStore';
import type { PageData } from './$types';
export let data: PageData;
// Initialize store from URL
$: comparisonStore.setSettings(data.settings);
$: comparisonStore.setFonts(data.initialFonts);
// Sync URL changes
$: {
if (comparisonStore.shareable) {
const url = new URL(window.location.href);
url.searchParams.set(
'fonts',
JSON.stringify(comparisonStore.fonts.map(f => ({ family: f.font.family, weight: f.variant.weight })))
);
url.searchParams.set('text', comparisonStore.settings.text);
url.searchParams.set('size', comparisonStore.settings.size.toString());
// Only update URL without full navigation
window.history.replaceState({}, '', url.toString());
}
}
</script>
```
---
## Data Models
### Core Type Definitions
```typescript
// src/lib/types/fonts.ts
/**
* Font provider sources
*/
export type FontProvider = 'google-fonts' | 'fontshare';
/**
* Standard font categories
*/
export type FontCategory =
| 'sans-serif'
| 'serif'
| 'display'
| 'handwriting'
| 'monospace';
/**
* License information for fonts
*/
export interface LicenseInfo {
/** License type classification */
type: 'open-source' | 'commercial' | 'free-for-personal';
/** URL to full license text */
url: string;
/** Attribution required for display */
attribution: string;
/** Commercial use permitted */
commercial: boolean;
/** Allowed embedding methods */
embedding: 'webfont' | 'self-host' | 'both';
}
/**
* Individual font variant (weight/style combination)
*/
export interface FontVariant {
/** Font weight (100-900) */
weight: number;
/** Font style */
style: 'normal' | 'italic';
/** Display name for the variant */
name: string;
/** URL to the font file (CSS or WOFF2) */
url?: string;
/** Subset information (optional) */
subset?: string;
}
/**
* Complete metadata for a font family
*/
export interface FontMetadata {
/** Unique identifier for the font */
id: string;
/** Source provider */
provider: FontProvider;
/** Font family name (CSS property value) */
family: string;
/** Display name for UI */
displayName: string;
/** Font category */
category: FontCategory;
/** Available variants */
variants: FontVariant[];
/** License information */
license: LicenseInfo;
/** Supported language subsets */
languages: string[];
/** URL for font preview (CSS) */
previewUrl: string;
/** URL for CSS import */
cssUrl: string;
/** Popularity score (for sorting) */
popularity?: number;
/** Trending status */
trending?: boolean;
/** Additional provider-specific metadata */
metadata: Record<string, unknown>;
}
/**
* Font with selected variant for display
*/
export interface ActiveFont {
/** Font metadata */
font: FontMetadata;
/** Selected variant */
variant: FontVariant;
}
/**
* Filter options for font catalog
*/
export interface FontFilters {
/** Category filter */
categories: FontCategory[];
/** Weight range filter */
weightRange: [number, number];
/** Provider filter */
providers: FontProvider[];
/** Language subset filter */
languages: string[];
/** Search query */
search: string;
/** Sort option */
sortBy: 'popularity' | 'name' | 'recent' | 'trending';
/** Sort direction */
sortDirection: 'asc' | 'desc';
}
```
```typescript
// src/lib/types/comparison.ts
import type { FontMetadata, FontVariant } from './fonts';
/**
* Display settings for comparison preview
*/
export interface FontDisplaySettings {
/** Font size in pixels */
size: number;
/** Line height (unitless multiplier) */
lineHeight: number;
/** Letter spacing in pixels */
letterSpacing: number;
/** Text color */
color: string;
/** Custom preview text override */
text?: string;
/** Text alignment */
textAlign: 'left' | 'center' | 'right';
}
/**
* Global comparison settings
*/
export interface ComparisonSettings {
/** Default preview text */
text: string;
/** Background color */
backgroundColor: string;
/** Show comparison grid lines */
showGrid: boolean;
/** Show watermarks/attribution */
watermark: boolean;
/** Default display settings */
display: FontDisplaySettings;
}
/**
* Font in a comparison session
*/
export interface ComparisonFont {
/** Font metadata */
font: FontMetadata;
/** Selected variant */
variant: FontVariant;
/** Display settings (overrides global) */
settings: Partial<FontDisplaySettings>;
}
/**
* Complete comparison session
*/
export interface ComparisonSession {
/** Unique session identifier */
id: string;
/** Fonts being compared (max 4) */
fonts: ComparisonFont[];
/** Global comparison settings */
settings: ComparisonSettings;
/** Session metadata */
metadata: {
/** Creation timestamp */
createdAt: number;
/** Last modified timestamp */
updatedAt: number;
/** Session name */
name?: string;
/** Whether session is saved */
saved: boolean;
};
}
/**
* Shareable comparison data (for URL encoding)
*/
export interface ShareableComparison {
/** Font identifiers and weights */
fonts: Array<{ family: string; weight: number }>;
/** Preview text */
text?: string;
/** Font size */
size?: number;
/** Dark mode flag */
dark?: boolean;
}
```
```typescript
// src/lib/types/ui.ts
/**
* UI theme options
*/
export type Theme = 'light' | 'dark' | 'system';
/**
* UI settings persisted in localStorage
*/
export interface UISettings {
/** Selected theme */
theme: Theme;
/** Grid view density */
gridDensity: 'compact' | 'comfortable' | 'spacious';
/** Show font variants in catalog */
showVariants: boolean;
/** Enable animations */
animationsEnabled: boolean;
}
/**
* Toast notification types
*/
export type ToastType = 'success' | 'error' | 'info' | 'warning';
/**
* Toast notification
*/
export interface Toast {
id: string;
type: ToastType;
message: string;
duration?: number;
action?: {
label: string;
handler: () => void;
};
}
/**
* Keyboard shortcut definition
*/
export interface KeyboardShortcut {
key: string;
ctrl?: boolean;
shift?: boolean;
alt?: boolean;
description: string;
action: () => void;
}
/**
* Modal/Dialog state
*/
export interface ModalState {
id: string | null;
open: boolean;
props?: Record<string, unknown>;
}
```
```typescript
// src/lib/types/index.ts
// Re-export all types for convenient importing
export * from './fonts';
export * from './comparison';
export * from './ui';
```
---
## Implementation Roadmap
### Week 1: Svelte Fundamentals
**Goal:** Build foundational understanding of Svelte 5 reactive primitives and set up development environment.
#### Tasks
- [ ] **Project Setup**
- [ ] Create SvelteKit project with TypeScript template
- [ ] Install and configure Tailwind CSS 4.x
- [ ] Install and set up Bits UI component library
- [ ] Configure Prettier and ESLint with Svelte plugin
- [ ] Set up Git repository and .gitignore
- [ ] **Environment Configuration**
- [ ] Create `.env.example` with GOOGLE_FONTS_API_KEY
- [ ] Add `.env` to `.gitignore`
- [ ] Configure environment variable loading in `vite.config.ts`
- [ ] **Core Types Definition**
- [ ] Create `src/lib/types/fonts.ts`
- [ ] Create `src/lib/types/comparison.ts`
- [ ] Create `src/lib/types/ui.ts`
- [ ] Create `src/lib/types/index.ts` for re-exports
- [ ] **Svelte 5 Reactive Primitives Practice**
- [ ] Build Counter component using `$state`
- [ ] Create DerivedCounter component using `$derived`
- [ ] Implement EffectLogger component using `$effect`
- [ ] Practice with reactive arrays and objects
- [ ] **Practice Components**
- [ ] Build simple Button component
- [ ] Create Toggle component
- [ ] Implement Select dropdown component
- [ ] Build simple FilterBar component
#### Learning Outcomes
- Understand Svelte 5's new runes system (`$state`, `$derived`, `$effect`)
- Comfortable with Svelte component structure and syntax
- Familiar with Bits UI components
- Tailwind CSS integration working
#### Week 1 Deliverables
- Working development environment
- 4 practice components demonstrating Svelte reactivity
- Core TypeScript types defined
---
### Week 2: SvelteKit & Routing
**Goal:** Build font catalog page with routing, data fetching, and understand SvelteKit's SSR capabilities.
#### Tasks
- [ ] **Google Fonts Service**
- [ ] Create `fetchGoogleFonts()` function
- [ ] Implement `mapGoogleFontToMetadata()` mapper
- [ ] Add TypeScript interfaces for Google Fonts API response
- [ ] Test with Google Fonts API key
- [ ] **Fontshare Service**
- [ ] Create `fetchFontshareFonts()` function
- [ ] Build static font list for Fontshare
- [ ] Implement `mapFontshareToMetadata()` mapper
- [ ] Combine both providers into single font catalog
- [ ] **Font Catalog Page**
- [ ] Create `routes/fonts/+page.svelte`
- [ ] Implement `routes/fonts/+page.ts` for data loading
- [ ] Build FontCard component
- [ ] Create responsive FontGrid layout
- [ ] **Font Detail Page**
- [ ] Create `routes/fonts/[slug]/+page.svelte`
- [ ] Implement dynamic route parameter extraction
- [ ] Build font detail view with all variants
- [ ] Add "Add to Comparison" button
- [ ] **Comparison Page Skeleton**
- [ ] Create `routes/compare/+page.svelte`
- [ ] Implement URL state parsing in `+page.ts`
- [ ] Build basic layout for comparison grid
- [ ] **SSR Understanding**
- [ ] Explore SvelteKit rendering modes (SSR, CSR, SSG)
- [ ] Test page performance with SSR enabled
- [ ] Understand hydration process
- [ ] Configure static generation for font catalog
#### Learning Outcomes
- Understand SvelteKit file-based routing
- Learn data loading patterns (`+page.ts`, `+page.server.ts`)
- Grasp SSR vs CSR concepts and trade-offs
- Comfortable with dynamic routes and parameters
#### Week 2 Deliverables
- Working font catalog page with Google Fonts data
- Font detail pages with all metadata
- Basic comparison page with URL state
- Understanding of SvelteKit rendering modes
---
### Week 3: State Management
**Goal:** Implement comprehensive state management with Svelte stores, localStorage persistence, and URL state synchronization.
#### Tasks
- [ ] **Font Store**
- [ ] Create `src/lib/stores/fontStore.ts`
- [ ] Implement `$state` for fonts array
- [ ] Add derived state for filtered fonts
- [ ] Implement localStorage persistence
- [ ] Add loading and error states
- [ ] **Filter Store**
- [ ] Create `src/lib/stores/filterStore.ts`
- [ ] Implement `$state` for all filter options
- [ ] Add derived state for active filter count
- [ ] Implement reset filters function
- [ ] Add localStorage persistence
- [ ] **Comparison Store**
- [ ] Create `src/lib/stores/comparisonStore.ts`
- [ ] Implement `$state` for comparison fonts (max 4)
- [ ] Add functions to add/remove fonts
- [ ] Implement comparison settings state
- [ ] Add shareable URL generation
- [ ] Implement localStorage persistence
- [ ] **UI Store**
- [ ] Create `src/lib/stores/uiStore.ts`
- [ ] Implement theme toggle logic
- [ ] Add settings for grid density, animations
- [ ] Implement system theme detection
- [ ] Add localStorage persistence
- [ ] **Cache Utility**
- [ ] Implement `cache.ts` with TTL support
- [ ] Add caching to Google Fonts fetcher
- [ ] Implement cache invalidation logic
- [ ] Add cache management UI (optional)
- [ ] **URL State Integration**
- [ ] Sync comparison state with URL
- [ ] Sync filter state with URL (optional)
- [ ] Implement deep linking to comparisons
- [ ] Handle URL encoding/decoding
#### Learning Outcomes
- Master Svelte 5 stores with `$state` runes
- Understand localStorage persistence patterns
- Learn URL state synchronization
- Practice derived state and computed values
#### Week 3 Deliverables
- Fully functional stores for fonts, filters, comparisons, UI
- localStorage persistence working
- URL state synchronization working
- Caching layer implemented
---
### Week 4: UI & Polish
**Goal:** Build polished UI with Bits UI components, dark mode, responsive design, and smooth animations.
#### Tasks
- [ ] **Layout Components**
- [ ] Build Header component with logo and navigation
- [ ] Create Footer component with links and attribution
- [ ] Implement global layout in `+layout.svelte`
- [ ] Add mobile navigation menu
- [ ] **FilterBar Component**
- [ ] Use Bits UI Select for category filter
- [ ] Use Bits UI Dialog for advanced filters
- [ ] Add search input with debouncing
- [ ] Implement active filter indicators
- [ ] **ComparisonGrid Component**
- [ ] Use Bits UI Grid for responsive layout
- [ ] Implement drag-and-drop reordering (optional)
- [ ] Add font settings panel per column
- [ ] Implement "Remove font" button
- [ ] **FontPreview Component**
- [ ] Build preview text area
- [ ] Add size, weight, line-height controls
- [ ] Implement color picker
- [ ] Add lazy loading for fonts
- [ ] **Dark Mode**
- [ ] Implement dark mode toggle
- [ ] Configure Tailwind dark mode classes
- [ ] Add system preference detection
- [ ] Test color contrast in both themes
- [ ] **Responsive Design**
- [ ] Test on mobile (375px)
- [ ] Test on tablet (768px)
- [ ] Test on desktop (1280px+)
- [ ] Adjust grid columns for breakpoints
- [ ] **Animations & Transitions**
- [ ] Add page transitions using Svelte transitions
- [ ] Implement loading skeletons
- [ ] Add hover effects on FontCard
- [ ] Create toast notification system
- [ ] **Accessibility**
- [ ] Add ARIA labels to interactive elements
- [ ] Ensure keyboard navigation works
- [ ] Test with screen reader
- [ ] Verify color contrast ratios
- [ ] **Performance Optimization**
- [ ] Implement image/font lazy loading
- [ ] Add code splitting where appropriate
- [ ] Test Lighthouse scores
- [ ] Optimize bundle size
#### Learning Outcomes
- Proficient with Bits UI component library
- Comfortable building responsive layouts
- Understand accessibility best practices
- Experience with performance optimization
#### Week 4 Deliverables
- Polished, production-ready UI
- Dark mode fully implemented
- Responsive design across all breakpoints
- Accessibility improvements completed
- Performance targets met
---
## TanStack Query Research
### What is TanStack Query?
TanStack Query (formerly React Query) is a powerful data synchronization library for fetching, caching, and managing asynchronous state. It's widely used in the React ecosystem.
### Key Features
- **Automatic caching and refetching**
- **Loading and error state management**
- **Optimistic updates**
- **Pagination support**
- **Request deduplication**
### TanStack Query for Svelte?
TanStack Query has a Svelte implementation (`@tanstack/svelte-query`) that provides similar functionality to the React version.
### Why TanStack Query is NOT Recommended for MVP
| Factor | TanStack Query | SvelteKit Load | Recommendation |
|--------|----------------|----------------|----------------|
| **Data Source** | Client-side API calls | Server-side data loading | SvelteKit Load is sufficient |
| **Complexity** | Additional dependency | Built-in to framework | SvelteKit reduces complexity |
| **SSR Support** | Requires hydration | Native SSR | SvelteKit is cleaner |
| **Learning Curve** | Additional concepts to learn | Learn SvelteKit once | Focus on SvelteKit |
| **Bundle Size** | ~13KB gzipped | 0KB (built-in) | Smaller bundle |
| **Type Safety** | Good | Excellent | SvelteKit with TS is robust |
| **Caching Strategy** | In-memory cache | Static generation + CDN | SSG is more performant |
### Use Cases Where TanStack Query Would Be Useful
Consider adding TanStack Query in the future if:
- Implementing user authentication and profile data
- Building real-time features (WebSocket, polling)
- Complex pagination with infinite scroll
- Multiple API endpoints requiring complex cache invalidation
- Offline-first functionality
### Recommended Data Fetching Approach for MVP
#### Use SvelteKit's Data Loading
```typescript
// ✅ Recommended: SvelteKit load function
// src/routes/fonts/+page.ts
import { fetchGoogleFonts, fetchFontshareFonts } from '$lib/services';
import { setCache, getCache } from '$lib/utils/cache';
export const load = async ({ fetch, depends }) => {
// Check cache first
const cached = getCache('font-catalog');
if (cached) {
return { fonts: cached, cached: true };
}
// Fetch from providers
const [googleFonts, fontshareFonts] = await Promise.all([
fetchGoogleFonts(import.meta.env.VITE_GOOGLE_FONTS_API_KEY),
fetchFontshareFonts()
]);
const allFonts = [...googleFonts, ...fontshareFonts];
// Cache for 24 hours
setCache('font-catalog', allFonts);
// Invalidate cache when needed
depends('app:fonts');
return { fonts: allFonts, cached: false };
};
```
#### Use Svelte Stores for Client-Side State
```typescript
// ✅ Recommended: Svelte 5 stores with $state
// src/lib/stores/fontStore.ts
import { setCache, getCache } from '$lib/utils/cache';
class FontStore {
fonts = $state<FontMetadata[]>([]);
loading = $state(false);
error = $state<Error | null>(null);
async loadFonts(apiKey: string) {
this.loading = true;
this.error = null;
try {
const cached = getCache('font-catalog');
if (cached) {
this.fonts = cached;
return;
}
const [googleFonts, fontshareFonts] = await Promise.all([
fetchGoogleFonts(apiKey),
fetchFontshareFonts()
]);
this.fonts = [...googleFonts, ...fontshareFonts];
setCache('font-catalog', this.fonts);
} catch (e) {
this.error = e as Error;
} finally {
this.loading = false;
}
}
}
export const fontStore = new FontStore();
```
### Conclusion: Do NOT Use TanStack Query for MVP
**Reasoning:**
1. SvelteKit's data loading capabilities are sufficient for this project's needs
2. Focus on learning Svelte 5 and SvelteKit rather than adding more dependencies
3. Static generation provides better performance than client-side caching
4. Simpler codebase is easier to maintain while learning
**Future Consideration:**
Revisit this decision if the project grows to include:
- User authentication
- Real-time features
- Complex client-side data synchronization
---
## Learning Guide: React → Svelte
### React vs Svelte 5 Comparison
| React Concept | Svelte 5 Equivalent | Notes |
|--------------|---------------------|-------|
| `useState` | `$state` | No setter function needed, direct reassignment triggers reactivity |
| `useEffect` | `$effect` | Auto-tracks dependencies, no dependency array needed |
| `useMemo` | `$derived` | Computed values that automatically update |
| `useCallback` | Direct function | Functions are auto-memoized in Svelte 5 |
| `children` prop | `<slot />` | Slot composition with named slots |
| `props` | `export let` | Props declared with `export let propName` |
| `context` | `setContext` / `getContext` | Similar but with cleaner API |
| `useRef` | `bind:this` | Direct element binding instead of ref objects |
| `useState(prev => prev + 1)` | `count += 1` | Direct mutation works with $state |
| `useReducer` | Custom store with methods | Svelte stores are more flexible |
| `forwardRef` | N/A | Ref forwarding handled automatically |
| `useImperativeHandle` | N/A | Call methods directly on component instances |
### Code Examples
#### Counter Component
**React:**
```tsx
import { useState, useEffect } from 'react';
function Counter({ initial = 0 }: { initial?: number }) {
const [count, setCount] = useState(initial);
const [doubled, setDoubled] = useState(0);
useEffect(() => {
setDoubled(count * 2);
}, [count]);
return (
<div>
<h2>Count: {count}</h2>
<h3>Doubled: {doubled}</h3>
<button onClick={() => setCount(c => c + 1)}>Increment</button>
<button onClick={() => setCount(c => c - 1)}>Decrement</button>
</div>
);
}
```
**Svelte 5:**
```svelte
<script lang="ts">
export let initial = 0;
let count = $state(initial);
// $derived auto-updates when count changes
let doubled = $derived(count * 2);
function increment() {
count += 1;
}
function decrement() {
count -= 1;
}
</script>
<div>
<h2>Count: {count}</h2>
<h3>Doubled: {doubled}</h3>
<button onclick={increment}>Increment</button>
<button onclick={decrement}>Decrement</button>
</div>
```
**Key Differences:**
- No setter function needed - direct mutation works with `$state`
- `$derived` eliminates need for `useEffect` with dependencies
- Functions are defined separately (no need for `useCallback`)
- Event handlers use lowercase `onclick` instead of `onClick`
---
#### Data Fetching
**React with React Query:**
```tsx
import { useQuery } from '@tanstack/react-query';
function FontList() {
const {
data: fonts,
isLoading,
error
} = useQuery({
queryKey: ['fonts'],
queryFn: async () => {
const response = await fetch('/api/fonts');
if (!response.ok) throw new Error('Failed to fetch');
return response.json();
}
});
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<ul>
{fonts.map((font: any) => (
<li key={font.id}>{font.displayName}</li>
))}
</ul>
);
}
```
**SvelteKit with load function:**
```typescript
// +page.ts
export const load = async ({ fetch }) => {
const response = await fetch('https://www.googleapis.com/webfonts/v1/webfonts?key=' + import.meta.env.VITE_GOOGLE_FONTS_API_KEY);
const fonts = await response.json();
return { fonts };
};
```
```svelte
<!-- +page.svelte -->
<script lang="ts">
export let data;
// data.fonts is available here, loaded on the server
</script>
{#if data.fonts}
<ul>
{#each data.fonts as font}
<li>{font.family}</li>
{/each}
</ul>
{/if}
```
**Key Differences:**
- SvelteKit loads data on the server (SSR) by default
- No loading state needed for initial page load
- Data flows from `load` function to page component via props
---
#### State Management
**React with Zustand:**
```ts
// store.ts
import { create } from 'zustand';
interface FontStore {
fonts: FontMetadata[];
selectedFont: string | null;
setFonts: (fonts: FontMetadata[]) => void;
selectFont: (id: string) => void;
}
export const useFontStore = create<FontStore>((set) => ({
fonts: [],
selectedFont: null,
setFonts: (fonts) => set({ fonts }),
selectFont: (id) => set({ selectedFont: id })
}));
```
```tsx
// Component.tsx
import { useFontStore } from './store';
function FontSelector() {
const { fonts, selectedFont, selectFont } = useFontStore();
return (
<select
value={selectedFont || ''}
onChange={(e) => selectFont(e.target.value)}
>
<option value="">Select a font</option>
{fonts.map((font) => (
<option key={font.id} value={font.id}>
{font.displayName}
</option>
))}
</select>
);
}
```
**Svelte with Store:**
```ts
// fontStore.ts
import type { FontMetadata } from '$lib/types';
class FontStore {
fonts = $state<FontMetadata[]>([]);
selectedFont = $state<string | null>(null);
setFonts(fonts: FontMetadata[]) {
this.fonts = fonts;
}
selectFont(id: string) {
this.selectedFont = id;
}
}
export const fontStore = new FontStore();
```
```svelte
<!-- FontSelector.svelte -->
<script lang="ts">
import { fontStore } from '$lib/stores/fontStore';
</script>
<select bind:value={fontStore.selectedFont}>
<option value="">Select a font</option>
{#each fontStore.fonts as font}
<option value={font.id}>
{font.displayName}
</option>
{/each}
</select>
```
**Key Differences:**
- Svelte stores use `$state` directly, no need for immer
- Two-way binding with `bind:value` instead of `onChange`
- Store access is simpler - just import and use
---
#### Conditional Rendering
**React:**
```tsx
function ConditionalRender({ fonts, loading }: { fonts: Font[]; loading: boolean }) {
if (loading) {
return <Spinner />;
}
if (fonts.length === 0) {
return <EmptyState message="No fonts found" />;
}
return (
<div>
{fonts.map(font => (
<FontCard key={font.id} font={font} />
))}
</div>
);
}
```
**Svelte:**
```svelte
<script lang="ts">
export let fonts: Font[];
export let loading: boolean;
</script>
{#if loading}
<Spinner />
{:else if fonts.length === 0}
<EmptyState message="No fonts found" />
{:else}
<div>
{#each fonts as font}
<FontCard {font} />
{/each}
</div>
{/if}
```
**Key Differences:**
- Svelte uses `{#if}`, `{:else if}`, `{:else}`, `{/if}` block syntax
- No ternary operator needed inside JSX
- `{#each}` block with `{/each}` for lists
---
#### Component Props
**React:**
```tsx
interface FontCardProps {
font: FontMetadata;
onClick?: (font: FontMetadata) => void;
size?: number;
showLicense?: boolean;
}
function FontCard({ font, onClick, size = 16, showLicense = false }: FontCardProps) {
return (
<div className="font-card" style={{ fontSize: size }}>
<h3>{font.displayName}</h3>
{showLicense && <LicenseInfo license={font.license} />}
{onClick && <button onClick={() => onClick(font)}>Select</button>}
</div>
);
}
```
**Svelte:**
```svelte
<script lang="ts">
import type { FontMetadata } from '$lib/types';
export let font: FontMetadata;
export let onClick: (font: FontMetadata) => void | undefined;
export let size = 16;
export let showLicense = false;
</script>
<div class="font-card" style:font-size="{size}px">
<h3>{font.displayName}</h3>
{#if showLicense}
<LicenseInfo license={font.license} />
{/if}
{#if onClick}
<button onclick={() => onClick(font)}>Select</button>
{/if}
</div>
```
**Key Differences:**
- Props declared with `export let` instead of function parameters
- Default values assigned with `=` in declaration
- Event handlers use lowercase (`onclick`)
- Props passed as attributes without curly braces for simple values
---
#### Slot/Children Composition
**React:**
```tsx
// Modal.tsx
function Modal({ children, title, footer }: {
children: React.ReactNode;
title: string;
footer?: React.ReactNode;
}) {
return (
<div className="modal">
<header>{title}</header>
<main>{children}</main>
{footer && <footer>{footer}</footer>}
</div>
);
}
// Usage
<Modal title="Select Font" footer={<Button>Save</Button>}>
<p>Choose a font from the list below.</p>
<FontSelector />
</Modal>
```
**Svelte:**
```svelte
<!-- Modal.svelte -->
<script lang="ts">
export let title: string;
export let footer = false;
</script>
<div class="modal">
<header>{title}</header>
<main>
<slot />
</main>
{#if footer}
<footer>
<slot name="footer" />
</footer>
{/if}
</div>
<!-- Usage -->
<Modal title="Select Font" footer>
<svelte:fragment slot="footer">
<Button>Save</Button>
</svelte:fragment>
<p>Choose a font from the list below.</p>
<FontSelector />
</Modal>
```
**Key Differences:**
- Named slots using `slot="name"` and `<slot name="name" />`
- Multiple slots with different names
- Default slot uses `<slot />` without name
- `<svelte:fragment>` for content without wrapper element
---
### Common Patterns
#### Form Handling
**React:**
```tsx
function SearchForm({ onSearch }: { onSearch: (query: string) => void }) {
const [query, setQuery] = useState('');
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
onSearch(query);
};
return (
<form onSubmit={handleSubmit}>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search fonts..."
/>
<button type="submit">Search</button>
</form>
);
}
```
**Svelte:**
```svelte
<script lang="ts">
export let onSearch: (query: string) => void;
let query = $state('');
function handleSubmit(e: Event) {
e.preventDefault();
onSearch(query);
}
</script>
<form onsubmit={handleSubmit}>
<input
bind:value={query}
placeholder="Search fonts..."
/>
<button type="submit">Search</button>
</form>
```
**Key Difference:** Two-way binding with `bind:value` eliminates the need for `onChange`.
---
#### Lists with Keys
**React:**
```tsx
{fonts.map(font => (
<FontCard
key={font.id}
font={font}
onClick={() => handleSelect(font)}
/>
))}
```
**Svelte:**
```svelte
{#each fonts as font (font.id)}
<FontCard
{font}
onClick={() => handleSelect(font)}
/>
{/each}
```
**Key Difference:** Key specified in parentheses after item name: `as item (key)`.
---
#### Lifecycle Methods
**React:**
```tsx
useEffect(() => {
console.log('Mounted');
return () => console.log('Cleanup');
}, []);
useEffect(() => {
console.log('count changed:', count);
}, [count]);
```
**Svelte:**
```svelte
<script>
import { onMount, onDestroy } from 'svelte';
let count = $state(0);
$effect(() => {
console.log('count changed:', count);
});
onMount(() => {
console.log('Mounted');
});
onDestroy(() => {
console.log('Cleanup');
});
</script>
```
---
## Scalability & Commercialization
### When to Add Backend
#### Indicators for Backend Implementation
| Metric | Threshold | Action |
|--------|-----------|--------|
| Daily Active Users | > 1,000 | Consider backend |
| Google Fonts API Usage | > 800/day (80% of limit) | Implement server proxy |
| Feature Requests | > 10/month for user accounts | Plan authentication |
| Comparison Saves | Users frequently export/save | Add database |
| Page Load Time | > 3s on mobile | Optimize with backend caching |
#### Backend Architecture (When Needed)
```
┌─────────────────────────────────────────────────────────────┐
│ Vercel Edge │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ SvelteKit Server Functions │ │
│ │ ┌──────────────┐ ┌──────────────┐ │ │
│ │ │ API Routes │ │ Auth Routes │ │ │
│ │ └──────────────┘ └──────────────┘ │ │
│ └───────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│ │
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ Vercel Postgres │ │ Vercel KV │
│ (User accounts, │ │ (Caching, │
│ saved comps) │ │ rate limiting) │
└──────────────────┘ └──────────────────┘
```
#### Recommended Backend Stack
| Component | Technology | Why |
|-----------|------------|-----|
| Server Runtime | Vercel Edge Functions | Fast, global, free tier generous |
| Database | Vercel Postgres (Neon) | Managed, edge-ready, TypeScript native |
| Caching | Vercel KV (Redis) | Fast key-value store, built-in |
| Authentication | Lucia Auth | Lightweight, framework-agnostic |
| ORM | Drizzle ORM | TypeScript-first, no runtime overhead |
| File Storage | Vercel Blob | Simple, scalable storage |
---
### Monetization Models
#### Tier 1: Freemium (Recommended Launch Model)
| Feature | Free | Pro ($5/mo) |
|---------|------|-------------|
| Font comparisons | 4 fonts max | Unlimited |
| Save comparisons | Browser storage only | Cloud sync |
| Export images | Watermarked | No watermark |
| Custom text | Limited chars | Unlimited |
| Advanced filters | Basic | Advanced |
| API access | ❌ | ✅ |
#### Tier 2: Team/Agency ($29/mo)
- All Pro features
- Team collaboration (shared workspaces)
- Brand management (save brand fonts)
- Custom domains for shared comparisons
- Priority support
- Monthly usage reports
#### Tier 3: Enterprise (Custom)
- Unlimited API access
- Self-hosted deployment option
- Custom integrations
- SSO authentication
- Dedicated support
- SLA guarantees
---
### Additional Features for Monetization
1. **Font Pairing Recommendations**
- ML-based font matching
- Designer-curated pairings
- Upload custom fonts for analysis
2. **CSS Generator**
- Export optimized CSS
- Variable font support
- Fallback font stacks
3. **Embeddable Widget**
- Embed comparison on external sites
- White-label options
- Custom branding
4. **Font Inspector**
- Detailed glyph analysis
- Character set viewer
- Font metrics display
5. **A/B Testing**
- Test font combinations
- User feedback collection
- Conversion tracking
---
## Deployment
### Vercel Configuration
#### vercel.json
```json
{
"buildCommand": "npm run build",
"devCommand": "npm run dev",
"installCommand": "npm install",
"framework": "sveltekit",
"regions": ["iad1"],
"headers": [
{
"source": "/(.*)",
"headers": [
{
"key": "X-Content-Type-Options",
"value": "nosniff"
},
{
"key": "X-Frame-Options",
"value": "DENY"
},
{
"key": "X-XSS-Protection",
"value": "1; mode=block"
},
{
"key": "Referrer-Policy",
"value": "strict-origin-when-cross-origin"
}
]
},
{
"source": "/static/(.*)",
"headers": [
{
"key": "Cache-Control",
"value": "public, max-age=31536000, immutable"
}
]
},
{
"source": "/fonts/(.*)",
"headers": [
{
"key": "Cache-Control",
"value": "public, max-age=604800"
}
]
}
],
"rewrites": [
{
"source": "/api/:path*",
"destination": "/api/:path*"
}
]
}
```
---
### Environment Configuration
#### .env.example
```bash
# Google Fonts API Key
# Get from: https://developers.google.com/fonts/docs/developer_api
GOOGLE_FONTS_API_KEY=your_api_key_here
# Application Configuration
APP_URL=https://glyphdiff.com
APP_NAME=GlyphDiff
# Analytics (Optional)
VITE_GA_ID=G-XXXXXXXXXX
VITE_PLAUSIBLE_DOMAIN= glyphdiff.com
# Feature Flags
VITE_ENABLE_CACHE=true
VITE_ENABLE_ANALYTICS=false
```
#### vite.config.ts
```typescript
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()],
define: {
// Expose env variables to client code
'import.meta.env.VITE_GOOGLE_FONTS_API_KEY': JSON.stringify(process.env.GOOGLE_FONTS_API_KEY),
}
});
```
---
### DNS Configuration
#### DNS Records for glyphdiff.com
| Type | Name | Value | TTL |
|------|------|-------|-----|
| A | @ | 76.76.21.21 | 3600 |
| CNAME | www | cname.vercel-dns.com | 3600 |
**Note:** 76.76.21.21 is Vercel's IPv4 address for custom domains.
---
### Build and Deploy Commands
```bash
# Install dependencies
npm install
# Run development server
npm run dev
# Build for production
npm run build
# Preview production build
npm run preview
# Run tests
npm test
# Run linter
npm run lint
# Format code
npm run format
# Type check
npm run check
```
#### package.json Scripts
```json
{
"name": "glyphdiff",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . && eslint .",
"format": "prettier --write .",
"test": "playwright test",
"test:unit": "vitest"
},
"devDependencies": {
"@sveltejs/adapter-vercel": "^5.0.0",
"@sveltejs/kit": "^2.0.0",
"svelte": "^5.0.0",
"typescript": "^5.3.0",
"vite": "^5.0.0"
}
}
```
---
### Deployment Steps
1. **Push to GitHub**
```bash
git init
git add .
git commit -m "Initial commit"
git branch -M main
git remote add origin git@github.com:your-username/glyphdiff.git
git push -u origin main
```
2. **Connect to Vercel**
- Go to [vercel.com](https://vercel.com)
- Click "Add New Project"
- Import from GitHub
- Configure environment variables:
- `GOOGLE_FONTS_API_KEY`
3. **Configure Custom Domain**
- Go to Project Settings → Domains
- Add `glyphdiff.com`
- Add `www.glyphdiff.com`
- Update DNS records as shown above
4. **Deploy**
- Push changes to GitHub
- Vercel automatically deploys on push to main
- Preview deployments for pull requests
---
## Risk Assessment
### Technical Risks
| Risk | Probability | Impact | Mitigation |
|------|-------------|--------|------------|
| Google Fonts API rate limiting | Medium | High | Implement caching, batch requests, consider backend proxy |
| Browser font loading issues | Low | Medium | Use `document.fonts.load()`, implement fallback fonts |
| Poor mobile performance | Medium | High | Lazy loading, code splitting, test on real devices |
| Accessibility violations | Low | Medium | Regular audits with axe DevTools, keyboard testing |
| Vercel free tier limits | Low | Low | Monitor usage, easy upgrade path |
### Learning Curve Risks
| Risk | Probability | Impact | Mitigation |
|------|-------------|--------|------------|
| Difficulty with Svelte 5 runes | Medium | High | Start with simple components, practice with $state basics |
| TypeScript integration issues | Low | Medium | Use strict mode from start, leverage SvelteKit templates |
| CSS/Tailwind complexity | Low | Low | Use utility classes, avoid custom CSS where possible |
| Bits UI learning curve | Low | Medium | Read documentation, check examples, copy patterns |
### External Dependency Risks
| Risk | Probability | Impact | Mitigation |
|------|-------------|--------|------------|
| Google Fonts API changes | Low | High | Use TypeScript interfaces, watch for deprecation notices |
| Fontshare availability changes | Low | Low | Include as optional source, can be removed if needed |
| Vercel downtime | Very Low | Medium | Static files served from CDN, minimal impact |
| Bits UI breaking changes | Medium | Low | Lock to specific version, update carefully |
---
## Success Metrics
### Learning Success Metrics
| Metric | Target | Measurement |
|--------|--------|-------------|
| Svelte 5 proficiency | Comfortable with $state, $derived, $effect | Self-assessment quiz |
| SvelteKit understanding | Build 3+ routes with data loading | Route count |
| Store management | Implement 4 stores with persistence | Store count |
| Component library usage | Use 5+ Bits UI components | Component count |
| TypeScript adoption | 100% typed codebase | Lint check |
### Project Success Metrics
| Metric | Target | Measurement |
|--------|--------|-------------|
| Functional font catalog | 1000+ fonts loaded | Google Fonts API response |
| Comparison tool working | 4 fonts simultaneously | Manual testing |
| Performance score | 90+ Lighthouse score | Lighthouse audit |
| Mobile responsiveness | Pass on 375px - 1280px | Browser DevTools |
| Accessibility | WCAG 2.1 AA compliant | axe DevTools |
### Milestones
- [ ] **Week 1 Complete:** Environment setup, basic components, types defined
- [ ] **Week 2 Complete:** Font catalog with data fetching, routing working
- [ ] **Week 3 Complete:** All stores implemented, URL state working
- [ ] **Week 4 Complete:** Polished UI, dark mode, responsive design
- [ ] **MVP Launch:** Application deployed to glyphdiff.com
- [ ] **Post-Launch:** Collect feedback, iterate on features
---
## Appendix
### Commands Reference
#### Project Initialization
```bash
# Create new SvelteKit project
npm create svelte@latest glyphdiff
# Select options:
# - Which Svelte app template? Skeleton project
# - Add type checking? Yes, using TypeScript
# - Select additional options: ESLint, Prettier, Playwright
# Navigate to project
cd glyphdiff
# Install dependencies
npm install
# Add Tailwind CSS
npx svelte-add@latest tailwindcss
# Add Bits UI
npm install bits-ui
# Install additional dependencies
npm install clsx tailwind-merge
```
#### Development Commands
```bash
# Start development server
npm run dev
# Open browser to localhost:5173
# Type check
npm run check
# Type check in watch mode
npm run check:watch
# Lint code
npm run lint
# Format code
npm run format
# Run Playwright tests
npm run test
# Run unit tests (Vitest)
npm run test:unit
```
#### Build Commands
```bash
# Build for production
npm run build
# Preview production build
npm run preview
# Clean build artifacts
rm -rf .svelte-kit build
```
#### Git Commands
```bash
# Initialize git repo
git init
# Create .gitignore
echo ".svelte-kit
node_modules
.env
.DS_Store
dist
build
.vercel" > .gitignore
# Stage all files
git add .
# Commit
git commit -m "Initial commit"
# Create main branch
git branch -M main
# Add remote
git remote add origin git@github.com:username/glyphdiff.git
# Push to remote
git push -u origin main
```
---
### Learning Resources
#### Official Documentation
- [Svelte 5 Documentation](https://svelte-5-preview.vercel.app/)
- [SvelteKit Documentation](https://kit.svelte.dev/docs)
- [Tailwind CSS Documentation](https://tailwindcss.com/docs)
- [Bits UI Documentation](https://www.bits-ui.com/)
#### React to Svelte Migration
- [Svelte for React Developers](https://svelte.dev/docs/svelte-vs-react)
- [Svelte 5 Runes Guide](https://svelte-5-preview.vercel.app/docs/runes-api)
- [SvelteKit Data Loading](https://kit.svelte.dev/docs/load)
#### Google Fonts API
- [Google Fonts API Developer Guide](https://developers.google.com/fonts/docs/developer_api)
- [Google Fonts API Reference](https://developers.google.com/fonts/docs/reference/rest)
#### Video Resources
- [Svelte 5 Tutorial](https://www.youtube.com/results?search_query=svelte+5+tutorial)
- [SvelteKit Crash Course](https://www.youtube.com/results?search_query=sveltekit+crash+course)
- [Tailwind CSS Full Course](https://www.youtube.com/results?search_query=tailwind+css+full+course)
#### Community
- [Svelte Discord](https://svelte.dev/chat)
- [Svelte subreddit](https://reddit.com/r/sveltejs)
- [SvelteKit subreddit](https://reddit.com/r/sveltekit)
---
### File Templates
#### Component Template
```svelte
<script lang="ts">
import type { Snippet } from 'svelte';
export let prop: string;
// State
let count = $state(0);
// Derived state
let doubled = $derived(count * 2);
// Actions
function increment() {
count += 1;
}
// Lifecycle
import { onMount, onDestroy } from 'svelte';
onMount(() => {
console.log('Component mounted');
});
onDestroy(() => {
console.log('Component destroyed');
});
</script>
<div class="component">
<h2>{prop}</h2>
<p>Count: {count}, Doubled: {doubled}</p>
<button onclick={increment}>Increment</button>
</div>
<style>
.component {
padding: 1rem;
}
</style>
```
#### Store Template
```typescript
import type { FontMetadata } from '$lib/types';
import { setCache, getCache } from '$lib/utils/cache';
class ExampleStore {
// State
items = $state<FontMetadata[]>([]);
loading = $state(false);
error = $state<Error | null>(null);
// Derived state (computed automatically)
get count() {
return this.items.length;
}
// Methods
async loadItems() {
this.loading = true;
this.error = null;
try {
// Load from cache first
const cached = getCache('items');
if (cached) {
this.items = cached;
return;
}
// Fetch items
// this.items = await fetchItems();
// setCache('items', this.items);
} catch (e) {
this.error = e as Error;
} finally {
this.loading = false;
}
}
addItem(item: FontMetadata) {
this.items = [...this.items, item];
}
removeItem(id: string) {
this.items = this.items.filter(item => item.id !== id);
}
}
// Export singleton instance
export const exampleStore = new ExampleStore();
```
#### Load Function Template
```typescript
// +page.ts or +page.server.ts
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ fetch, depends, url }) => {
// Declare dependencies for invalidation
depends('app:data');
// Get URL parameters
const search = url.searchParams.get('search') || '';
try {
// Fetch data
const response = await fetch('/api/data');
const data = await response.json();
return {
data,
search
};
} catch (error) {
return {
data: [],
search,
error: error.message
};
}
};
```
---
### Troubleshooting
#### Common Issues
**Issue: Google Fonts API returns 403**
```bash
# Solution: Check API key and rate limits
# 1. Verify API key is correct
# 2. Check API key restrictions (set to None for testing)
# 3. Verify you haven't exceeded daily quota
```
**Issue: Tailwind classes not working**
```bash
# Solution: Check configuration
# 1. Ensure app.css imports tailwind directives
# 2. Restart dev server after config changes
# 3. Check vite.config.ts includes postcss config
```
**Issue: Svelte 5 syntax errors**
```bash
# Solution: Ensure you're using latest Svelte
npm install svelte@next
# If using VS Code, install Svelte extension
# Ensure it's the latest version for Svelte 5 support
```
**Issue: TypeScript errors with $state**
```bash
# Solution: Update Svelte configuration
# Ensure svelte.config.js includes:
export default {
compilerOptions: {
runes: true
}
};
```
**Issue: Vercel deployment fails**
```bash
# Solution: Check build output
npm run build
# Review error logs in Vercel dashboard
# Common issues:
# - Missing environment variables
# - TypeScript errors
# - Build timeout
```
---
### Performance Checklist
- [ ] Implement lazy loading for fonts
- [ ] Use SvelteKit static generation where possible
- [ ] Optimize images (use webp format)
- [ ] Minimize JavaScript bundle size
- [ ] Use code splitting for routes
- [ ] Implement caching strategy
- [ ] Enable compression (handled by Vercel)
- [ ] Test Lighthouse scores
- [ ] Minimize layout shifts
- [ ] Use appropriate font formats (WOFF2)
---
## Conclusion
This project plan provides a comprehensive roadmap for building glyphdiff.com as a learning-focused MVP. The pure client-side approach prioritizes mastering Svelte 5 and SvelteKit while building a functional font comparison tool.
### Next Steps
1. Set up the development environment (Week 1 tasks)
2. Obtain Google Fonts API key
3. Configure environment variables
4. Begin implementing Svelte fundamentals
Good luck on your React → Svelte journey! 🚀
---
*Document Version: 1.0*
*Last Updated: December 2025*
*Project: glyphdiff.com*