Files
frontend-svelte/GLYPHDIFF_PROJECT_PLAN.md

2998 lines
79 KiB
Markdown
Raw Normal View History

# 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*