Files
frontend-svelte/GLYPHDIFF_PROJECT_PLAN.md

80 KiB
Raw Blame 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
  2. Executive Summary
  3. Architecture Decisions
  4. Font Provider Integration Strategy
  5. Performance Optimization
  6. Frontend Architecture
  7. Data Models
  8. Implementation Roadmap
  9. TanStack Query Research
  10. Learning Guide: React → Svelte
  11. Scalability & Commercialization
  12. Deployment
  13. Risk Assessment
  14. Success Metrics
  15. 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
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

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

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

// 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.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.

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

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:

  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:

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
  • $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:

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 load function 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 $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:

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 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:

// 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) │
└──────────────────┘   └──────────────────┘
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

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

{
  "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

  1. 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
    
  2. Connect to Vercel

    • Go to 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

# 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

  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