80 KiB
GlyphDiff.com - Font Comparison Project Plan
Status: Learning-Focused MVP | Tech Stack: SvelteKit + TypeScript + Tailwind CSS | Transition Path: React → Svelte
Table of Contents
- Project Overview
- Executive Summary
- Architecture Decisions
- Font Provider Integration Strategy
- Performance Optimization
- Frontend Architecture
- Data Models
- Implementation Roadmap
- TanStack Query Research
- Learning Guide: React → Svelte
- Scalability & Commercialization
- Deployment
- Risk Assessment
- Success Metrics
- 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)
- Font Catalog: Browse available fonts from multiple providers
- Side-by-Side Comparison: Compare up to 4 fonts simultaneously
- Custom Preview Text: Enter custom text for realistic comparison
- Filter & Search: Filter by category, popularity, weight, width
- Responsive Design: Seamless experience across all devices
- Dark Mode: Built-in theme toggle for comfortable viewing
- 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 |
| Package Manager | Yarn | Fast, reliable dependency management |
| Linting | oxlint | Fast Rust-based linter |
| Formatting | dprint | Fast Rust-based code formatter |
| 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
- Learning Focus: Minimize backend complexity to concentrate on Svelte/SvelteKit mastery
- Data Availability: Font metadata is publicly available via Google Fonts API and Fontshare CDN
- Performance: Static generation provides instant page loads and optimal Core Web Vitals
- Cost Efficiency: No server costs during MVP phase
- 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:
- Design for future backend: Structure data fetching and state management to be easily adapted to server-side later
- Implement smart caching: Use browser localStorage for font metadata cache with TTL
- Optimize API usage: Batch requests and use Google Fonts API efficiently
- Monitor usage: Track API usage and user engagement to inform backend decision
- Migration path: Keep server components clear of logic that could be moved to
+page.server.tslater
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
// 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
// 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
// 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:
// 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
// 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
<!-- 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
// 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)
// 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
// 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
// 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 }
};
};
<!-- 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
// 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';
}
// 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;
}
// 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>;
}
// 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 oxlint and dprint for linting and formatting
- Set up Git repository and .gitignore
-
Environment Configuration
- Create
.env.examplewith GOOGLE_FONTS_API_KEY - Add
.envto.gitignore - Configure environment variable loading in
vite.config.ts
- Create
-
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.tsfor re-exports
- Create
-
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
- Build Counter component using
-
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
- Create
-
Fontshare Service
- Create
fetchFontshareFonts()function - Build static font list for Fontshare
- Implement
mapFontshareToMetadata()mapper - Combine both providers into single font catalog
- Create
-
Font Catalog Page
- Create
routes/fonts/+page.svelte - Implement
routes/fonts/+page.tsfor data loading - Build FontCard component
- Create responsive FontGrid layout
- Create
-
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
- Create
-
Comparison Page Skeleton
- Create
routes/compare/+page.svelte - Implement URL state parsing in
+page.ts - Build basic layout for comparison grid
- Create
-
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
$statefor fonts array - Add derived state for filtered fonts
- Implement localStorage persistence
- Add loading and error states
- Create
-
Filter Store
- Create
src/lib/stores/filterStore.ts - Implement
$statefor all filter options - Add derived state for active filter count
- Implement reset filters function
- Add localStorage persistence
- Create
-
Comparison Store
- Create
src/lib/stores/comparisonStore.ts - Implement
$statefor comparison fonts (max 4) - Add functions to add/remove fonts
- Implement comparison settings state
- Add shareable URL generation
- Implement localStorage persistence
- Create
-
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
- Create
-
Cache Utility
- Implement
cache.tswith TTL support - Add caching to Google Fonts fetcher
- Implement cache invalidation logic
- Add cache management UI (optional)
- Implement
-
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
$staterunes - 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
// ✅ 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
// ✅ 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:
- SvelteKit's data loading capabilities are sufficient for this project's needs
- Focus on learning Svelte 5 and SvelteKit rather than adding more dependencies
- Static generation provides better performance than client-side caching
- 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:
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:
<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 $derivedeliminates need foruseEffectwith dependencies- Functions are defined separately (no need for
useCallback) - Event handlers use lowercase
onclickinstead ofonClick
Data Fetching
React with React Query:
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:
// +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 };
};
<!-- +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
loadfunction to page component via props
State Management
React with Zustand:
// 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 })
}));
// 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:
// 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();
<!-- 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
$statedirectly, no need for immer - Two-way binding with
bind:valueinstead ofonChange - Store access is simpler - just import and use
Conditional Rendering
React:
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:
<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:
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:
<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 letinstead 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:
// 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:
<!-- 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:
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:
<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:
{fonts.map(font => (
<FontCard
key={font.id}
font={font}
onClick={() => handleSelect(font)}
/>
))}
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:
useEffect(() => {
console.log('Mounted');
return () => console.log('Cleanup');
}, []);
useEffect(() => {
console.log('count changed:', count);
}, [count]);
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
-
Font Pairing Recommendations
- ML-based font matching
- Designer-curated pairings
- Upload custom fonts for analysis
-
CSS Generator
- Export optimized CSS
- Variable font support
- Fallback font stacks
-
Embeddable Widget
- Embed comparison on external sites
- White-label options
- Custom branding
-
Font Inspector
- Detailed glyph analysis
- Character set viewer
- Font metrics display
-
A/B Testing
- Test font combinations
- User feedback collection
- Conversion tracking
Deployment
Vercel Configuration
vercel.json
{
"buildCommand": "yarn build",
"devCommand": "yarn dev",
"installCommand": "yarn 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
# 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
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
# Install dependencies
yarn install
# Run development server
yarn dev
# Build for production
yarn build
# Preview production build
yarn preview
# Run tests
yarn test
# Run linter
yarn lint
# Format code
yarn format
# Type check
yarn check
package.json Scripts
{
"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": "oxlint",
"format": "dprint fmt",
"format:check": "dprint check",
"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
-
Push to GitHub
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 -
Connect to Vercel
- Go to vercel.com
- Click "Add New Project"
- Import from GitHub
- Configure environment variables:
GOOGLE_FONTS_API_KEY
-
Configure Custom Domain
- Go to Project Settings → Domains
- Add
glyphdiff.com - Add
www.glyphdiff.com - Update DNS records as shown above
-
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
# Create new SvelteKit project
yarn create svelte@latest glyphdiff
# Select options:
# - Which Svelte app template? Skeleton project
# - Add type checking? Yes, using TypeScript
# - Select additional options: Playwright
# Navigate to project
cd glyphdiff
# Install dependencies
yarn install
# Add Tailwind CSS
yarn dlx svelte-add@latest tailwindcss
# Add Bits UI
yarn add bits-ui
# Install additional dependencies
yarn add clsx tailwind-merge
# Install oxlint and dprint
yarn add -D oxlint dprint
Development Commands
# Start development server
yarn dev
# Open browser to localhost:5173
# Type check
yarn check
# Type check in watch mode
yarn check:watch
# Lint code
yarn lint
# Format code
yarn format
# Run Playwright tests
yarn test
# Run unit tests (Vitest)
yarn test:unit
Build Commands
# Build for production
yarn build
# Preview production build
yarn preview
# Clean build artifacts
rm -rf .svelte-kit build
Git Commands
# 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
React to Svelte Migration
Google Fonts API
Video Resources
Community
File Templates
Component Template
<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
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
// +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
# 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
# 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
# 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
# Solution: Update Svelte configuration
# Ensure svelte.config.js includes:
export default {
compilerOptions: {
runes: true
}
};
Issue: Vercel deployment fails
# 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
- Set up the development environment (Week 1 tasks)
- Obtain Google Fonts API key
- Configure environment variables
- Begin implementing Svelte fundamentals
Good luck on your React → Svelte journey! 🚀
Document Version: 1.0 Last Updated: December 2025 Project: glyphdiff.com