1 Commits

Author SHA1 Message Date
ilia 09c18ec654 Merge pull request 'chore/svelte-kit-to-svelte-migration' (#6) from chore/svelte-kit-to-svelte-migration into feature/project-setup
Lint / Lint Code (push) Failing after 1m38s
Test / Svelte Checks (push) Failing after 2s
Reviewed-on: glyphdiff.com/frontend-sveltekit#6
2025-12-30 16:40:55 +00:00
437 changed files with 2904 additions and 42014 deletions
-9
View File
@@ -1,9 +0,0 @@
node_modules
.yarn/cache
.yarn/unplugged
.yarn/install-state.gz
dist
.git
.gitea
.svelte-kit
storybook-static
+562
View File
@@ -0,0 +1,562 @@
# Gitea Actions CI/CD Setup
This document describes the CI/CD pipeline configuration for the GlyphDiff project using Gitea Actions (GitHub Actions compatible).
## Table of Contents
- [Overview](#overview)
- [Workflow Files](#workflow-files)
- [Workflow Triggers](#workflow-triggers)
- [Setup Instructions](#setup-instructions)
- [Self-Hosted Runner Setup](#self-hosted-runner-setup)
- [Caching Strategy](#caching-strategy)
- [Environment Variables](#environment-variables)
- [Troubleshooting](#troubleshooting)
## Overview
The CI/CD pipeline consists of four main workflows:
1. **Lint** - Code quality checks (oxlint, dprint formatting)
2. **Test** - Type checking and E2E tests (Playwright)
3. **Build** - Production build verification
4. **Deploy** - Deployment automation (optional/template)
All workflows are designed to run on both push and pull request events, with appropriate branch filtering and concurrency controls.
## Workflow Files
### `.gitea/workflows/lint.yml`
**Purpose**: Run code quality checks to ensure code style and formatting standards.
**Checks performed**:
- `oxlint` - Fast JavaScript/TypeScript linter
- `dprint check` - Code formatting verification
**Triggers**:
- Push to `main`, `develop`, `feature/*` branches
- Pull requests to `main` or `develop`
- Manual workflow dispatch
**Cache**: Node modules and Yarn cache
**Concurrency**: Cancels in-progress runs for the same branch when a new commit is pushed.
---
### `.gitea/workflows/test.yml`
**Purpose**: Run type checking and end-to-end tests.
**Jobs**:
#### 1. `type-check` job
- `tsc --noEmit` - TypeScript type checking
- `svelte-check --threshold warning` - Svelte component type checking
#### 2. `e2e-tests` job
- Installs Playwright browsers with system dependencies
- Runs E2E tests using Playwright
- Uploads test report artifacts (retained for 7 days)
- Uploads screenshots on test failure for debugging
**Triggers**: Same as lint workflow
**Cache**: Node modules and Yarn cache
**Artifacts**:
- `playwright-report` - Test execution report
- `playwright-screenshots` - Screenshots from failed tests
---
### `.gitea/workflows/build.yml`
**Purpose**: Verify that the production build completes successfully.
**Steps**:
1. Checkout repository
2. Setup Node.js v20 with Yarn caching
3. Install dependencies with `--frozen-lockfile`
4. Run `svelte-kit sync` to prepare SvelteKit
5. Build the project with `NODE_ENV=production`
6. Upload build artifacts (`.svelte-kit/output`, `.svelte-kit/build`)
7. Run the preview server and verify it responds (health check)
**Triggers**:
- Push to `main` or `develop` branches
- Pull requests to `main` or `develop`
- Manual workflow dispatch
**Cache**: Node modules and Yarn cache
**Artifacts**:
- `build-artifacts` - Compiled SvelteKit output (retained for 7 days)
---
### `.gitea/workflows/deploy.yml`
**Purpose**: Automated deployment pipeline (template configuration).
**Current state**: Placeholder configuration. Uncomment and customize one of the deployment examples.
**Pre-deployment checks**:
- Must pass linting workflow
- Must pass testing workflow
- Must pass build workflow
**Deployment examples included**:
1. **Docker container registry** - Build and push Docker image
2. **SSH deployment** - Deploy to server via SSH
3. **Vercel** - Deploy to Vercel platform
**Triggers**:
- Push to `main` branch
- Manual workflow dispatch with environment selection (staging/production)
**Secrets required** (configure in Gitea):
- For Docker: `REGISTRY_URL`, `REGISTRY_USERNAME`, `REGISTRY_PASSWORD`
- For SSH: `DEPLOY_HOST`, `DEPLOY_USER`, `DEPLOY_SSH_KEY`
- For Vercel: `VERCEL_TOKEN`, `VERCEL_ORG_ID`, `VERCEL_PROJECT_ID`
## Workflow Triggers
### Branch-Specific Behavior
| Workflow | Push Triggers | PR Triggers | Runs on Merge |
| -------- | ------------------------------ | -------------------- | ------------- |
| Lint | `main`, `develop`, `feature/*` | To `main`, `develop` | Yes |
| Test | `main`, `develop`, `feature/*` | To `main`, `develop` | Yes |
| Build | `main`, `develop` | To `main`, `develop` | Yes |
| Deploy | `main` only | None | Yes |
### Concurrency Strategy
All workflows use concurrency groups based on the workflow name and branch reference:
```yaml
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true # or false for deploy workflow
```
This ensures:
- For lint/test/build: New commits cancel in-progress runs (saves resources)
- For deploy: Prevents concurrent deployments (ensures safety)
## Setup Instructions
### Step 1: Verify Gitea Actions is Enabled
1. Navigate to your Gitea instance
2. Go to **Site Administration****Actions**
3. Ensure Actions is enabled
4. Configure default runner settings if needed
### Step 2: Configure Repository Settings
1. Go to your repository in Gitea
2. Click **Settings****Actions**
3. Enable Actions for the repository if not already enabled
4. Set appropriate permissions for read/write access
### Step 3: Push Workflows to Repository
The workflow files are already in `.gitea/workflows/`. Commit and push them:
```bash
git add .gitea/workflows/
git commit -m "Add Gitea Actions CI/CD workflows"
git push origin main
```
### Step 4: Verify Workflows Run
1. Navigate to **Actions** tab in your repository
2. You should see the workflows trigger on the next push
3. Click into a workflow run to view logs and status
### Step 5: Configure Secrets (Optional - for deployment)
1. Go to repository **Settings****Secrets****Actions**
2. Click **Add New Secret**
3. Add secrets required for your deployment method
Example secrets for SSH deployment:
```
DEPLOY_HOST=your-server.com
DEPLOY_USER=deploy
DEPLOY_SSH_KEY=-----BEGIN OPENSSH PRIVATE KEY-----
...
-----END OPENSSH PRIVATE KEY-----
```
## Self-Hosted Runner Setup
### Option 1: Using Gitea's Built-in Act Runner (Recommended)
Gitea provides `act_runner` (compatible with GitHub Actions runner).
#### Install act_runner
On Linux (Debian/Ubuntu):
```bash
wget -O /usr/local/bin/act_runner https://gitea.com/act_runner/releases/download/v0.2.11/act_runner-0.2.11-linux-amd64
chmod +x /usr/local/bin/act_runner
```
Verify installation:
```bash
act_runner --version
```
#### Register the Runner
1. In Gitea, navigate to repository **Settings****Actions****Runners**
2. Click **New Runner**
3. Copy the registration token
4. Run the registration command:
```bash
act_runner register \
--instance https://your-gitea-instance.com \
--token YOUR_REGISTRATION_TOKEN \
--name "linux-runner-1" \
--labels ubuntu-latest,linux,docker \
--no-interactive
```
#### Start the Runner as a Service
Create a systemd service file at `/etc/systemd/system/gitea-runner.service`:
```ini
[Unit]
Description=Gitea Actions Runner
After=network.target
[Service]
Type=simple
User=git
WorkingDirectory=/var/lib/gitea-runner
ExecStart=/usr/local/bin/act_runner daemon
Restart=always
RestartSec=5s
[Install]
WantedBy=multi-user.target
```
Enable and start the service:
```bash
sudo systemctl daemon-reload
sudo systemctl enable gitea-runner
sudo systemctl start gitea-runner
```
#### Check Runner Status
```bash
sudo systemctl status gitea-runner
```
Verify in Gitea: The runner should appear as **Online** with the `ubuntu-latest` label.
### Option 2: Using Self-Hosted Runners with Docker
If you prefer Docker-based execution:
#### Install Docker
```bash
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
sudo usermod -aG docker $USER
```
#### Configure Runner to Use Docker
Ensure the runner has access to the Docker socket:
```bash
sudo usermod -aG docker act_runner_user
```
The workflows will now run containers inside the runner's Docker environment.
### Option 3: Using External Runners (GitHub Actions Runner Compatible)
If you want to use standard GitHub Actions runners:
```bash
# Download and configure GitHub Actions runner
mkdir actions-runner && cd actions-runner
curl -o actions-runner-linux-x64-2.311.0.tar.gz -L https://github.com/actions/runner/releases/download/v2.311.0/actions-runner-linux-x64-2.311.0.tar.gz
tar xzf ./actions-runner-linux-x64-2.311.0.tar.gz
# Configure to point to Gitea instance
./config.sh --url https://your-gitea-instance.com --token YOUR_TOKEN
```
## Caching Strategy
### Node.js and Yarn Cache
All workflows use `actions/setup-node@v4` with built-in caching:
```yaml
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'yarn'
```
This caches:
- `node_modules` directory
- Yarn cache directory (`~/.yarn/cache`)
- Reduces installation time from minutes to seconds on subsequent runs
### Playwright Cache
Playwright browsers are installed fresh each time. To cache Playwright (optional optimization):
```yaml
- name: Cache Playwright binaries
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-playwright-
```
## Environment Variables
### Default Environment Variables
The workflows use the following environment variables:
```bash
NODE_ENV=production # For build workflow
NODE_VERSION=20 # Node.js version used across all workflows
```
### Custom Environment Variables
To add custom environment variables:
1. Go to repository **Settings****Variables****Actions**
2. Click **Add New Variable**
3. Add variable name and value
4. Set scope (environment, repository, or organization)
Example for feature flags:
```
ENABLE_ANALYTICS=false
API_URL=https://api.example.com
```
Access in workflow:
```yaml
env:
API_URL: ${{ vars.API_URL }}
ENABLE_ANALYTICS: ${{ vars.ENABLE_ANALYTICS }}
```
## Troubleshooting
### Workflows Not Running
**Symptoms**: Workflows don't appear or don't trigger
**Solutions**:
1. Verify Actions is enabled in Gitea site administration
2. Check repository Settings → Actions is enabled
3. Verify workflow files are in `.gitea/workflows/` directory
4. Check workflow YAML syntax (no indentation errors)
### Runner Offline
**Symptoms**: Runner shows as **Offline** or **Idle**
**Solutions**:
1. Check runner service status: `sudo systemctl status gitea-runner`
2. Review runner logs: `journalctl -u gitea-runner -f`
3. Verify network connectivity to Gitea instance
4. Restart runner: `sudo systemctl restart gitea-runner`
### Linting Fails with Formatting Errors
**Symptoms**: `dprint check` fails on CI but passes locally
**Solutions**:
1. Ensure dprint configuration (`dprint.json`) is committed
2. Run `yarn dprint fmt` locally before committing
3. Consider adding auto-fix workflow (see below)
### Playwright Tests Timeout
**Symptoms**: E2E tests fail with timeout errors
**Solutions**:
1. Check `playwright.config.ts` timeout settings
2. Ensure preview server starts before tests run (built into config)
3. Increase timeout in workflow:
```yaml
- name: Run Playwright tests
run: yarn test:e2e
env:
PLAYWRIGHT_TIMEOUT: 60000
```
### Build Fails with Out of Memory
**Symptoms**: Build fails with memory allocation errors
**Solutions**:
1. Increase Node.js memory limit:
```yaml
- name: Build project
run: yarn build
env:
NODE_OPTIONS: --max-old-space-size=4096
```
2. Ensure runner has sufficient RAM (minimum 2GB recommended)
### Permission Denied on Runner
**Symptoms**: Runner can't access repository or secrets
**Solutions**:
1. Verify runner has read access to repository
2. Check secret names match exactly in workflow
3. Ensure runner user has file system permissions
### Yarn Install Fails with Lockfile Conflict
**Symptoms**: `yarn install --frozen-lockfile` fails
**Solutions**:
1. Ensure `yarn.lock` is up-to-date locally
2. Run `yarn install` and commit updated `yarn.lock`
3. Do not use `--frozen-lockfile` if using different platforms (arm64 vs amd64)
### Slow Workflow Execution
**Symptoms**: Workflows take too long to complete
**Solutions**:
1. Verify caching is working (check logs for "Cache restored")
2. Use `--frozen-lockfile` for faster dependency resolution
3. Consider matrix strategy for parallel execution (not currently used)
4. Optimize Playwright tests (reduce test count, increase timeouts only if needed)
## Best Practices
### 1. Keep Dependencies Updated
Regularly update action versions:
```yaml
- uses: actions/checkout@v4 # Update from v3 to v4 when available
- uses: actions/setup-node@v4
```
### 2. Use Frozen Lockfile
Always use `--frozen-lockfile` in CI to ensure reproducible builds:
```bash
yarn install --frozen-lockfile
```
### 3. Monitor Workflow Status
Set up notifications for workflow failures:
- Email notifications in Gitea user settings
- Integrate with Slack/Mattermost for team alerts
- Use status badges in README
### 4. Test Locally Before Pushing
Run the same checks locally:
```bash
yarn lint # oxlint
yarn dprint check # Formatting check
yarn tsc --noEmit # Type check
yarn test:e2e # E2E tests
yarn build # Build
```
### 5. Leverage Git Hooks
The project uses lefthook for pre-commit/pre-push checks. This catches issues before they reach CI:
```bash
# Pre-commit: Format code, lint staged files
# Pre-push: Full type check, format check, full lint
```
## Additional Resources
- [Gitea Actions Documentation](https://docs.gitea.com/usage/actions/overview)
- [Gitea act_runner Documentation](https://docs.gitea.com/usage/actions/act-runner)
- [GitHub Actions Documentation](https://docs.github.com/en/actions)
- [SvelteKit Deployment Guide](https://kit.svelte.dev/docs/adapters)
- [Playwright CI/CD Guide](https://playwright.dev/docs/ci)
## Status Badges
Add status badges to your README.md:
```markdown
![Lint](https://your-gitea-instance.com/username/glyphdiff/actions/badges/workflow/lint.yml/badge.svg)
![Test](https://your-gitea-instance.com/username/glyphdiff/actions/badges/workflow/test.yml/badge.svg)
![Build](https://your-gitea-instance.com/username/glyphdiff/actions/badges/workflow/build.yml/badge.svg)
```
## Next Steps
1. **Customize deployment**: Modify `deploy.yml` with your deployment strategy
2. **Add notifications**: Set up workflow failure notifications
3. **Optimize caching**: Add Playwright cache if needed
4. **Add badges**: Include status badges in README
5. **Schedule tasks**: Add periodic tests or dependency updates (optional)
---
**Last Updated**: December 30, 2025
**Version**: 1.0.0
+32
View File
@@ -0,0 +1,32 @@
name: Build
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'yarn'
- name: Install
run: yarn install --frozen-lockfile --prefer-offline
- name: Build Svelte App
run: yarn build
- name: Upload Artifacts
uses: actions/upload-artifact@v4
with:
name: build-artifacts
path: dist/
retention-days: 7
+42
View File
@@ -0,0 +1,42 @@
name: Deploy Pipeline
on:
push:
branches: [main]
workflow_dispatch:
inputs:
environment:
description: 'Target'
required: true
default: 'production'
type: choice
options: [staging, production]
jobs:
pipeline:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'yarn'
- name: Install
run: yarn install --frozen-lockfile --prefer-offline
- name: Validation
run: |
yarn oxlint .
yarn svelte-check
- name: Build for Production
run: yarn build
env:
NODE_ENV: production
- name: Deploy Step
run: |
echo "Deploying dist/ to ${{ github.event.inputs.environment || 'production' }}..."
# EXAMPLE: rsync -avz dist/ user@your-vps:/var/www/html/
+48
View File
@@ -0,0 +1,48 @@
name: Lint
on:
push:
branches:
- main
- develop
- feature/*
pull_request:
branches:
- main
- develop
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
lint:
name: Lint Code
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'yarn'
- name: Get yarn cache directory path
id: yarn-cache-dir-path
run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
- name: Persistent Yarn Cache
uses: actions/cache@v4
id: yarn-cache
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- name: Install dependencies
run: yarn install --frozen-lockfile --prefer-offline
+69
View File
@@ -0,0 +1,69 @@
name: Test
on:
push:
branches: [main, develop, "feature/*"]
pull_request:
branches: [main, develop]
workflow_dispatch:
jobs:
test:
name: Svelte Checks
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'yarn'
- name: Install
run: yarn install --frozen-lockfile --prefer-offline
- name: Type Check
run: yarn svelte-check --threshold warning
- name: Lint
run: yarn oxlint .
# e2e-tests:
# name: E2E Tests (Playwright)
# runs-on: ubuntu-latest
#
# steps:
# - name: Checkout repository
# uses: actions/checkout@v4
#
# - name: Setup Node.js
# uses: actions/setup-node@v4
# with:
# node-version: '20'
# cache: 'yarn'
#
# - name: Install dependencies
# run: yarn install --frozen-lockfile
#
# - name: Install Playwright browsers
# run: yarn playwright install --with-deps
#
# - name: Run Playwright tests
# run: yarn test:e2e
#
# - name: Upload Playwright report
# if: always()
# uses: actions/upload-artifact@v4
# with:
# name: playwright-report
# path: playwright-report/
# retention-days: 7
#
# - name: Upload Playwright screenshots (on failure)
# if: failure()
# uses: actions/upload-artifact@v4
# with:
# name: playwright-screenshots
# path: test-results/
# retention-days: 7
#
# Note: E2E tests are disabled until Playwright setup is complete.
# Uncomment this job section when Playwright tests are ready to run.
-97
View File
@@ -1,97 +0,0 @@
name: Workflow
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '25'
- name: Enable Corepack
run: |
corepack enable
corepack prepare yarn@stable --activate
- name: Persistent Yarn Cache
uses: actions/cache@v4
id: yarn-cache
with:
path: .yarn/cache
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: ${{ runner.os }}-yarn-
- name: Install dependencies
run: yarn install --immutable
- name: Build Svelte App
run: yarn build
- name: Lint
run: yarn lint
- name: Type Check
run: yarn check
- name: Run Unit Tests
run: yarn test:unit
- name: Run Component Tests
timeout-minutes: 5
run: yarn test:component --reporter=verbose --logHeapUsage
e2e:
needs: build
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.59.0-jammy
steps:
- uses: actions/checkout@v4
- name: Enable Corepack
run: |
corepack enable
corepack prepare yarn@stable --activate
- name: Persistent Yarn Cache
uses: actions/cache@v4
with:
path: .yarn/cache
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: ${{ runner.os }}-yarn-
- name: Install dependencies
run: yarn install --immutable
- name: Build Svelte SPA
run: yarn build
- name: E2E Tests
timeout-minutes: 15
run: yarn test:e2e
publish:
# Runs if lint, unit-, component-, e2e-tests pass
needs: [build, e2e]
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' # Only deploy from main branch
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Login to Gitea Registry
run: echo "${{ secrets.CI_DEPLOY_TOKEN }}" | docker login git.allmy.work -u ${{ gitea.repository_owner }} --password-stdin
- name: Build and Push Docker Image
run: |
docker build \
-t git.allmy.work/${{ gitea.repository }}:latest \
-t git.allmy.work/${{ gitea.repository }}:${{ gitea.sha }} \
.
docker push git.allmy.work/${{ gitea.repository }}:latest
docker push git.allmy.work/${{ gitea.repository }}:${{ gitea.sha }}
-21
View File
@@ -10,12 +10,6 @@ node_modules
/build /build
/dist /dist
# IDE settings
.vscode
# Git worktrees (isolated development branches)
.worktrees
# OS # OS
.DS_Store .DS_Store
Thumbs.db Thumbs.db
@@ -28,8 +22,6 @@ Thumbs.db
# Yarn # Yarn
.yarn .yarn
.yarn/**
.yarn/install-state.gz
.pnp.* .pnp.*
# Zed # Zed
@@ -40,16 +32,3 @@ vite.config.js.timestamp-*
vite.config.ts.timestamp-* vite.config.ts.timestamp-*
/docs /docs
AGENTS.md
*.md
!README.md
*storybook.log
storybook-static
# Tests
coverage/
.aider*
playwright-report/
blob-report/
.playwright/
-195
View File
@@ -1,195 +0,0 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["import"],
"categories": {
"correctness": "error",
"suspicious": "warn",
"perf": "warn",
// style/restriction off: opt-in, contradictory grab-bags. Wanted rules enabled individually below.
"style": "off",
"restriction": "off"
},
"env": {
"browser": true,
"es2021": true
},
"ignorePatterns": [
"node_modules",
"dist",
"build",
".svelte-kit",
".vercel",
"*.config.js",
"*.config.ts"
],
"rules": {
"no-console": "warn",
"no-debugger": "error",
"no-alert": "warn",
// no-cycle resolves $-aliases via tsconfig auto-discovery (no resolver config in oxlint)
"import/no-cycle": "error",
"import/no-duplicates": "warn",
"import/no-unassigned-import": "off", // CSS/side-effect imports are intentional
"no-sequences": "error",
"no-underscore-dangle": "off",
"no-shadow": "warn",
"no-implicit-coercion": "warn",
"no-await-in-loop": "warn",
"no-return-assign": "warn",
"no-new": "warn",
"no-unneeded-ternary": "warn"
},
// FSD boundaries. oxlint has no zone rule, so layer/segment direction is enforced
// with no-restricted-imports patterns scoped per glob. Layer order (high->low):
// app(exempt top shell) > routes > widgets > features > entities > shared.
// A layer bans imports from itself (cross-slice via alias) and every layer above.
// Overrides are LAST-WINS, not merged: a file matching two overrides keeps only the
// last rule config. So the domain override (below) is a self-contained superset, and
// the test/story override (last) fully disables boundary checks for those files.
"overrides": [
// shared = lowest layer: imports nothing above it
{
"files": ["src/shared/**"],
"rules": {
"no-restricted-imports": ["error", {
"patterns": [
{
"group": [
"$app",
"$app/*",
"$routes",
"$routes/*",
"$widgets",
"$widgets/*",
"$features",
"$features/*",
"$entities",
"$entities/*"
],
"message": "FSD layer violation: `shared` is the lowest layer and may not import from any layer above it."
}
]
}]
}
},
// entities: import shared only; no other entity via alias; interior ui<-only-ui
{
"files": ["src/entities/**"],
"rules": {
"no-restricted-imports": ["error", {
"patterns": [
{
"group": ["$app", "$app/*", "$routes", "$routes/*", "$widgets", "$widgets/*", "$features", "$features/*"],
"message": "FSD layer violation: `entities` may only import from `shared`."
},
{
"group": ["$entities", "$entities/*"],
"message": "FSD cross-slice violation: do not import another entity via its alias. Use relative imports inside your own slice; invert the dependency through a higher layer for cross-slice needs."
},
{
"group": ["../ui", "../ui/*", "../../ui/*"],
"message": "FSD segment violation: only `ui` may import `ui`. Interior direction is ui -> model -> domain."
}
]
}]
}
},
// features: import entities/shared only; no other feature via alias
{
"files": ["src/features/**"],
"rules": {
"no-restricted-imports": ["error", {
"patterns": [
{
"group": ["$app", "$app/*", "$routes", "$routes/*", "$widgets", "$widgets/*"],
"message": "FSD layer violation: `features` may only import from `entities` and `shared`."
},
{
"group": ["$features", "$features/*"],
"message": "FSD cross-slice violation: do not import another feature via its alias. Invert the dependency through a higher layer (widget/route)."
},
{
"group": ["../ui", "../ui/*", "../../ui/*"],
"message": "FSD segment violation: only `ui` may import `ui`. Interior direction is ui -> model -> domain."
}
]
}]
}
},
// widgets: import features/entities/shared only; no other widget via alias
{
"files": ["src/widgets/**"],
"rules": {
"no-restricted-imports": ["error", {
"patterns": [
{
"group": ["$app", "$app/*", "$routes", "$routes/*"],
"message": "FSD layer violation: `widgets` may only import from `features`, `entities`, and `shared`."
},
{
"group": ["$widgets", "$widgets/*"],
"message": "FSD cross-slice violation: do not import another widget via its alias. Invert the dependency through the route layer."
},
{
"group": ["../ui", "../ui/*", "../../ui/*"],
"message": "FSD segment violation: only `ui` may import `ui`. Interior direction is ui -> model -> domain."
}
]
}]
}
},
// routes: top of the FSD list, imports any layer below; only app is above it
{
"files": ["src/routes/**"],
"rules": {
"no-restricted-imports": ["error", {
"patterns": [
{ "group": ["$app", "$app/*"], "message": "FSD layer violation: `routes` may not import from `app`." }
]
}]
}
},
// domain (FSD+): pure logic. Imports NO layer (not even shared) and no sibling
// model/ui segment. Superset: wins over the layer override above for these files.
{
"files": ["src/**/domain/**"],
"rules": {
"no-restricted-imports": ["error", {
"patterns": [
{
"group": [
"$app",
"$app/*",
"$routes",
"$routes/*",
"$widgets",
"$widgets/*",
"$features",
"$features/*",
"$entities",
"$entities/*",
"$shared",
"$shared/*"
],
"message": "FSD+ domain isolation: `domain` is pure business logic and may not import any layer (including `shared`). Allowed: relative imports within `domain` and framework-agnostic npm packages."
},
{
"group": ["../model", "../model/*", "../../model/*", "../ui", "../ui/*", "../../ui/*"],
"message": "FSD+ domain isolation: `domain` may not import sibling `model` or `ui` segments. Dependency flows ui -> model -> domain, never back."
}
]
}]
}
},
// tests/stories/fixtures legitimately cross-import (e.g. $entities/Font/testing).
// Must be LAST so last-wins disables boundary checks for them.
{
"files": ["**/*.test.ts", "**/*.spec.ts", "**/*.stories.svelte", "src/**/testing/**"],
"rules": {
"no-restricted-imports": "off"
}
}
]
}
-26
View File
@@ -1,26 +0,0 @@
<!--
Component: Decorator
Global Storybook decorator that wraps all stories with necessary providers.
This provides:
- ResponsiveManager context for breakpoint tracking
- TooltipProvider for tooltip components
-->
<script lang="ts">
import { createResponsiveManager } from '$shared/lib';
import type { ResponsiveManager } from '$shared/lib';
import { setContext } from 'svelte';
interface Props {
children: import('svelte').Snippet;
}
let { children }: Props = $props();
// Create and provide responsive context
const responsiveManager = createResponsiveManager();
$effect(() => responsiveManager.init());
setContext<ResponsiveManager>('responsive', responsiveManager);
</script>
{@render children()}
-20
View File
@@ -1,20 +0,0 @@
<script lang="ts">
interface Props {
children: import('svelte').Snippet;
/**
* Tailwind max-width class applied to the card, or 'none' to remove width constraint.
* @default 'max-w-3xl'
*/
maxWidth?: string;
}
let { children, maxWidth = 'max-w-3xl' }: Props = $props();
</script>
<div class="flex min-h-screen w-full items-center justify-center bg-background p-8">
<div class="w-full bg-card shadow-lg ring-1 ring-border rounded-xl p-12 {maxWidth !== 'none' ? maxWidth : ''}">
<div class="relative flex justify-center items-center text-foreground">
{@render children()}
</div>
</div>
</div>
-79
View File
@@ -1,79 +0,0 @@
<!--
Component: ThemeDecorator
Storybook decorator that initializes ThemeManager for theme-related stories.
Ensures theme management works correctly in Storybook's iframe environment.
Includes a floating theme toggle for universal theme switching across all stories.
-->
<script lang="ts">
import { themeManager } from '$features/ChangeAppTheme';
import type { ResponsiveManager } from '$shared/lib';
import { IconButton } from '$shared/ui';
import MoonIcon from '@lucide/svelte/icons/moon';
import SunIcon from '@lucide/svelte/icons/sun';
import { getContext } from 'svelte';
import {
onDestroy,
onMount,
} from 'svelte';
interface Props {
children: import('svelte').Snippet;
}
let { children }: Props = $props();
// Get responsive context (set by Decorator)
const responsive = getContext<ResponsiveManager>('responsive');
// Initialize themeManager on mount
onMount(() => {
themeManager.init();
// Add keyboard shortcut for theme toggle (Cmd/Ctrl+Shift+D)
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'D') {
e.preventDefault();
themeManager.toggle();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
});
// Clean up themeManager when story unmounts
onDestroy(() => {
themeManager.destroy();
});
const theme = $derived(themeManager.value);
const themeLabel = $derived(theme === 'light' ? 'Light' : 'Dark');
</script>
<!-- Floating Theme Toggle -->
<div
class="fixed top-4 right-4 z-50 flex items-center gap-2 px-3 py-2 bg-card border border-border shadow-lg rounded-lg"
title="Toggle theme (Cmd/Ctrl+Shift+D)"
>
<span class="text-xs font-medium text-muted-foreground">Theme: {themeLabel}</span>
<IconButton
onclick={() => themeManager.toggle()}
size={responsive?.isMobile ? 'sm' : 'md'}
variant="ghost"
title="Toggle theme"
>
{#snippet icon()}
{#if theme === 'light'}
<MoonIcon class="size-4" />
{:else}
<SunIcon class="size-4" />
{/if}
{/snippet}
</IconButton>
</div>
<!-- Story Content -->
{@render children()}
-47
View File
@@ -1,47 +0,0 @@
import type { StorybookConfig } from '@storybook/svelte-vite';
import {
dirname,
resolve,
} from 'path';
import { fileURLToPath } from 'url';
import {
loadConfigFromFile,
mergeConfig,
} from 'vite';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const config: StorybookConfig = {
'stories': [
'../src/**/*.mdx',
'../src/**/*.stories.@(js|ts|svelte)',
],
'addons': [
{
name: '@storybook/addon-svelte-csf',
options: {
// Use modern template syntax for better performance
legacyTemplate: false,
},
},
'@chromatic-com/storybook',
'@storybook/addon-vitest',
'@storybook/addon-a11y',
'@storybook/addon-docs',
],
'framework': '@storybook/svelte-vite',
async viteFinal(config) {
// This attempts to find your actual vite.config.ts
const { config: userConfig } = await loadConfigFromFile(
{ command: 'serve', mode: 'development' },
resolve(__dirname, '../vite.config.ts'),
) || {};
return mergeConfig(config, {
// Merge only the resolve/alias parts if you want to be safe
resolve: userConfig?.resolve || {},
});
},
};
export default config;
-13
View File
@@ -1,13 +0,0 @@
<link rel="preconnect" href="https://api.fontshare.com" />
<link rel="preconnect" href="https://cdn.fontshare.com" crossorigin="anonymous" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Barlow:ital,wght@0,100;0,200;1,100;1,200&family=Karla:wght@200..800&family=Major+Mono+Display&display=swap"
/>
<style>
body {
font-family: "Karla", system-ui, -apple-system, "Segoe UI", Inter, Roboto, Arial, sans-serif;
}
</style>
-146
View File
@@ -1,146 +0,0 @@
import type { Preview } from '@storybook/svelte-vite';
import Decorator from './Decorator.svelte';
import StoryStage from './StoryStage.svelte';
import ThemeDecorator from './ThemeDecorator.svelte';
import '../src/app/styles/app.css';
const preview: Preview = {
parameters: {
layout: 'padded',
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
a11y: {
// 'todo' - show a11y violations in the test UI only
// 'error' - fail CI on a11y violations
// 'off' - skip a11y checks entirely
test: 'todo',
},
docs: {
story: {
// This sets the default height for the iframe in Autodocs
iframeHeight: '600px',
},
},
viewport: {
viewports: {
// Mobile devices
mobile1: {
name: 'iPhone 5/SE',
styles: {
width: '320px',
height: '568px',
},
},
mobile2: {
name: 'iPhone 14 Pro Max',
styles: {
width: '414px',
height: '896px',
},
},
// Tablet
tablet: {
name: 'iPad (Portrait)',
styles: {
width: '834px',
height: '1112px',
},
},
desktop: {
name: 'Desktop (Small)',
styles: {
width: '1024px',
height: '1280px',
},
},
// Widget-specific viewports
widgetMedium: {
name: 'Widget Medium',
styles: {
width: '768px',
height: '800px',
},
},
widgetWide: {
name: 'Widget Wide',
styles: {
width: '1024px',
height: '800px',
},
},
widgetExtraWide: {
name: 'Widget Extra Wide',
styles: {
width: '1280px',
height: '800px',
},
},
// Full-width viewports
fullWidth: {
name: 'Full Width',
styles: {
width: '100%',
height: '800px',
},
},
fullScreen: {
name: 'Full Screen',
styles: {
width: '100%',
height: '100%',
},
},
},
},
head: `
<link rel="preconnect" href="https://api.fontshare.com" />
<link rel="preconnect" href="https://cdn.fontshare.com" crossorigin="anonymous" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Barlow:ital,wght@0,100;0,200;1,100;1,200&family=Karla:wght@200..800&family=Major+Mono+Display&display=swap"
/>
<style>
body {
font-family: "Karla", system-ui, -apple-system, "Segoe UI", Inter, Roboto, Arial, sans-serif;
}
</style>
`,
},
decorators: [
// Outermost: initialize ThemeManager for all stories
story => ({
Component: ThemeDecorator,
props: {
children: story(),
},
}),
// Wrap with providers (TooltipProvider, ResponsiveManager)
story => ({
Component: Decorator,
props: {
children: story(),
},
}),
// Wrap with StoryStage for presentation styling
(story, context) => ({
Component: StoryStage,
props: {
children: story(),
maxWidth: context.parameters.storyStage?.maxWidth,
},
}),
],
};
export default preview;
-7
View File
@@ -1,7 +0,0 @@
import * as a11yAddonAnnotations from '@storybook/addon-a11y/preview';
import { setProjectAnnotations } from '@storybook/svelte-vite';
import * as projectAnnotations from './preview';
// This is an important step to apply the right configuration when testing your stories.
// More info at: https://storybook.js.org/docs/api/portable-stories/portable-stories-vitest#setprojectannotations
setProjectAnnotations([a11yAddonAnnotations, projectAnnotations]);
Binary file not shown.
-28
View File
@@ -1,28 +0,0 @@
:3000 {
root * /usr/share/caddy
# Compress text responses only. woff2/png and other binaries are already
# compressed, so they're excluded — re-compressing them burns CPU for ~0%.
encode {
zstd
gzip
match {
header Content-Type text/*
header Content-Type application/javascript*
header Content-Type application/json*
header Content-Type image/svg+xml*
}
}
# Vite emits all build output under /assets/ with content-hashed filenames,
# so those bytes never change for a given URL — cache them indefinitely.
@assets path /assets/*
header @assets Cache-Control "public, max-age=31536000, immutable"
# The HTML shell is the un-hashed entry point; it must revalidate so a new
# deploy is served immediately rather than from a stale cache.
header /index.html Cache-Control "no-cache"
try_files {path} /index.html
file_server
}
-22
View File
@@ -1,22 +0,0 @@
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
# Enable Corepack so we can use Yarn v4 (pinned to match lockfile)
RUN corepack enable && corepack prepare yarn@4.11.0 --activate
# Force Yarn to use node_modules instead of PnP
ENV YARN_NODE_LINKER=node-modules
COPY package.json yarn.lock ./
RUN yarn install --immutable
COPY . .
RUN yarn build && ls -la dist
# Production stage - Caddy
FROM caddy:2-alpine
WORKDIR /usr/share/caddy
# Copy built static files from the builder stage
COPY --from=builder /app/dist .
# Copy our local Caddyfile config
COPY Caddyfile /etc/caddy/Caddyfile
EXPOSE 3000
# Start caddy using the config file
CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"]
+22 -62
View File
@@ -1,78 +1,38 @@
# GlyphDiff # sv
A modern font exploration and comparison tool for browsing fonts from Google Fonts and Fontshare with real-time visual comparisons, advanced filtering, and customizable typography. Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
## Features ## Creating a project
- **Multi-Provider Catalog**: Browse fonts from Google Fonts and Fontshare in one place If you're seeing this, you've probably already done this step. Congrats!
- **Side-by-Side Comparison**: Compare up to 4 fonts simultaneously with customizable text, size, and typography settings
- **Advanced Filtering**: Filter by category, provider, character subsets, and weight
- **Virtual Scrolling**: Fast, smooth browsing of thousands of fonts
- **Responsive UI**: Beautiful interface built with Tailwind CSS
- **Type-Safe**: Full TypeScript coverage with strict mode enabled
## Tech Stack ```sh
# create a new project in the current directory
npx sv create
- **Framework**: Svelte 5 with reactive primitives (runes) # create a new project in my-app
- **Styling**: Tailwind CSS v4 npx sv create my-app
- **Components**: Bits UI primitives
- **State Management**: TanStack Query for async data
- **Architecture**: Feature-Sliced Design (FSD)
- **Quality**: oxlint (linting), dprint (formatting), lefthook (git hooks)
## Project Structure
```
src/
├── app/ # App shell, layout, providers
├── widgets/ # Composed UI blocks (ComparisonSlider, SampleList, FontSearch)
├── features/ # Business features (filters, search, display)
├── entities/ # Domain models and stores (Font, Breadcrumb)
├── shared/ # Reusable utilities, UI components, helpers
└── routes/ # Page-level components
``` ```
## Quick Start ## Developing
```bash Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
# Install dependencies
yarn install
# Start development server ```sh
yarn dev npm run dev
# Build for production # or start the server and open the app in a new browser tab
yarn build npm run dev -- --open
# Preview production build
yarn preview
``` ```
## Available Scripts ## Building
| Command | Description | To create a production version of your app:
| ------------------- | -------------------------- |
| `yarn dev` | Start development server |
| `yarn build` | Build for production |
| `yarn preview` | Preview production build |
| `yarn check` | Run Svelte type checking |
| `yarn lint` | Run oxlint |
| `yarn format` | Format code with dprint |
| `yarn test:unit` | Run unit tests |
| `yarn test:unit:ui` | Run Vitest UI |
| `yarn storybook` | Start Storybook dev server |
## Code Style ```sh
npm run build
```
- **Path Aliases**: Use `$app/`, `$shared/`, `$features/`, `$entities/`, `$widgets/`, `$routes/` You can preview the production build with `npm run preview`.
- **Components**: PascalCase (e.g., `ComparisonSlider.svelte`)
- **Formatting**: 100 char line width, 4-space indent, single quotes
- **Type Safety**: Strict TypeScript with JSDoc comments for public APIs
## Architecture Notes > To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
This project follows the Feature-Sliced Design (FSD) methodology for clean separation of concerns. The application uses Svelte 5's new runes system (`$state`, `$derived`, `$effect`) for reactive state management.
## License
MIT
+16
View File
@@ -0,0 +1,16 @@
{
"$schema": "https://shadcn-svelte.com/schema.json",
"tailwind": {
"css": "src/app.css",
"baseColor": "zinc"
},
"aliases": {
"components": "$lib/components",
"utils": "$lib/utils",
"ui": "$lib/components/ui",
"hooks": "$lib/hooks",
"lib": "$lib"
},
"typescript": true,
"registry": "https://shadcn-svelte.com/registry"
}
+592
View File
@@ -0,0 +1,592 @@
# Git Workflow and Branching Strategy
This document outlines the git workflow, branching strategy, commit conventions, and code review guidelines for the glyphdiff.com project.
## Table of Contents
1. [Branching Strategy](#branching-strategy)
2. [Branch Naming Conventions](#branch-naming-conventions)
3. [Commit Message Conventions](#commit-message-conventions)
4. [Code Splitting and Merge Request Guidelines](#code-splitting-and-merge-request-guidelines)
5. [Branch Protection Rules](#branch-protection-rules)
6. [Git Hooks Configuration](#git-hooks-configuration)
---
## Branching Strategy
We use a Gitflow-inspired branching strategy adapted for our development workflow. This strategy provides a clear structure for feature development, bug fixes, and releases.
### Branch Types
#### 1. `main` Branch
- **Purpose**: Production-ready code only
- **Protection**: Highest level of protection
- **Rules**:
- Only merge `release/*` or `hotfix/*` branches into `main`
- No direct commits allowed
- Must pass all tests and code reviews
- Tags are created from this branch for releases (e.g., `v1.0.0`)
#### 2. `develop` Branch
- **Purpose**: Integration branch for features
- **Protection**: High level of protection
- **Rules**:
- Merge `feature/*` and `fix/*` branches into `develop`
- No direct commits allowed
- Must pass all tests before merging
- Serves as the base for `release/*` branches
#### 3. `feature/*` Branches
- **Purpose**: Develop new features
- **Naming**: `feature/feature-name` (e.g., `feature/font-catalog`, `feature/comparison-grid`)
- **Base**: Always branch from `develop`
- **Merge**: Merge back into `develop` via Merge Request (MR)
- **Rules**:
- One feature per branch
- Keep branches focused and small
- Delete after merging
#### 4. `fix/*` Branches
- **Purpose**: Fix bugs discovered during development
- **Naming**: `fix/issue-description` (e.g., `fix/font-loading-error`, `fix/responsive-layout`)
- **Base**: Branch from `develop`
- **Merge**: Merge back into `develop` via MR
- **Rules**:
- One fix per branch
- Include tests that verify the fix
- Delete after merging
#### 5. `hotfix/*` Branches
- **Purpose**: Critical fixes for production issues
- **Naming**: `hotfix/critical-fix` (e.g., `hotfix/security-patch`, `hotfix-production-crash`)
- **Base**: Branch from `main`
- **Merge**: Merge into both `main` and `develop`
- **Rules**:
- Use only for production emergencies
- Must be thoroughly tested
- Create a release tag after merging to `main`
#### 6. `release/*` Branches
- **Purpose**: Prepare for a new release
- **Naming**: `release/vX.Y.Z` (e.g., `release/v1.0.0`, `release/v1.1.0`)
- **Base**: Branch from `develop`
- **Merge**: Merge into both `main` and `develop`
- **Rules**:
- Finalize release notes
- Update version numbers
- Perform final testing
- Create release tag after merging to `main`
### Branch Workflow Diagram
```
main (production)
│ hotfix/*, release/*
develop (integration)
│ feature/*, fix/*
feature branches
```
---
## Branch Naming Conventions
### Feature Branches
- Format: `feature/feature-name`
- Examples:
- `feature/font-catalog`
- `feature/comparison-grid`
- `feature/dark-mode`
- `feature/google-fonts-integration`
### Fix Branches
- Format: `fix/issue-description`
- Examples:
- `fix/font-loading-error`
- `fix/responsive-layout`
- `fix/state-persistence`
- `fix-accessibility-contrast`
### Hotfix Branches
- Format: `hotfix/critical-fix`
- Examples:
- `hotfix/security-patch`
- `hotfix-production-crash`
- `hotfix-api-rate-limit`
### Release Branches
- Format: `release/vX.Y.Z`
- Examples:
- `release/v1.0.0`
- `release/v1.1.0`
- `release/v2.0.0`
### Naming Guidelines
- Use lowercase letters
- Use hyphens to separate words
- Be descriptive but concise
- Avoid special characters (except hyphens)
- Keep names under 50 characters
---
## Commit Message Conventions
We follow the [Conventional Commits](https://www.conventionalcommits.org/) specification. This format enables automated changelog generation and better commit history readability.
### Format
```
<type>(<scope>): <subject>
<body>
<footer>
```
### Commit Types
| Type | Description | Examples |
|------|-------------|----------|
| `feat` | New feature | `feat(fonts): add Google Fonts integration` |
| `fix` | Bug fix | `fix(comparison): resolve font loading race condition` |
| `docs` | Documentation changes | `docs(readme): update installation instructions` |
| `style` | Code style changes (formatting, etc.) | `style(components): format with Prettier` |
| `refactor` | Code refactoring | `refactor(stores): simplify state management` |
| `test` | Adding or updating tests | `test(fonts): add unit tests for font mapper` |
| `chore` | Maintenance tasks | `chore(deps): update Tailwind CSS to v4.0` |
| `perf` | Performance improvements | `perf(catalog): implement lazy loading for fonts` |
### Scope
The scope provides context about which part of the codebase is affected. Common scopes for this project:
- `fonts` - Font-related functionality
- `comparison` - Font comparison features
- `catalog` - Font catalog pages
- `stores` - State management stores
- `components` - UI components
- `routes` - SvelteKit routes
- `services` - External API services
- `utils` - Utility functions
- `types` - TypeScript type definitions
- `ui` - UI-related changes (theme, layout, etc.)
- `config` - Configuration files
### Subject
- Use imperative mood ("add" not "added", "fix" not "fixed")
- Keep it short (50 characters or less)
- Don't end with a period
- Be specific and descriptive
### Body
- Use imperative mood
- Explain **what** and **why**, not **how**
- Wrap at 72 characters
- Include references to issues (e.g., `Closes #123`)
### Footer
- Reference breaking changes with `BREAKING CHANGE:`
- Reference issues with `Closes #123` or `Fixes #456`
- Include co-authors if needed
### Examples
#### Feature Commit
```
feat(fonts): add Google Fonts API integration
Implement Google Fonts API service to fetch and display available fonts.
This includes the fetchGoogleFonts function and font mapper utilities.
Closes #12
```
#### Bug Fix Commit
```
fix(comparison): resolve font loading race condition
The comparison grid was attempting to render fonts before they were fully
loaded. Added loading state checks to prevent this issue.
Fixes #45
```
#### Refactor Commit
```
refactor(stores): simplify state management with Svelte 5 runes
Migrated from Svelte stores to Svelte 5's $state runes for better
performance and simpler code. This change affects all stores in the
project.
BREAKING CHANGE: Store API has changed from subscribe() to direct
property access. Update all store consumers accordingly.
```
#### Documentation Commit
```
docs(git-workflow): add commit message conventions
Document the conventional commits format with examples and guidelines
for the team.
```
#### Chore Commit
```
chore(deps): update Tailwind CSS to v4.0.0
Update Tailwind CSS to the latest version and adjust configuration
files accordingly.
```
---
## Code Splitting and Merge Request Guidelines
### Merge Request Size Guidelines
- **Maximum MR size**: < 500 lines changed (additions + deletions)
- **Ideal MR size**: 100-300 lines changed
- **Files per MR**: < 10 files
### When to Split a Feature into Multiple MRs
Split a feature into multiple MRs when:
1. **The feature is large** (> 500 lines or > 10 files)
2. **Multiple concerns are involved** (e.g., UI + API + state management)
3. **Independent parts can be tested separately**
4. **The feature has logical phases** (e.g., setup → implementation → polish)
### Example: Splitting a Feature
**Feature**: Font Catalog with Filtering
**MR 1**: `feature/font-catalog-setup`
- Create basic catalog page structure
- Set up routing
- Add placeholder components
- ~150 lines
**MR 2**: `feature/font-catalog-data`
- Implement Google Fonts API integration
- Create font data fetching logic
- Add font mapper utilities
- ~200 lines
**MR 3**: `feature/font-catalog-ui`
- Build FontCard component
- Implement grid layout
- Add loading states
- ~250 lines
**MR 4**: `feature/font-catalog-filtering`
- Implement filter store
- Build FilterBar component
- Connect filters to catalog
- ~180 lines
### Merge Request Description Template
Every MR must include a comprehensive description:
```markdown
## Description
Brief description of what this MR changes and why.
## Changes Made
- [ ] Change 1
- [ ] Change 2
- [ ] Change 3
## Type of Change
- [ ] Bug fix
- [ ] New feature
- [ ] Breaking change
- [ ] Documentation update
- [ ] Refactoring
- [ ] Performance improvement
## Testing
- [ ] Unit tests pass
- [ ] Manual testing completed
- [ ] Tested on Chrome
- [ ] Tested on Firefox
- [ ] Tested on Safari
- [ ] Tested on mobile (responsive)
## Screenshots (if applicable)
Add screenshots or GIFs showing the changes.
## Checklist
- [ ] Code follows project style guidelines
- [ ] Self-review completed
- [ ] Comments added for complex logic
- [ ] Documentation updated
- [ ] No new warnings generated
- [ ] Tests added/updated
- [ ] All tests passing
## Related Issues
Closes #123
Related to #456
```
### Code Review Checklist
Reviewers should check:
#### Functionality
- [ ] Does the code work as intended?
- [ ] Are edge cases handled?
- [ ] Is error handling appropriate?
#### Code Quality
- [ ] Is the code readable and maintainable?
- [ ] Are variable/function names descriptive?
- [ ] Is there unnecessary complexity?
- [ ] Are there code duplications?
#### Best Practices
- [ ] Does it follow project conventions?
- [ ] Are TypeScript types properly defined?
- [ ] Are Svelte best practices followed?
- [ ] Is Tailwind CSS used appropriately?
#### Testing
- [ ] Are tests included?
- [ ] Do tests cover edge cases?
- [ ] Are tests meaningful and not redundant?
#### Documentation
- [ ] Is the code self-documenting?
- [ ] Are complex functions commented?
- [ ] Is the MR description clear?
#### Performance
- [ ] Are there performance concerns?
- [ ] Is lazy loading used where appropriate?
- [ ] Are unnecessary re-renders avoided?
### Merge Request Approval Process
1. **Author**: Creates MR with complete description
2. **Reviewer**: Reviews code using the checklist above
3. **Discussion**: Address any concerns or suggestions
4. **Approval**: At least one approval required
4. **Merge**: Squash and merge into target branch
5. **Cleanup**: Delete source branch after merge
---
## Branch Protection Rules
### `main` Branch Protection
- **Require pull request reviews**: Yes
- Required approvers: 1
- Dismiss stale reviews: Yes
- **Require status checks**: Yes
- Required checks: All tests, linting
- Require branches to be up to date: Yes
- **Restrict who can push**: Only maintainers
- **Require linear history**: Yes (squash and merge)
- **Block force pushes**: Yes
### `develop` Branch Protection
- **Require pull request reviews**: Yes
- Required approvers: 1
- Dismiss stale reviews: Yes
- **Require status checks**: Yes
- Required checks: All tests, linting
- Require branches to be up to date: Yes
- **Restrict who can push**: Only developers and maintainers
- **Require linear history**: Yes (squash and merge)
- **Block force pushes**: Yes
### Implementation Notes
These rules should be configured in your Git hosting platform (GitHub, GitLab, or Bitbucket). The exact configuration steps vary by platform:
- **GitHub**: Settings → Branches → Add rule
- **GitLab**: Settings → Repository → Protected branches
- **Bitbucket**: Repository settings → Branch restrictions
---
## Git Hooks Configuration
Git hooks are automated scripts that run at specific points in the git workflow. They help maintain code quality and consistency.
### Recommended Hooks
#### 1. Pre-commit Hook
**Purpose**: Run linter and formatter before committing
**Tools**: ESLint, Prettier
**Implementation**:
```bash
#!/bin/sh
# .git/hooks/pre-commit
# Run Prettier
npm run format:check
# Run ESLint
npm run lint
# Exit with error if any check fails
if [ $? -ne 0 ]; then
echo "❌ Pre-commit checks failed. Please fix the issues before committing."
exit 1
fi
echo "✅ Pre-commit checks passed."
```
**Setup**:
```bash
# Install husky (recommended)
npm install --save-dev husky
# Initialize husky
npx husky install
# Add pre-commit hook
npx husky add .husky/pre-commit "npm run lint && npm run format:check"
```
#### 2. Commit-msg Hook
**Purpose**: Validate commit message format
**Tools**: commitlint
**Implementation**:
```bash
#!/bin/sh
# .git/hooks/commit-msg
# Validate commit message with commitlint
npx --no -- commitlint --edit $1
```
**Setup**:
```bash
# Install commitlint
npm install --save-dev @commitlint/cli @commitlint/config-conventional
# Create commitlint config
echo "module.exports = { extends: ['@commitlint/config-conventional'] };" > commitlint.config.js
# Add commit-msg hook
npx husky add .husky/commit-msg "npx --no -- commitlint --edit \$1"
```
#### 3. Pre-push Hook
**Purpose**: Run tests before pushing
**Tools**: Vitest, SvelteKit test runner
**Implementation**:
```bash
#!/bin/sh
# .git/hooks/pre-push
# Run tests
npm run test
# Exit with error if tests fail
if [ $? -ne 0 ]; then
echo "❌ Tests failed. Please fix the failing tests before pushing."
exit 1
fi
echo "✅ All tests passed."
```
**Setup**:
```bash
# Add pre-push hook
npx husky add .husky/pre-push "npm run test"
```
### Alternative: Using Husky
[Husky](https://typicode.github.io/husky/) is a popular tool for managing git hooks. It's easier to maintain and works across different operating systems.
**Installation**:
```bash
npm install --save-dev husky
npx husky install
npm pkg set scripts.prepare="husky install"
```
**Adding hooks**:
```bash
# Pre-commit hook
npx husky add .husky/pre-commit "npm run lint && npm run format:check"
# Commit-msg hook
npx husky add .husky/commit-msg "npx --no -- commitlint --edit \$1"
# Pre-push hook
npx husky add .husky/pre-push "npm run test"
```
### Hook Scripts for This Project
Once the project is set up with SvelteKit, add these scripts to `package.json`:
```json
{
"scripts": {
"format": "prettier --write .",
"format:check": "prettier --check .",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"test": "vitest run",
"test:watch": "vitest",
"prepare": "husky install"
}
}
```
### Benefits of Git Hooks
1. **Consistency**: Enforce code style and formatting
2. **Quality**: Catch bugs before they're committed
3. **Efficiency**: Fail fast, fix early
4. **Automation**: Reduce manual checks
5. **Team alignment**: Ensure everyone follows the same standards
---
## Summary
This git workflow provides a structured approach to development for the glyphdiff.com project:
- **Clear branching strategy** with defined purposes for each branch type
- **Conventional commits** for readable and automated changelogs
- **Code splitting guidelines** to keep MRs focused and reviewable
- **Comprehensive review process** to maintain code quality
- **Git hooks** to automate quality checks
Following this workflow will help the team:
- Develop features in parallel without conflicts
- Maintain a clean git history
- Catch issues early in the development process
- Ensure code quality and consistency
- Streamline the release process
For questions or suggestions about this workflow, please discuss with the team or create an issue in the project repository.
+6 -28
View File
@@ -1,5 +1,4 @@
{ {
"$schema": "https://dprint.dev/schemas/v0.json",
"incremental": true, "incremental": true,
"includes": ["**/*.{ts,tsx,js,jsx,svelte,json,md}"], "includes": ["**/*.{ts,tsx,js,jsx,svelte,json,md}"],
"excludes": [ "excludes": [
@@ -13,35 +12,17 @@
"https://plugins.dprint.dev/typescript-0.93.0.wasm", "https://plugins.dprint.dev/typescript-0.93.0.wasm",
"https://plugins.dprint.dev/json-0.19.3.wasm", "https://plugins.dprint.dev/json-0.19.3.wasm",
"https://plugins.dprint.dev/markdown-0.17.8.wasm", "https://plugins.dprint.dev/markdown-0.17.8.wasm",
"https://plugins.dprint.dev/g-plane/markup_fmt-v0.27.0.wasm" "https://plugins.dprint.dev/g-plane/markup_fmt-v0.25.3.wasm"
], ],
"typescript": { "typescript": {
"lineWidth": 120, "lineWidth": 100,
"indentWidth": 4, "indentWidth": 4,
"useTabs": false, "useTabs": false,
"semiColons": "prefer", "semiColons": "prefer",
"quoteStyle": "preferSingle", "quoteStyle": "preferSingle",
"trailingCommas": "onlyMultiLine", "trailingCommas": "onlyMultiLine",
"arrowFunction.useParentheses": "preferNone", "arrowFunction.useParentheses": "preferNone",
"importDeclaration.sortNamedImports": "caseInsensitive"
"module.sortImportDeclarations": "caseSensitive",
"module.sortExportDeclarations": "caseSensitive",
"importDeclaration.sortNamedImports": "caseSensitive",
"importDeclaration.forceMultiLine": "whenMultiple",
"importDeclaration.forceSingleLine": false,
"exportDeclaration.forceMultiLine": "whenMultiple",
"exportDeclaration.forceSingleLine": false,
"ifStatement.useBraces": "always",
"ifStatement.singleBodyPosition": "nextLine",
"whileStatement.useBraces": "always",
"whileStatement.singleBodyPosition": "nextLine",
"forStatement.useBraces": "always",
"forStatement.singleBodyPosition": "nextLine",
"forInStatement.useBraces": "always",
"forInStatement.singleBodyPosition": "nextLine",
"forOfStatement.useBraces": "always",
"forOfStatement.singleBodyPosition": "nextLine"
}, },
"json": { "json": {
"indentWidth": 2, "indentWidth": 2,
@@ -51,14 +32,11 @@
"lineWidth": 100 "lineWidth": 100
}, },
"markup": { "markup": {
"printWidth": 120, "printWidth": 100,
"indentWidth": 4, "indentWidth": 4,
"useTabs": false, "useTabs": false,
"quotes": "double", "quotes": "double",
"scriptIndent": false, "scriptIndent": true,
"styleIndent": false, "styleIndent": true
"formatComments": true,
"svelteAttrShorthand": true,
"svelteDirectiveShorthand": true
} }
} }
-39
View File
@@ -1,39 +0,0 @@
import {
expect,
test,
} from './fixtures';
test.describe('compare flow', () => {
test('selects fontA and fontB onto opposite sides', async ({ comparison }) => {
await comparison.pickPair('Inter', 'Roboto');
// Each side's header region exposes the font name independently.
await expect(comparison.primaryFont).toContainText('Inter');
await expect(comparison.secondaryFont).toContainText('Roboto');
// Slider is rendered and interactive once both fonts are picked.
await expect(comparison.slider).toBeVisible();
});
test('reflects active side via aria-pressed', async ({ comparison }) => {
await comparison.selectSide('B');
expect(await comparison.activeSide()).toBe('B');
await expect(comparison.secondarySideButton).toHaveAttribute('aria-pressed', 'true');
await expect(comparison.primarySideButton).toHaveAttribute('aria-pressed', 'false');
});
test('persists selection through the comparisonStore localStorage', async ({ comparison }) => {
await comparison.pickPair('Inter', 'Roboto');
// Wait for the store debounce to flush to localStorage.
await expect.poll(async () => {
const storage = await comparison.readStorage();
return storage['glyphdiff:comparison'];
}).toMatch(/inter/i);
const storage = await comparison.readStorage();
const state = JSON.parse(storage['glyphdiff:comparison']!);
expect(state.fontAId).toBe('inter');
expect(state.fontBId).toBe('roboto');
});
});
+6
View File
@@ -0,0 +1,6 @@
import { expect, test } from '@playwright/test';
test('home page has expected h1', async ({ page }) => {
await page.goto('/');
await expect(page.locator('h1')).toBeVisible();
});
-35
View File
@@ -1,35 +0,0 @@
import { test as base } from '@playwright/test';
import { ComparisonPage } from './pages/comparison-page';
import { TypographyMenu } from './pages/typography-menu';
type Fixtures = {
/**
* Opened ComparisonPage with the root view loaded.
*/
comparison: ComparisonPage;
/**
* Typography menu helper bound to the same page.
*/
typography: TypographyMenu;
};
/**
* Custom test that auto-opens the comparison view before each spec.
* Playwright gives each test a fresh BrowserContext by default, so
* localStorage is empty unless a test seeds it.
*/
export const test = base.extend<Fixtures>({
comparison: async ({ page }, use) => {
const view = new ComparisonPage(page);
await view.open();
await use(view);
},
// Depends on `comparison` so the root page is opened before the menu is
// consulted — TypographyMenu has no markup of its own to load.
typography: async ({ comparison, page }, use) => {
void comparison;
await use(new TypographyMenu(page));
},
});
export { expect } from '@playwright/test';
-22
View File
@@ -1,22 +0,0 @@
import {
expect,
test,
} from './fixtures';
test.describe('font loading', () => {
test('selected fonts land in the FontFaceSet with status="loaded"', async ({ comparison }) => {
await comparison.pickPair('Inter', 'Roboto');
await expect.poll(() => comparison.fontLoaded('Inter')).toBe(true);
await expect.poll(() => comparison.fontLoaded('Roboto')).toBe(true);
});
test('an unrelated font remains absent from the FontFaceSet', async ({ comparison }) => {
await comparison.pickPair('Inter', 'Roboto');
// "Audiowide" is unlikely to be on the system AND was not selected, so
// no FontFace should ever have been registered for it. This guards
// against the loader over-fetching neighbouring fonts.
await expect.poll(() => comparison.fontLoaded('Audiowide')).toBe(false);
});
});
-16
View File
@@ -1,16 +0,0 @@
import type { Page } from '@playwright/test';
/**
* Shared base for all page objects. Subclasses extend this and expose
* domain-specific locators + actions — never raw selectors leaking into tests.
*/
export abstract class BasePage {
protected constructor(protected readonly page: Page) {}
/**
* Navigate to a path relative to baseURL.
*/
async goto(path = '/') {
await this.page.goto(path);
}
}
-144
View File
@@ -1,144 +0,0 @@
import type {
Locator,
Page,
} from '@playwright/test';
import { BasePage } from './base-page';
/**
* Page object for the root comparison view. Encapsulates locators for the
* primary controls so tests don't hardcode aria-labels or DOM structure.
*
* Selection flow: clicking a font row assigns it to whichever side
* (`A` = "Left Font" / Primary, `B` = "Right Font" / Secondary) is currently
* active in the Sidebar — there's no per-row A/B toggle.
*/
export class ComparisonPage extends BasePage {
readonly searchInput: Locator;
readonly previewInput: Locator;
readonly slider: Locator;
readonly primarySideButton: Locator;
readonly secondarySideButton: Locator;
readonly primaryFont: Locator;
readonly secondaryFont: Locator;
readonly fontList: Locator;
constructor(page: Page) {
super(page);
this.searchInput = page.getByRole('textbox', { name: 'Search typefaces' });
this.previewInput = page.getByRole('textbox', { name: 'Preview text' });
this.slider = page.getByRole('slider', { name: 'Font comparison slider' });
// ARIA-controls couples the side toggle to the font display it targets — copy-independent.
this.primarySideButton = page.locator('[aria-controls="primary-font"]');
this.secondarySideButton = page.locator('[aria-controls="secondary-font"]');
this.primaryFont = page.locator('#primary-font');
this.secondaryFont = page.locator('#secondary-font');
this.fontList = page.locator('[data-font-list]');
}
/**
* Open the root page and wait for the main controls to be interactable.
* Uses lg+ viewport for the preview input to be visible.
*/
async open() {
await this.goto('/');
await this.searchInput.waitFor({ state: 'visible' });
}
async searchFor(query: string) {
await this.searchInput.fill(query);
}
async setPreviewText(text: string) {
await this.previewInput.fill(text);
}
/**
* Switch which side the next font click will assign to.
*/
async selectSide(side: 'A' | 'B') {
const button = side === 'A' ? this.primarySideButton : this.secondarySideButton;
await button.click();
}
/**
* Read which side is currently active from `aria-pressed`.
* Falls back to A when neither button reports pressed (initial state in some flows).
*/
async activeSide(): Promise<'A' | 'B' | null> {
const [primaryPressed, secondaryPressed] = await Promise.all([
this.primarySideButton.getAttribute('aria-pressed'),
this.secondarySideButton.getAttribute('aria-pressed'),
]);
if (primaryPressed === 'true') {
return 'A';
}
if (secondaryPressed === 'true') {
return 'B';
}
return null;
}
/**
* Search for a font and click the matching list row. The row's accessible
* name is the font name itself (rendered by FontApplicator).
*/
async pickFont(name: string) {
await this.searchFor(name);
const row = this.fontList.getByRole('button', { name, exact: true });
await row.click();
}
/**
* Assign fontA to side A and fontB to side B in one call.
*/
async pickPair(fontA: string, fontB: string) {
await this.selectSide('A');
await this.pickFont(fontA);
await this.selectSide('B');
await this.pickFont(fontB);
}
/**
* Read aria-valuenow off the comparison slider.
*/
async sliderValue(): Promise<number> {
const value = await this.slider.getAttribute('aria-valuenow');
return Number(value);
}
/**
* Snapshot the glyphdiff:* localStorage entries.
*/
async readStorage(): Promise<Record<string, string | null>> {
return await this.page.evaluate(() => {
const out: Record<string, string | null> = {};
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i)!;
if (key.startsWith('glyphdiff:')) {
out[key] = localStorage.getItem(key);
}
}
return out;
});
}
/**
* Whether the document.fonts FontFaceSet contains a fully-loaded face for
* the named family. Counts only faces registered via the FontFace API —
* system-installed fallbacks (which `document.fonts.check` honours) are
* excluded, so a `false` here is meaningful in negative assertions.
*/
async fontLoaded(name: string): Promise<boolean> {
return await this.page.evaluate(target => {
for (const face of document.fonts) {
// FontFace.family is wrapped in quotes only if the literal was;
// strip any surrounding quotes before comparing.
const family = face.family.replace(/^["']|["']$/g, '');
if (family === target && face.status === 'loaded') {
return true;
}
}
return false;
}, name);
}
}
-73
View File
@@ -1,73 +0,0 @@
import type {
Locator,
Page,
} from '@playwright/test';
/**
* Typography settings menu — desktop layout exposes inline ComboControls with
* increase/decrease buttons. The current value is encoded in the trigger
* button's aria-label as `${controlLabel}: ${value}` (e.g. "Size: 24").
*/
export type TypographyControl = 'size' | 'weight' | 'leading' | 'tracking';
const LABELS: Record<TypographyControl, { increase: string; decrease: string; trigger: string }> = {
size: {
increase: 'Increase Font Size',
decrease: 'Decrease Font Size',
trigger: 'Size',
},
weight: {
increase: 'Increase Font Weight',
decrease: 'Decrease Font Weight',
trigger: 'Weight',
},
leading: {
increase: 'Increase Line Height',
decrease: 'Decrease Line Height',
trigger: 'Leading',
},
tracking: {
increase: 'Increase Letter Spacing',
decrease: 'Decrease Letter Spacing',
trigger: 'Tracking',
},
};
export class TypographyMenu {
constructor(private readonly page: Page) {}
increase(control: TypographyControl): Locator {
return this.page.getByRole('button', { name: LABELS[control].increase });
}
decrease(control: TypographyControl): Locator {
return this.page.getByRole('button', { name: LABELS[control].decrease });
}
/**
* Trigger button whose aria-label encodes the current value, e.g. "Size: 24".
*/
trigger(control: TypographyControl): Locator {
return this.page.getByRole('button', { name: new RegExp(`^${LABELS[control].trigger}:\\s`) });
}
/**
* Parse the numeric value out of the trigger button's aria-label.
* Returns null if the label can't be read yet.
*/
async readValue(control: TypographyControl): Promise<number | null> {
const label = await this.trigger(control).getAttribute('aria-label');
if (!label) {
return null;
}
const match = label.match(/:\s*(-?\d+(?:\.\d+)?)/);
return match ? Number(match[1]) : null;
}
async bump(control: TypographyControl, direction: 'up' | 'down', times = 1) {
const button = direction === 'up' ? this.increase(control) : this.decrease(control);
for (let i = 0; i < times; i++) {
await button.click();
}
}
}
-41
View File
@@ -1,41 +0,0 @@
import {
expect,
test,
} from './fixtures';
test.describe('persistence', () => {
test('restores selected fonts after reload', async ({ comparison, page }) => {
await comparison.pickPair('Inter', 'Roboto');
// Confirm the store has flushed before reloading — otherwise we race
// the debounce and may reload with empty storage.
await expect.poll(async () => {
const storage = await comparison.readStorage();
return storage['glyphdiff:comparison'];
}).toMatch(/roboto/i);
await page.reload();
await comparison.searchInput.waitFor({ state: 'visible' });
await expect(comparison.primaryFont).toContainText('Inter');
await expect(comparison.secondaryFont).toContainText('Roboto');
});
test('restores typography settings after reload', async ({ comparison, typography, page }) => {
const baseline = await typography.readValue('size');
await typography.bump('size', 'up', 2);
const bumped = await typography.readValue('size');
expect(bumped).not.toBe(baseline);
await expect.poll(async () => {
const storage = await comparison.readStorage();
return storage['glyphdiff:comparison:typography'];
}).not.toBeNull();
await page.reload();
await comparison.searchInput.waitFor({ state: 'visible' });
expect(await typography.readValue('size')).toBe(bumped);
});
});
-32
View File
@@ -1,32 +0,0 @@
import { windowSizeForLine } from '../src/entities/Font/domain/windowSizeForLine/windowSizeForLine';
import {
expect,
test,
} from './fixtures';
test.describe('preview text', () => {
test('drives the slider character rendering', async ({ comparison }) => {
/**
* Must stay a single unwrapped line of ASCII: the assertion feeds
* `text.length` (UTF-16 code units) to `windowSizeForLine`, but the
* renderer feeds it the line's grapheme count. They match only for
* plain ASCII — emoji/combining marks (length > graphemes) or wrapping
* (one input string splitting into several lines) silently desync them.
*/
const text = 'Sphinx';
await comparison.pickPair('Inter', 'Roboto');
await comparison.setPreviewText(text);
// Window chars render as `.char-wrap` cells for crossfade. The window
// size is a pure function of the line's grapheme count — assert against
// the rule, not a hardcoded constant, so tuning the policy can't silently
// break this. "Sphinx" is one unwrapped line of 6 graphemes.
await expect(comparison.slider.locator('.char-wrap')).toHaveCount(windowSizeForLine(text.length));
});
test('preserves the typed value in the input', async ({ comparison }) => {
const text = 'Sphinx of black quartz';
await comparison.setPreviewText(text);
await expect(comparison.previewInput).toHaveValue(text);
});
});
-46
View File
@@ -1,46 +0,0 @@
import {
expect,
test,
} from './fixtures';
/**
* Slider position is spring-animated; aria-valuenow reflects the current
* value, not the target. All assertions use `toHaveAttribute` so Playwright
* polls until the spring settles.
*/
test.describe('comparison slider', () => {
test.beforeEach(async ({ comparison }) => {
await comparison.pickPair('Inter', 'Roboto');
await comparison.slider.focus();
});
test('keyboard navigation snaps to End and Home', async ({ comparison }) => {
await comparison.slider.press('End');
await expect(comparison.slider).toHaveAttribute('aria-valuenow', '100');
await comparison.slider.press('Home');
await expect(comparison.slider).toHaveAttribute('aria-valuenow', '0');
});
test('arrow keys nudge by one, Shift+Arrow by ten', async ({ comparison }) => {
await comparison.slider.press('Home');
await expect(comparison.slider).toHaveAttribute('aria-valuenow', '0');
await comparison.slider.press('ArrowRight');
await expect(comparison.slider).toHaveAttribute('aria-valuenow', '1');
await comparison.slider.press('Shift+ArrowRight');
await expect(comparison.slider).toHaveAttribute('aria-valuenow', '11');
});
test('PageUp / PageDown move by ten', async ({ comparison }) => {
await comparison.slider.press('Home');
await expect(comparison.slider).toHaveAttribute('aria-valuenow', '0');
await comparison.slider.press('PageUp');
await expect(comparison.slider).toHaveAttribute('aria-valuenow', '10');
await comparison.slider.press('PageDown');
await expect(comparison.slider).toHaveAttribute('aria-valuenow', '0');
});
});
-23
View File
@@ -1,23 +0,0 @@
import {
expect,
test,
} from '@playwright/test';
import { ComparisonPage } from './pages/comparison-page';
test.describe('smoke', () => {
test('loads the comparison view with its primary controls', async ({ page }) => {
const view = new ComparisonPage(page);
await view.open();
await expect(view.searchInput).toBeVisible();
await expect(view.previewInput).toBeVisible();
});
test('accepts a search query', async ({ page }) => {
const view = new ComparisonPage(page);
await view.open();
await view.searchFor('Inter');
await expect(view.searchInput).toHaveValue('Inter');
});
});
-44
View File
@@ -1,44 +0,0 @@
import {
expect,
test,
} from './fixtures';
import type { TypographyControl } from './pages/typography-menu';
/**
* Each control's trigger button advertises its current value via aria-label
* ("Size: 24"). We bump in one direction, then back, and assert the value
* tracks symmetrically.
*/
const controls: TypographyControl[] = ['size', 'weight', 'leading', 'tracking'];
test.describe('typography settings', () => {
for (const control of controls) {
test(`${control}: increase then decrease returns to baseline`, async ({ typography }) => {
const baseline = await typography.readValue(control);
expect(baseline).not.toBeNull();
await typography.bump(control, 'up');
const bumped = await typography.readValue(control);
expect(bumped).not.toBe(baseline);
expect(bumped! > baseline!).toBe(true);
await typography.bump(control, 'down');
const restored = await typography.readValue(control);
expect(restored).toBe(baseline);
});
}
test('font size step is reflected in the persisted typography state', async ({ comparison, typography }) => {
await typography.bump('size', 'up');
const expected = await typography.readValue('size');
await expect.poll(async () => {
const storage = await comparison.readStorage();
const raw = storage['glyphdiff:comparison:typography'];
if (!raw) {
return null;
}
return JSON.parse(raw).fontSize ?? null;
}).toBe(expected);
});
});
-1
View File
@@ -4,7 +4,6 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>glyphdiff</title> <title>glyphdiff</title>
<script src="https://mcp.figma.com/mcp/html-to-design/capture.js" async></script>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
+1 -5
View File
@@ -13,15 +13,11 @@ pre-commit:
pre-push: pre-push:
parallel: true parallel: true
commands: commands:
test-unit:
run: yarn test:unit
test-component:
run: yarn test:component
type-check: type-check:
run: yarn tsc --noEmit run: yarn tsc --noEmit
svelte-check: svelte-check:
run: yarn check --threshold warning run: yarn svelte-check --threshold warning
format-check: format-check:
glob: "*.{ts,js,svelte,json,md}" glob: "*.{ts,js,svelte,json,md}"
+27
View File
@@ -0,0 +1,27 @@
{
"categories": {
"correctness": "error",
"suspicious": "warn",
"perf": "warn",
"style": "warn",
"restriction": "error"
},
"env": {
"browser": true,
"es2021": true
},
"ignore": [
"node_modules",
"dist",
"build",
".svelte-kit",
".vercel",
"*.config.js",
"*.config.ts"
],
"rules": {
"no-console": "off",
"no-debugger": "error",
"no-alert": "warn"
}
}
+15 -53
View File
@@ -2,74 +2,36 @@
"name": "glyphdiff", "name": "glyphdiff",
"private": true, "private": true,
"version": "0.0.1", "version": "0.0.1",
"packageManager": "yarn@4.11.0",
"type": "module", "type": "module",
"sideEffects": [
"*.css",
"**/router.ts"
],
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"prepare": "svelte-check --tsconfig ./tsconfig.json || echo ''", "prepare": "svelte-check --tsconfig ./tsconfig.json || echo ''",
"check": "svelte-check", "check": "svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-check --tsconfig ./tsconfig.json --watch", "check:watch": "svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "oxlint", "lint": "oxlint",
"format": "dprint fmt", "format": "dprint fmt",
"format:check": "dprint check", "format:check": "dprint check",
"test:e2e": "playwright test", "test:e2e": "playwright test",
"test:unit": "vitest run", "test": "npm run test:e2e"
"test:unit:watch": "vitest",
"test:unit:ui": "vitest --ui",
"test:unit:coverage": "vitest run --coverage",
"test:component": "vitest run --config vitest.config.component.ts",
"test:component:browser": "vitest run --config vitest.config.browser.ts",
"test:component:browser:watch": "vitest --config vitest.config.browser.ts",
"test": "yarn run test:unit",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
}, },
"devDependencies": { "devDependencies": {
"@chromatic-com/storybook": "5.1.2", "@lucide/svelte": "^0.562.0",
"@internationalized/date": "3.12.1", "@playwright/test": "^1.57.0",
"@lucide/svelte": "^1.14.0", "@sveltejs/vite-plugin-svelte": "^6.2.1",
"@playwright/test": "1.59.1", "@tailwindcss/vite": "^4.1.18",
"@storybook/addon-a11y": "10.3.6",
"@storybook/addon-docs": "10.3.6",
"@storybook/addon-svelte-csf": "5.1.2",
"@storybook/addon-vitest": "10.3.6",
"@storybook/svelte-vite": "10.3.6",
"@sveltejs/vite-plugin-svelte": "7.1.0",
"@tailwindcss/vite": "4.2.4",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/svelte": "^5.3.1",
"@tsconfig/svelte": "5.0.8",
"@types/jsdom": "28.0.1",
"@vitest/browser-playwright": "4.1.5",
"@vitest/coverage-v8": "4.1.5",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dprint": "0.54.0", "dprint": "^0.50.2",
"jsdom": "29.1.1", "lefthook": "^2.0.13",
"lefthook": "2.1.6", "oxlint": "^1.35.0",
"oxlint": "1.62.0", "svelte": "^5.45.6",
"playwright": "1.59.1", "svelte-check": "^4.3.4",
"storybook": "10.3.6", "tailwind-merge": "^3.4.0",
"svelte": "5.55.5",
"svelte-check": "4.4.8",
"svelte-language-server": "0.18.0",
"tailwind-merge": "3.5.0",
"tailwind-variants": "^3.2.2", "tailwind-variants": "^3.2.2",
"tailwindcss": "4.2.4", "tailwindcss": "^4.1.18",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "6.0.3", "typescript": "^5.9.3",
"vite": "8.0.10", "vite": "^7.2.6"
"vitest": "4.1.5",
"vitest-browser-svelte": "2.1.1"
},
"dependencies": {
"@chenglou/pretext": "0.0.6",
"@tanstack/svelte-query": "6.1.28",
"sv-router": "^0.16.3"
} }
} }
+2 -50
View File
@@ -1,54 +1,6 @@
import { import { defineConfig } from '@playwright/test';
defineConfig,
devices,
} from '@playwright/test';
/**
* E2E config. Tests run against the production build via `vite preview` on port 4173.
* Locally: all three browser engines run in parallel.
* CI: chromium only, workers=1 — the runner has 6GB RAM and `yarn build` already
* spikes 12GB, so we keep the E2E peak bounded.
*/
const isCI = !!process.env.CI;
export default defineConfig({ export default defineConfig({
webServer: { command: 'yarn build && yarn preview', port: 4173 },
testDir: 'e2e', testDir: 'e2e',
testMatch: /.*\.test\.ts$/,
fullyParallel: true,
forbidOnly: isCI,
retries: isCI ? 2 : 0,
workers: isCI ? 1 : undefined,
reporter: isCI
? [['html', { open: 'never' }], ['github']]
: [['html', { open: 'on-failure' }], ['list']],
use: {
baseURL: 'http://localhost:4173',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: isCI
? [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }]
: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
},
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
],
webServer: {
command: 'yarn build && yarn preview',
port: 4173,
reuseExistingServer: !isCI,
timeout: 120_000,
},
}); });
+20
View File
@@ -0,0 +1,20 @@
<script lang="ts">
import favicon from '$lib/assets/favicon.svg';
import './app.css';
import Page from './routes/Page.svelte';
</script>
<svelte:head>
<link rel="icon" href={favicon} />
</svelte:head>
<div id="app-root">
<Page />
</div>
<style>
#app-root {
width: 100%;
height: 100vh;
}
</style>
+20
View File
@@ -0,0 +1,20 @@
declare module '*.svelte' {
import type { ComponentType } from 'svelte';
const component: ComponentType;
export default component;
}
declare module '*.svg' {
const content: string;
export default content;
}
declare module '*.png' {
const content: string;
export default content;
}
declare module '*.jpg' {
const content: string;
export default content;
}
+121
View File
@@ -0,0 +1,121 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.21 0.006 285.885);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.705 0.015 286.067);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.21 0.006 285.885);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.705 0.015 286.067);
}
.dark {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.92 0.004 286.32);
--primary-foreground: oklch(0.21 0.006 285.885);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.552 0.016 285.938);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.552 0.016 285.938);
}
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
-32
View File
@@ -1,32 +0,0 @@
<!--
Component: App
Application root with query provider and layout
-->
<script lang="ts">
/**
* App Component
*
* Application entry point component. Wraps the active route within the shared
* layout shell. This is the root component mounted by the application.
*
* Structure:
* - QueryProvider provides TanStack Query client for data fetching
* - Layout provides sidebar, header/footer, and page container
* - Router renders the matched route component
*/
import '$routes/router';
import { Router } from 'sv-router';
import {
AppBindingsProvider,
QueryProvider,
} from './providers';
import Layout from './ui/Layout.svelte';
</script>
<QueryProvider>
<AppBindingsProvider>
<Layout>
<Router />
</Layout>
</AppBindingsProvider>
</QueryProvider>
Binary file not shown.
Binary file not shown.
Binary file not shown.
-24
View File
@@ -1,24 +0,0 @@
<!--
Component: AppBindings
Provider that starts app-wide store bindings (filters → sort → font catalog)
for its subtree. Mount-scoped so the bindings' lifetime tracks the app tree.
-->
<script lang="ts">
import { startFilterBindings } from '$features/FilterAndSortFonts';
import { onMount } from 'svelte';
import type { Snippet } from 'svelte';
interface Props {
/**
* Content snippet
*/
children?: Snippet;
}
let { children }: Props = $props();
// startFilterBindings returns its $effect.root cleanup; onMount runs it on unmount.
onMount(() => startFilterBindings());
</script>
{@render children?.()}
-28
View File
@@ -1,28 +0,0 @@
<!--
Component: QueryProvider
Provides a QueryClientProvider for child components.
All components that use useQueryClient() or createQuery() must be
descendants of this provider.
-->
<script lang="ts">
import { getQueryClient } from '$shared/api/queryClient';
import { QueryClientProvider } from '@tanstack/svelte-query';
import type { Snippet } from 'svelte';
interface Props {
/**
* Content snippet
*/
children?: Snippet;
}
let { children }: Props = $props();
// First call to the lazy singleton — constructs the shared client for the app.
const queryClient = getQueryClient();
</script>
<QueryClientProvider client={queryClient}>
{@render children?.()}
</QueryClientProvider>
-2
View File
@@ -1,2 +0,0 @@
export { default as AppBindingsProvider } from './AppBindings.svelte';
export { default as QueryProvider } from './QueryProvider.svelte';
-536
View File
@@ -1,536 +0,0 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "./fonts.css";
@variant dark (&:where(.dark, .dark *));
:root {
/* Base font size */
--font-size: 16px;
/* GLYPHDIFF Design System */
/* Primary Colors */
--swiss-beige: #f3f0e9;
--swiss-red: #ff3b30;
--swiss-black: #1a1a1a;
--swiss-white: #ffffff;
/* Semantic mode-switching colors. These are redefined inside `.dark`
so utilities that reference them auto-adapt without a `dark:` variant. */
--color-border-subtle: var(--neutral-300);
--color-text-subtle: var(--neutral-500);
--color-skeleton: var(--neutral-200);
--color-grid-line: rgb(0 0 0 / 0.03);
/* Neutral Grays */
--neutral-50: #fafafa;
--neutral-100: #f5f5f5;
--neutral-200: #e5e5e5;
--neutral-300: #d4d4d4;
--neutral-400: #a3a3a3;
--neutral-500: #737373;
--neutral-600: #525252;
--neutral-700: #404040;
--neutral-800: #262626;
--neutral-900: #171717;
/* Dark Mode Backgrounds */
--dark-bg: #121212;
--dark-card: #1e1e1e;
--dark-border: rgba(255, 255, 255, 0.1);
/* Light Mode Backgrounds */
--light-bg: #f3f0e9;
--light-card: #ffffff;
--light-border: rgba(0, 0, 0, 0.05);
/* Semantic Colors */
--color-brand: var(--swiss-red);
--color-surface: var(--swiss-beige);
--color-paper: var(--swiss-white);
/* Base Tailwind Colors (for compatibility) */
--background: #ffffff;
--foreground: oklch(0.145 0 0);
--card: #ffffff;
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: #030213;
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.95 0.0058 264.53);
--secondary-foreground: #030213;
--muted: #ececf0;
--muted-foreground: #717182;
--accent: #e9ebef;
--accent-foreground: #030213;
--destructive: #d4183d;
--destructive-foreground: #ffffff;
--border: rgba(0, 0, 0, 0.1);
--input: transparent;
--input-background: #f3f3f5;
--switch-background: #cbced4;
--font-weight-medium: 500;
--font-weight-normal: 400;
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--radius: 0rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: #030213;
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
/* Typography Scale */
--text-xs: 0.75rem;
--text-sm: 0.875rem;
--text-base: 1rem;
--text-lg: 1.125rem;
--text-xl: 1.25rem;
--text-2xl: 1.5rem;
--text-3xl: 1.875rem;
--text-4xl: 2.25rem;
--text-5xl: 3rem;
--text-6xl: 3.75rem;
--text-7xl: 4.5rem;
--text-8xl: 6rem;
/* Comparison Font Sizes */
--comparison-font-mobile: 3rem;
--comparison-font-tablet: 4.5rem;
--comparison-font-desktop: 6rem;
}
.dark {
--color-surface: var(--dark-bg);
--color-paper: var(--dark-card);
/* Dark-mode overrides for the semantic mode-switching colors. */
--color-border-subtle: rgb(255 255 255 / 0.1);
--color-text-subtle: var(--neutral-400);
--color-skeleton: var(--neutral-800);
--color-grid-line: rgb(255 255 255 / 0.05);
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.145 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.145 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.985 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.396 0.141 25.723);
--destructive-foreground: oklch(0.637 0.237 25.331);
--border: oklch(0.269 0 0);
--input: oklch(0.269 0 0);
--ring: oklch(0.439 0 0);
--font-weight-medium: 500;
--font-weight-normal: 400;
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(0.269 0 0);
--sidebar-ring: oklch(0.439 0 0);
}
@theme {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-input-background: var(--input-background);
--color-switch-background: var(--switch-background);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius-sm: 0rem;
--radius-md: 0rem;
--radius-lg: 0rem;
--radius-xl: 0rem;
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--color-swiss-beige: var(--swiss-beige);
--color-swiss-red: var(--swiss-red);
--color-swiss-black: var(--swiss-black);
--color-swiss-white: var(--swiss-white);
--color-brand: var(--color-brand);
--color-surface: var(--color-surface);
--color-paper: var(--color-paper);
--color-dark-bg: var(--dark-bg);
--color-dark-card: var(--dark-card);
--font-logo: 'Syne', system-ui, -apple-system, 'Segoe UI', Inter, Roboto, Arial, sans-serif;
--font-mono: 'Space Mono', monospace;
--font-primary: 'Space Grotesk', system-ui, -apple-system, 'Segoe UI', Inter, Roboto, Arial, sans-serif;
--font-secondary: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, Arial, sans-serif;
/* Micro typography scale — extends Tailwind's text-xs (0.75rem) downward */
--text-5xs: 0.4375rem;
--text-4xs: 0.5rem;
--text-3xs: 0.5625rem;
--text-2xs: 0.625rem;
/* Monospace label tracking — used in Loader and Footnote */
--tracking-wider-mono: 0.2em;
/* Shadow tokens */
/* Default resting shadow — equivalent to Tailwind's shadow-sm. Used on
buttons, sliders, popover triggers in non-floating state. */
--shadow-rest: 0 1px 2px 0 rgb(0 0 0 / 0.05);
/* Swiss "hard offset" stamp — rests at 2px/2px, lifts to 3px/3px on
hover, presses back to 1px/1px on active. Primary button motif. */
--shadow-stamp-rest: 0.125rem 0.125rem 0 0 rgb(0 0 0 / 0.1);
--shadow-stamp-hover: 0.1875rem 0.1875rem 0 0 rgb(0 0 0 / 0.15);
--shadow-stamp-pressed: 0.0625rem 0.0625rem 0 0 rgb(0 0 0 / 0.1);
/* Card-tier hard-offset stamp — wider, brand-tinted. Used on
interactive cards (FontSampler hover). */
--shadow-stamp-card: 5px 5px 0 0 var(--color-brand);
/* Floating popovers (typography menu, combo control list). */
--shadow-popover: 0 20px 40px -10px rgb(0 0 0 / 0.15);
/* Drop-shadow under semi-translucent floating panels like the
comparison slider's character row. */
--shadow-floating-panel: 0 25px 50px -12px rgb(0 0 0 / 0.05);
--shadow-floating-panel-dark: 0 25px 50px -12px rgb(0 0 0 / 0.2);
/* Drawer / overlay shadow — full-strength shadow-2xl. */
--shadow-overlay: 0 25px 50px -12px rgb(0 0 0 / 0.25);
/* Motion tokens */
--duration-fast: 150ms;
--duration-normal: 200ms;
--duration-slow: 300ms;
--duration-slower: 500ms;
/* Tailwind's default ease-in-out — symmetric, good for layout shifts. */
--ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
/* Decelerating curve — matches Tailwind's ease-out. Dominant in this codebase. */
--ease-out-soft: cubic-bezier(0, 0, 0.2, 1);
/* Spring overshoot — used in character pop animation. */
--ease-spring-overshoot: cubic-bezier(0.34, 1.56, 0.64, 1);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
::selection {
background-color: var(--color-brand);
color: var(--swiss-white);
}
body {
@apply bg-background text-foreground;
font-family: var(--font-secondary);
font-optical-sizing: auto;
}
html {
font-size: var(--font-size);
}
h1 {
font-size: var(--text-2xl);
font-weight: var(--font-weight-medium);
line-height: 1.5;
}
h2 {
font-size: var(--text-xl);
font-weight: var(--font-weight-medium);
line-height: 1.5;
}
h3 {
font-size: var(--text-lg);
font-weight: var(--font-weight-medium);
line-height: 1.5;
}
h4 {
font-size: var(--text-base);
font-weight: var(--font-weight-medium);
line-height: 1.5;
}
label {
font-size: var(--text-base);
font-weight: var(--font-weight-medium);
line-height: 1.5;
}
button {
font-size: var(--text-base);
font-weight: var(--font-weight-medium);
line-height: 1.5;
}
input {
font-size: var(--text-base);
font-weight: var(--font-weight-normal);
line-height: 1.5;
}
}
/* Design-system utilities.
Defined via `@utility` (Tailwind v4) so they integrate with the variant
system (`hover:`, `dark:`, breakpoints) and don't rely on `@apply`
chains. Colors reference the mode-switching semantic vars defined in
`:root`/`.dark` above, so most utilities need no `dark:` variant in
their definition or at call sites. */
@utility border-subtle {
border-color: var(--color-border-subtle);
}
/* Same color as border-subtle, applied via background-color — for 1px
dividers, inline separator strips, and other hairlines that aren't
element borders. */
@utility bg-subtle {
background-color: var(--color-border-subtle);
}
/* Muted text color — paired with `border-subtle` naming. The previous
name `text-secondary` collided with Tailwind v4 auto-generating a
utility from `--color-secondary` (the shadcn near-white surface token
registered in `@theme`), which made every consumer effectively
invisible (near-white text on light backgrounds). */
@utility text-subtle {
color: var(--color-text-subtle);
}
@utility focus-ring {
&:focus-visible {
outline: 2px solid transparent;
outline-offset: 2px;
box-shadow: 0 0 0 2px var(--color-background, white), 0 0 0 4px var(--color-brand);
}
}
/* Surface utilities */
@utility surface-canvas {
background-color: var(--color-surface);
}
@utility surface-card {
background-color: var(--color-paper);
border: 1px solid var(--color-border-subtle);
}
@utility surface-card-elevated {
background-color: var(--color-paper);
border: 1px solid var(--color-border-subtle);
box-shadow: var(--shadow-rest);
}
@utility surface-popover {
background-color: var(--color-paper);
border: 1px solid var(--color-border-subtle);
box-shadow: var(--shadow-popover);
}
@utility surface-floating {
background-color: color-mix(in srgb, var(--color-surface) 80%, transparent);
backdrop-filter: blur(12px);
border: 1px solid var(--color-border-subtle);
}
/* Shape / layout */
@utility flex-center {
display: flex;
align-items: center;
justify-content: center;
}
@utility skeleton-fill {
background-color: color-mix(in srgb, var(--color-skeleton) 70%, transparent);
}
/* Subtle dotted-grid overlay used as a decorative background on the
comparison paper surface. Color and intensity auto-switch via
--color-grid-line. `bg-grid-sm` uses a tighter cell — typical mobile
choice; `bg-grid` is the default desktop cell. Pair with absolute /
pointer-events-none on the overlay element. */
@utility bg-grid {
background-image:
linear-gradient(var(--color-grid-line) 1px, transparent 1px),
linear-gradient(90deg, var(--color-grid-line) 1px, transparent 1px);
background-size: 20px 20px;
}
@utility bg-grid-sm {
background-image:
linear-gradient(var(--color-grid-line) 1px, transparent 1px),
linear-gradient(90deg, var(--color-grid-line) 1px, transparent 1px);
background-size: 10px 10px;
}
/* Typography */
@utility text-label-mono {
font-family: var(--font-primary);
font-weight: 700;
letter-spacing: -0.025em;
text-transform: uppercase;
}
/* Honor prefers-reduced-motion: collapse animation and transition timing. */
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* Hint the upcoming height animation on open collapsibles. */
[data-state="open"] {
will-change: height;
}
/* Transition siblings of a focus-visible peer. */
.peer:focus-visible ~ * {
transition: all 150ms cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes nudge {
0%, 100% {
transform: translateY(0) scale(1) rotate(0deg);
}
2% {
transform: translateY(-2px) scale(1.1) rotate(-1deg);
}
4% {
transform: translateY(0) scale(1) rotate(1deg);
}
6% {
transform: translateY(-2px) scale(1.1) rotate(0deg);
}
8% {
transform: translateY(0) scale(1) rotate(0deg);
}
}
.animate-nudge {
animation: nudge 10s ease-in-out infinite;
}
/* Scrollbar styling */
/* Standard API: color + width (Chrome 121+, Firefox 64+). */
@supports (scrollbar-width: auto) {
* {
scrollbar-width: thin;
scrollbar-color: hsl(0 0% 70% / 0.4) var(--color-surface);
}
.dark * {
scrollbar-color: hsl(0 0% 40% / 0.5) var(--color-surface);
}
}
/* WebKit fallback: applies on top of the standard API in Chrome, standalone in
older Safari. Covers what scrollbar-width can't — hiding buttons, exact sizing. */
@supports selector(::-webkit-scrollbar) {
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-button {
display: none; /* hide scrollbar buttons */
}
::-webkit-scrollbar-track {
background: var(--color-surface);
}
::-webkit-scrollbar-thumb {
background: hsl(0 0% 70% / 0.4);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: hsl(0 0% 50% / 0.6);
}
::-webkit-scrollbar-thumb:active {
background: hsl(0 0% 40% / 0.8);
}
::-webkit-scrollbar-corner {
background: var(--color-surface);
}
.dark ::-webkit-scrollbar-thumb { background: hsl(0 0% 40% / 0.5); }
.dark ::-webkit-scrollbar-thumb:hover { background: hsl(0 0% 55% / 0.6); }
.dark ::-webkit-scrollbar-thumb:active { background: hsl(0 0% 65% / 0.7); }
}
html {
scroll-behavior: smooth;
}
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
}
body {
overscroll-behavior-y: none;
}
.scroll-stable {
scrollbar-gutter: stable;
}
-78
View File
@@ -1,78 +0,0 @@
/*
Self-hosted interface fonts (latin subset only).
Vendored from @fontsource — see docs/interface-font-selfhost-benchmark.md.
Variable faces (Inter, Space Grotesk) keep their wght axis; Inter also keeps opsz.
url()s are resolved + content-hashed by Vite at build → immutable long-cache.
*/
/* Inter — variable wght + opsz, the body/secondary UI font (--font-secondary) */
@font-face {
font-family: 'Inter';
font-style: normal;
font-display: swap;
font-weight: 100 900;
src: url('../assets/fonts/inter-latin-opsz-normal.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Inter';
font-style: italic;
font-display: swap;
font-weight: 100 900;
src: url('../assets/fonts/inter-latin-opsz-italic.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* Space Grotesk — variable wght, the primary/display UI font (--font-primary) */
@font-face {
font-family: 'Space Grotesk';
font-style: normal;
font-display: swap;
font-weight: 300 700;
src: url('../assets/fonts/space-grotesk-latin-wght-normal.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* Space Mono — static 400/700 × roman/italic (--font-mono) */
@font-face {
font-family: 'Space Mono';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url('../assets/fonts/space-mono-latin-400-normal.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Space Mono';
font-style: italic;
font-display: swap;
font-weight: 400;
src: url('../assets/fonts/space-mono-latin-400-italic.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Space Mono';
font-style: normal;
font-display: swap;
font-weight: 700;
src: url('../assets/fonts/space-mono-latin-700-normal.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Space Mono';
font-style: italic;
font-display: swap;
font-weight: 700;
src: url('../assets/fonts/space-mono-latin-700-italic.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* Syne — static 800, the logo font (--font-logo) */
@font-face {
font-family: 'Syne';
font-style: normal;
font-display: swap;
font-weight: 800;
src: url('../assets/fonts/syne-latin-800-normal.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
-57
View File
@@ -1,57 +0,0 @@
declare module '*.svelte' {
import type {
ComponentProps as SvelteComponentProps,
ComponentType,
Snippet,
} from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
interface Component {
new(options: {
target: HTMLElement;
props?: Record<string, unknown>;
intro?: boolean;
}): {
$on: (event: string, handler: (...args: unknown[]) => unknown) => void;
$destroy: () => void;
$set: (props: Record<string, unknown>) => void;
};
}
export default Component;
}
declare module '*.svg' {
const content: string;
export default content;
}
declare module '*.png' {
const content: string;
export default content;
}
declare module '*.jpg' {
const content: string;
export default content;
}
declare module '*.css';
declare module '*.woff2?url' {
const content: string;
export default content;
}
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly DEV: boolean;
readonly PROD: boolean;
readonly MODE: string;
// Add other env variables you use
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
-82
View File
@@ -1,82 +0,0 @@
<!--
Component: Layout
Application shell with providers and page wrapper
-->
<script lang="ts">
import { getThemeManager } from '$features/ChangeAppTheme';
import G from '$shared/assets/G.svg';
import { ResponsiveProvider } from '$shared/lib';
import { cn } from '$shared/lib';
import { Footer } from '$widgets/Footer';
/*
Preload the two render-critical interface faces (primary + secondary).
`?url` resolves to the content-hashed path Vite emits, so the binary is
fetched immediately rather than waiting for CSS @font-face discovery.
*/
import interWoff2 from '../assets/fonts/inter-latin-opsz-normal.woff2?url';
import spaceGroteskWoff2 from '../assets/fonts/space-grotesk-latin-wght-normal.woff2?url';
import {
type Snippet,
onDestroy,
onMount,
} from 'svelte';
interface Props {
/**
* Content snippet
*/
children: Snippet;
}
let { children }: Props = $props();
let fontsReady = $state(true);
const themeManager = getThemeManager();
const theme = $derived(themeManager.value);
onMount(() => themeManager.init());
onDestroy(() => themeManager.destroy());
</script>
<svelte:head>
<link rel="icon" href={G} type="image/svg+xml" />
<!-- Self-hosted interface fonts (see src/app/styles/fonts/fonts.css). Preload the two critical faces. -->
<link
rel="preload"
as="font"
type="font/woff2"
href={interWoff2}
crossorigin="anonymous"
/>
<link
rel="preload"
as="font"
type="font/woff2"
href={spaceGroteskWoff2}
crossorigin="anonymous"
/>
<title>GlyphDiff | Typography & Typefaces</title>
<meta
name="description"
content="Compare typefaces side by side. Adjust size, weight, leading, and tracking to find the perfect typographic pairing."
/>
</svelte:head>
<ResponsiveProvider>
<div
id="app-root"
class={cn(
'min-h-dvh w-auto flex flex-col surface-canvas relative',
theme === 'dark' ? 'dark' : '',
)}
>
{#if fontsReady}
{@render children?.()}
{/if}
<Footer />
</div>
</ResponsiveProvider>
-17
View File
@@ -1,17 +0,0 @@
/**
* Font API clients exports
*
* Exports API clients and normalization utilities
*/
// Proxy API (primary)
export {
fetchFontsByIds,
fetchProxyFontById,
fetchProxyFonts,
seedFontCache,
} from './proxy/proxyFonts';
export type {
ProxyFontsParams,
ProxyFontsResponse,
} from './proxy/proxyFonts';
@@ -1,211 +0,0 @@
/**
* Tests for proxy API client
*/
import {
beforeEach,
describe,
expect,
test,
vi,
} from 'vitest';
import type { UnifiedFont } from '../../model/types';
import type { ProxyFontsResponse } from './proxyFonts';
vi.mock('$shared/api/api', () => ({
api: {
get: vi.fn(),
},
}));
import { api } from '$shared/api/api';
import { getQueryClient } from '$shared/api/queryClient';
const queryClient = getQueryClient();
import { fontKeys } from '$shared/api/queryKeys';
import { FontResponseError } from '../../lib/errors/errors';
import {
fetchFontsByIds,
fetchProxyFontById,
fetchProxyFonts,
seedFontCache,
} from './proxyFonts';
const PROXY_API_URL = 'https://api.glyphdiff.com/api/v1/fonts';
function createMockFont(overrides: Partial<UnifiedFont> = {}): UnifiedFont {
return {
id: 'roboto',
family: 'Roboto',
provider: 'google',
category: 'sans-serif',
variants: [],
subsets: [],
...overrides,
} as UnifiedFont;
}
function mockApiGet<T>(data: T) {
vi.mocked(api.get).mockResolvedValueOnce({ data, status: 200 });
}
describe('proxyFonts', () => {
beforeEach(() => {
vi.mocked(api.get).mockReset();
queryClient.clear();
});
describe('fetchProxyFonts', () => {
test('should fetch fonts with no params', async () => {
const mockResponse: ProxyFontsResponse = {
fonts: [createMockFont()],
total: 1,
limit: 50,
offset: 0,
};
mockApiGet(mockResponse);
const result = await fetchProxyFonts();
expect(api.get).toHaveBeenCalledWith(PROXY_API_URL);
expect(result).toEqual(mockResponse);
});
test('should build URL with query params', async () => {
const mockResponse: ProxyFontsResponse = {
fonts: [createMockFont()],
total: 1,
limit: 20,
offset: 0,
};
mockApiGet(mockResponse);
await fetchProxyFonts({ provider: 'google', category: 'sans-serif', limit: 20, offset: 0 });
const calledUrl = vi.mocked(api.get).mock.calls[0][0];
expect(calledUrl).toContain('provider=google');
expect(calledUrl).toContain('category=sans-serif');
expect(calledUrl).toContain('limit=20');
expect(calledUrl).toContain('offset=0');
});
test('should throw FontResponseError on invalid response (missing fonts array)', async () => {
mockApiGet({ total: 0 });
await expect(fetchProxyFonts()).rejects.toSatisfy(
e => e instanceof FontResponseError && e.field === 'response.fonts',
);
});
test('should throw FontResponseError on null response data', async () => {
vi.mocked(api.get).mockResolvedValueOnce({ data: null, status: 200 });
await expect(fetchProxyFonts()).rejects.toSatisfy(
e => e instanceof FontResponseError && e.field === 'response',
);
});
});
describe('fetchProxyFontById', () => {
test('should return font matching the ID', async () => {
const targetFont = createMockFont({ id: 'satoshi', name: 'Satoshi' });
const mockResponse: ProxyFontsResponse = {
fonts: [createMockFont(), targetFont],
total: 2,
limit: 1000,
offset: 0,
};
mockApiGet(mockResponse);
const result = await fetchProxyFontById('satoshi');
expect(result).toEqual(targetFont);
});
test('should return undefined when font not found', async () => {
const mockResponse: ProxyFontsResponse = {
fonts: [createMockFont()],
total: 1,
limit: 1000,
offset: 0,
};
mockApiGet(mockResponse);
const result = await fetchProxyFontById('nonexistent');
expect(result).toBeUndefined();
});
test('should search with the ID as query param', async () => {
const mockResponse: ProxyFontsResponse = {
fonts: [],
total: 0,
limit: 1000,
offset: 0,
};
mockApiGet(mockResponse);
await fetchProxyFontById('Roboto');
const calledUrl = vi.mocked(api.get).mock.calls[0][0];
expect(calledUrl).toContain('limit=1000');
expect(calledUrl).toContain('q=Roboto');
});
});
describe('fetchFontsByIds', () => {
test('should return empty array for empty input', async () => {
const result = await fetchFontsByIds([]);
expect(result).toEqual([]);
expect(api.get).not.toHaveBeenCalled();
});
test('should call batch endpoint with comma-separated IDs', async () => {
const fonts = [createMockFont({ id: 'roboto' }), createMockFont({ id: 'satoshi' })];
mockApiGet(fonts);
const result = await fetchFontsByIds(['roboto', 'satoshi']);
expect(api.get).toHaveBeenCalledWith(`${PROXY_API_URL}/batch?ids=roboto,satoshi`);
expect(result).toEqual(fonts);
});
test('should return empty array when response data is nullish', async () => {
vi.mocked(api.get).mockResolvedValueOnce({ data: null, status: 200 });
const result = await fetchFontsByIds(['roboto']);
expect(result).toEqual([]);
});
});
describe('seedFontCache', () => {
test('should populate cache with multiple fonts', () => {
const fonts = [
createMockFont({ id: '1', name: 'A' }),
createMockFont({ id: '2', name: 'B' }),
];
seedFontCache(fonts);
expect(queryClient.getQueryData(fontKeys.detail('1'))).toEqual(fonts[0]);
expect(queryClient.getQueryData(fontKeys.detail('2'))).toEqual(fonts[1]);
});
test('should update existing cached fonts with new data', () => {
const id = 'update-me';
queryClient.setQueryData(fontKeys.detail(id), createMockFont({ id, name: 'Old' }));
const updated = createMockFont({ id, name: 'New' });
seedFontCache([updated]);
expect(queryClient.getQueryData(fontKeys.detail(id))).toEqual(updated);
});
test('should handle empty input arrays gracefully', () => {
const spy = vi.spyOn(queryClient, 'setQueryData');
seedFontCache([]);
expect(spy).not.toHaveBeenCalled();
spy.mockRestore();
});
});
});
-220
View File
@@ -1,220 +0,0 @@
/**
* Proxy API client
*
* Handles API requests to GlyphDiff proxy API for fetching font metadata.
* Provides error handling, pagination support, and type-safe responses.
*
* Proxy API normalizes font data from Google Fonts and Fontshare into a single
* unified format, eliminating the need for client-side normalization.
*
* @see https://api.glyphdiff.com/api/v1/fonts
*/
import { api } from '$shared/api/api';
import { getQueryClient } from '$shared/api/queryClient';
import { fontKeys } from '$shared/api/queryKeys';
import { buildQueryString } from '$shared/lib/utils';
import type { QueryParams } from '$shared/lib/utils';
import { FontResponseError } from '../../lib/errors/errors';
import type { UnifiedFont } from '../../model/types';
/**
* Normalizes cache by seeding individual font entries from collection responses.
* This ensures that a font fetched in a list or batch is available via its detail key.
*
* @param fonts - Array of fonts to cache
*/
export function seedFontCache(fonts: UnifiedFont[]): void {
fonts.forEach(font => {
getQueryClient().setQueryData(fontKeys.detail(font.id), font);
});
}
import { API_ENDPOINTS } from '$shared/api/endpoints';
/**
* Proxy API endpoint for font resources.
*/
const PROXY_API_URL = API_ENDPOINTS.fonts;
/**
* Proxy API parameters
*
* Maps directly to the proxy API query parameters
*
* UPDATED: Now supports array values for filters
*/
export interface ProxyFontsParams extends QueryParams {
/**
* Font provider filter
*
* NEW: Supports array of providers (e.g., ["google", "fontshare"])
* Backward compatible: Single value still works
*/
providers?: string[] | string;
/**
* Font category filter
*
* NEW: Supports array of categories (e.g., ["serif", "sans-serif"])
* Backward compatible: Single value still works
*/
categories?: string[] | string;
/**
* Character subset filter
*
* NEW: Supports array of subsets (e.g., ["latin", "cyrillic"])
* Backward compatible: Single value still works
*/
subsets?: string[] | string;
/**
* Search query (e.g., "roboto", "satoshi")
*/
q?: string;
/**
* Sort order for results
* "name" - Alphabetical by font name
* "popularity" - Most popular first
* "lastModified" - Recently updated first
*/
sort?: 'name' | 'popularity' | 'lastModified';
/**
* Number of items to return (pagination)
*/
limit?: number;
/**
* Number of items to skip (pagination)
* Use for pagination: offset = (page - 1) * limit
*/
offset?: number;
}
/**
* Proxy API response
*
* Includes pagination metadata alongside font data.
*
* Contract: `fonts` is always an array — never `null` or omitted, even when
* `total === 0`. Returning `null` on the wire is a backend regression and
* surfaces as FontResponseError (non-retryable) on the client.
*/
export interface ProxyFontsResponse {
/**
* List of font objects returned by the proxy.
* Always an array; empty when no matches.
*/
fonts: UnifiedFont[];
/**
* Total number of matching fonts (ignoring limit/offset)
*/
total: number;
/**
* Page size used for the request
*/
limit: number;
/**
* Start index for the result set
*/
offset: number;
}
/**
* Fetch fonts from proxy API
*
* @param params - Query parameters for filtering and pagination
* @returns Promise resolving to proxy API response
* @throws ApiError when request fails
*
* @example
* ```ts
* // Fetch all sans-serif fonts from Google
* const response = await fetchProxyFonts({
* provider: 'google',
* category: 'sans-serif',
* limit: 50,
* offset: 0
* });
*
* // Search fonts across all providers
* const searchResponse = await fetchProxyFonts({
* q: 'roboto',
* limit: 20
* });
*
* // Fetch fonts with pagination
* const page1 = await fetchProxyFonts({ limit: 50, offset: 0 });
* const page2 = await fetchProxyFonts({ limit: 50, offset: 50 });
* ```
*/
export async function fetchProxyFonts(
params: ProxyFontsParams = {},
): Promise<ProxyFontsResponse> {
const queryString = buildQueryString(params);
const url = `${PROXY_API_URL}${queryString}`;
const response = await api.get<ProxyFontsResponse>(url);
if (!response.data) {
throw new FontResponseError('response', response.data);
}
if (!Array.isArray(response.data.fonts)) {
throw new FontResponseError('response.fonts', response.data.fonts);
}
return response.data;
}
/**
* Fetch font by ID
*
* Convenience function for fetching a single font by ID
* Note: This fetches a page and filters client-side, which is not ideal
* For production, consider adding a dedicated endpoint to the proxy API
*
* @param id - Font ID (family name for Google, slug for Fontshare)
* @returns Promise resolving to font or undefined
*
* @example
* ```ts
* const roboto = await fetchProxyFontById('Roboto');
* const satoshi = await fetchProxyFontById('satoshi');
* ```
*/
export async function fetchProxyFontById(
id: string,
): Promise<UnifiedFont | undefined> {
const response = await fetchProxyFonts({ limit: 1000, q: id });
if (!response || !response.fonts) {
console.error('[fetchProxyFontById] No fonts in response', { response });
return undefined;
}
return response.fonts.find(font => font.id === id);
}
/**
* Fetch multiple fonts by their IDs
*
* @param ids - Array of font IDs to fetch
* @returns Promise resolving to an array of fonts
*/
export async function fetchFontsByIds(ids: string[]): Promise<UnifiedFont[]> {
if (ids.length === 0) {
return [];
}
const queryString = ids.join(',');
const url = `${PROXY_API_URL}/batch?ids=${queryString}`;
const response = await api.get<UnifiedFont[]>(url);
return response.data ?? [];
}
@@ -1,95 +0,0 @@
// @vitest-environment jsdom
import { installCanvasMock } from '$shared/lib/helpers/__mocks__/canvas';
import { clearCache } from '@chenglou/pretext';
import {
beforeEach,
describe,
expect,
it,
} from 'vitest';
import { DualFontLayout } from './DualFontLayout';
// FontA: 10px per character. FontB: 15px per character.
// The mock dispatches on whether the font string contains 'FontA' or 'FontB'.
const FONT_A_WIDTH = 10;
const FONT_B_WIDTH = 15;
function fontWidthFactory(font: string, text: string): number {
const perChar = font.includes('FontA') ? FONT_A_WIDTH : FONT_B_WIDTH;
return text.length * perChar;
}
describe('DualFontLayout', () => {
let layout: DualFontLayout;
beforeEach(() => {
installCanvasMock(fontWidthFactory);
clearCache();
layout = new DualFontLayout();
});
it('returns empty result for empty string', () => {
const result = layout.layout('', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
expect(result.lines).toHaveLength(0);
expect(result.totalHeight).toBe(0);
});
it('uses worst-case width across both fonts to determine line breaks', () => {
// 'AB CD' — two 2-char words separated by a space.
// FontA: 'AB'=20px, 'CD'=20px. Both fit in 25px? No: 'AB CD' = 50px total.
// FontB: 'AB'=30px, 'CD'=30px. Width 35px forces wrap after 'AB '.
// Unified must use FontB widths — so it must wrap at the same place FontB wraps.
const result = layout.layout('AB CD', '400 16px "FontA"', '400 16px "FontB"', 35, 20);
expect(result.lines.length).toBeGreaterThan(1);
// First line text must not include both words.
expect(result.lines[0].text).not.toContain('CD');
});
it('provides xA and xB offsets for both fonts on a single line', () => {
// 'ABC' fits in 500px for both fonts.
// FontA: A@0(w=10), B@10(w=10), C@20(w=10)
// FontB: A@0(w=15), B@15(w=15), C@30(w=15)
const result = layout.layout('ABC', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
const chars = result.lines[0].chars;
expect(chars).toHaveLength(3);
expect(chars[0].xA).toBe(0);
expect(chars[0].widthA).toBe(FONT_A_WIDTH);
expect(chars[0].xB).toBe(0);
expect(chars[0].widthB).toBe(FONT_B_WIDTH);
expect(chars[1].xA).toBe(FONT_A_WIDTH); // 10
expect(chars[1].widthA).toBe(FONT_A_WIDTH);
expect(chars[1].xB).toBe(FONT_B_WIDTH); // 15
expect(chars[1].widthB).toBe(FONT_B_WIDTH);
expect(chars[2].xA).toBe(FONT_A_WIDTH * 2); // 20
expect(chars[2].xB).toBe(FONT_B_WIDTH * 2); // 30
});
it('returns cached result when called again with same arguments', () => {
const r1 = layout.layout('ABC', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
const r2 = layout.layout('ABC', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
expect(r2).toBe(r1); // strict reference equality — same object
});
it('re-computes when text changes', () => {
const r1 = layout.layout('ABC', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
const r2 = layout.layout('DEF', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
expect(r2).not.toBe(r1);
expect(r2.lines[0].text).not.toBe(r1.lines[0].text);
});
it('re-computes when width changes', () => {
const r1 = layout.layout('Hello World', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
const r2 = layout.layout('Hello World', '400 16px "FontA"', '400 16px "FontB"', 60, 20);
expect(r2).not.toBe(r1);
});
it('re-computes when fontA changes', () => {
const r1 = layout.layout('ABC', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
const r2 = layout.layout('ABC', '400 24px "FontA"', '400 16px "FontB"', 500, 20);
expect(r2).not.toBe(r1);
});
});
@@ -1,278 +0,0 @@
import {
type PreparedTextWithSegments,
layoutWithLines,
prepareWithSegments,
} from '@chenglou/pretext';
/**
* Default render size in px when callers omit the `size` arg on `layout()`.
*/
const DEFAULT_RENDER_SIZE_PX = 16;
/**
* Per-grapheme data computed during dual-font layout. Internal to the engine;
* consumed by computeLineRenderModel to derive the per-frame render model.
*/
export interface ComparisonChar {
/**
* Grapheme cluster (may be >1 code unit for emoji, combining marks).
*/
char: string;
/**
* X offset from line start in fontA, pixels.
*/
xA: number;
/**
* Advance width of this grapheme in fontA, pixels.
*/
widthA: number;
/**
* X offset from line start in fontB, pixels.
*/
xB: number;
/**
* Advance width of this grapheme in fontB, pixels.
*/
widthB: number;
}
/**
* A single laid-out line. `chars` carries the per-grapheme data needed by
* computeLineRenderModel. Consumers should not iterate it directly.
*/
export interface ComparisonLine {
/**
* Full text of this line as returned by pretext.
*/
text: string;
/**
* Rendered width in pixels — maximum across fontA and fontB.
*/
width: number;
/**
* Per-grapheme metadata for both fonts.
*/
chars: ComparisonChar[];
}
/**
* Aggregated output of a dual-font layout pass.
*/
export interface ComparisonResult {
/**
* Per-line grapheme data. Empty when input text is empty.
*/
lines: ComparisonLine[];
/**
* Total height in pixels.
*/
totalHeight: number;
}
/**
* Dual-font text layout engine backed by `@chenglou/pretext`.
*
* Computes identical line breaks for two fonts simultaneously by constructing a
* "unified" prepared-text object whose per-glyph widths are the worst-case maximum
* of font A and font B. This guarantees that both fonts wrap at exactly the same
* positions, making side-by-side or slider comparison visually coherent.
*
* Relies on pretext's published structural fields on `PreparedTextWithSegments`
* (`widths`, `breakableFitAdvances`, `lineEndFitAdvances`, `lineEndPaintAdvances`)
* which are exposed via the `PreparedCore` intersection in `@chenglou/pretext@0.0.6`.
*
* **Two-level caching strategy**
* 1. Font-change cache (`#preparedA`, `#preparedB`, `#unifiedPrepared`): rebuilt only
* when `text`, `fontA`, or `fontB` changes. `prepareWithSegments` is expensive
* (canvas measurement), so this avoids re-measuring during slider interaction.
* 2. Layout cache (`#lastResult`): rebuilt when `width` or `lineHeight` changes but
* the fonts have not changed. Line-breaking is cheap relative to measurement, but
* still worth skipping on every render tick.
*
* Per-frame slider state derivation lives in `computeLineRenderModel`, not on the
* class. This class is pure layout + caching; it holds no reactive state.
*/
export class DualFontLayout {
#segmenter: Intl.Segmenter;
// Cached prepared data
#preparedA: PreparedTextWithSegments | null = null;
#preparedB: PreparedTextWithSegments | null = null;
#unifiedPrepared: PreparedTextWithSegments | null = null;
#lastText = '';
#lastFontA = '';
#lastFontB = '';
#lastSpacing = 0;
#lastSize = 0;
// Cached layout results
#lastWidth = -1;
#lastLineHeight = -1;
#lastResult: ComparisonResult | null = null;
constructor(locale?: string) {
this.#segmenter = new Intl.Segmenter(locale, { granularity: 'grapheme' });
}
/**
* Lay out `text` using both fonts within `width` pixels.
*
* Line breaks are determined by the worst-case (maximum) glyph widths across
* both fonts, so both fonts always wrap at identical positions.
*
* @param text Raw text to lay out.
* @param fontA CSS font string for the first font: `"weight sizepx \"family\""`.
* @param fontB CSS font string for the second font: `"weight sizepx \"family\""`.
* @param width Available line width in pixels.
* @param lineHeight Line height in pixels (passed directly to pretext).
* @param spacing Letter spacing in em (from typography settings).
* @param size Current font size in pixels (used to convert spacing em to px).
* @returns Per-line grapheme data for both fonts. Empty `lines` when `text` is empty.
*/
layout(
text: string,
fontA: string,
fontB: string,
width: number,
lineHeight: number,
spacing: number = 0,
size: number = DEFAULT_RENDER_SIZE_PX,
): ComparisonResult {
if (!text) {
return { lines: [], totalHeight: 0 };
}
const spacingPx = spacing * size;
const isFontChange = text !== this.#lastText
|| fontA !== this.#lastFontA
|| fontB !== this.#lastFontB
|| spacing !== this.#lastSpacing
|| size !== this.#lastSize;
const isLayoutChange = width !== this.#lastWidth || lineHeight !== this.#lastLineHeight;
if (!isFontChange && !isLayoutChange && this.#lastResult) {
return this.#lastResult;
}
// 1. Prepare (or use cache)
if (isFontChange) {
this.#preparedA = prepareWithSegments(text, fontA);
this.#preparedB = prepareWithSegments(text, fontB);
this.#unifiedPrepared = this.#createUnifiedPrepared(this.#preparedA, this.#preparedB, spacingPx);
this.#lastText = text;
this.#lastFontA = fontA;
this.#lastFontB = fontB;
this.#lastSpacing = spacing;
this.#lastSize = size;
}
if (!this.#unifiedPrepared || !this.#preparedA || !this.#preparedB) {
return { lines: [], totalHeight: 0 };
}
const { lines, height } = layoutWithLines(this.#unifiedPrepared, width, lineHeight);
// 3. Map results back to both fonts
const preparedA = this.#preparedA;
const preparedB = this.#preparedB;
const resultLines: ComparisonLine[] = lines.map(line => {
const chars: ComparisonChar[] = [];
let currentXA = 0;
let currentXB = 0;
const start = line.start;
const end = line.end;
for (let sIdx = start.segmentIndex; sIdx <= end.segmentIndex; sIdx++) {
const segmentText = preparedA.segments[sIdx];
if (segmentText === undefined) {
continue;
}
const graphemes = Array.from(this.#segmenter.segment(segmentText), s => s.segment);
const advA = preparedA.breakableFitAdvances[sIdx];
const advB = preparedB.breakableFitAdvances[sIdx];
const gStart = sIdx === start.segmentIndex ? start.graphemeIndex : 0;
const gEnd = sIdx === end.segmentIndex ? end.graphemeIndex : graphemes.length;
for (let gIdx = gStart; gIdx < gEnd; gIdx++) {
const char = graphemes[gIdx];
let wA = advA != null ? advA[gIdx]! : preparedA.widths[sIdx]!;
let wB = advB != null ? advB[gIdx]! : preparedB.widths[sIdx]!;
// Apply letter spacing (tracking) to the width of each character
wA += spacingPx;
wB += spacingPx;
chars.push({
char,
xA: currentXA,
widthA: wA,
xB: currentXB,
widthB: wB,
});
currentXA += wA;
currentXB += wB;
}
}
return {
text: line.text,
width: line.width,
chars,
};
});
this.#lastWidth = width;
this.#lastLineHeight = lineHeight;
this.#lastResult = {
lines: resultLines,
totalHeight: height,
};
return this.#lastResult;
}
/**
* Merge two prepared texts into a worst-case unified version so both fonts
* wrap at identical positions. Per-segment widths are the elementwise max
* across both fonts, with `spacingPx` added to model letter-spacing.
*/
#createUnifiedPrepared(
a: PreparedTextWithSegments,
b: PreparedTextWithSegments,
spacingPx: number = 0,
): PreparedTextWithSegments {
const unified: PreparedTextWithSegments = { ...a };
unified.widths = a.widths.map((w, i) => Math.max(w, b.widths[i]) + spacingPx);
unified.lineEndFitAdvances = a.lineEndFitAdvances.map((w, i) =>
Math.max(w, b.lineEndFitAdvances[i]) + spacingPx
);
unified.lineEndPaintAdvances = a.lineEndPaintAdvances.map((w, i) =>
Math.max(w, b.lineEndPaintAdvances[i]) + spacingPx
);
unified.breakableFitAdvances = a.breakableFitAdvances.map((advA, i) => {
const advB = b.breakableFitAdvances[i];
if (!advA && !advB) {
return null;
}
if (!advA) {
return advB!.map(w => w + spacingPx);
}
if (!advB) {
return advA.map(w => w + spacingPx);
}
return advA.map((w, j) => Math.max(w, advB[j]) + spacingPx);
});
return unified;
}
}
@@ -1,220 +0,0 @@
import {
describe,
expect,
it,
} from 'vitest';
import type { ComparisonLine } from '../DualFontLayout/DualFontLayout';
import {
type LineRenderModel,
computeLineRenderModel,
findSplitIndex,
} from './computeLineRenderModel';
/**
* Build a ComparisonLine fixture with given per-char widths. xA/xB are
* cumulative prefix sums of widthA/widthB respectively.
*/
function makeLine(
chars: { char: string; widthA: number; widthB: number }[],
): ComparisonLine {
let xA = 0;
let xB = 0;
const out: ComparisonLine = {
text: chars.map(c => c.char).join(''),
width: chars.reduce((s, c) => s + Math.max(c.widthA, c.widthB), 0),
chars: chars.map(c => {
const entry = {
char: c.char,
xA,
xB,
widthA: c.widthA,
widthB: c.widthB,
};
xA += c.widthA;
xB += c.widthB;
return entry;
}),
};
return out;
}
/**
* Test helper: compute split + render model in one step, matching the
* SliderArea call site shape.
*/
function compute(
line: ComparisonLine,
sliderPos: number,
containerWidth: number,
windowSize: number,
): LineRenderModel {
const split = findSplitIndex(line, sliderPos, containerWidth);
return computeLineRenderModel(line, split, windowSize);
}
describe('computeLineRenderModel', () => {
it('returns empty model for an empty line', () => {
const line = makeLine([]);
const model = compute(line, 50, 500, 5);
expect(model.leftText).toBe('');
expect(model.windowChars).toEqual([]);
expect(model.rightText).toBe('');
});
it('places entire line in rightText when slider is at 0', () => {
const line = makeLine([
{ char: 'A', widthA: 10, widthB: 10 },
{ char: 'B', widthA: 10, widthB: 10 },
{ char: 'C', widthA: 10, widthB: 10 },
]);
const model = compute(line, 0, 500, 0);
expect(model.leftText).toBe('');
expect(model.windowChars).toEqual([]);
expect(model.rightText).toBe('ABC');
});
it('places entire line in leftText when slider is at 100', () => {
const line = makeLine([
{ char: 'A', widthA: 10, widthB: 10 },
{ char: 'B', widthA: 10, widthB: 10 },
{ char: 'C', widthA: 10, widthB: 10 },
]);
const model = compute(line, 100, 500, 0);
expect(model.leftText).toBe('ABC');
expect(model.windowChars).toEqual([]);
expect(model.rightText).toBe('');
});
it('splits line correctly with slider mid-line (window=0)', () => {
// Equal widths → line is centered. Container=300, total=30 → xOffset=135.
// Char thresholds (per the threshold formula in the design):
// threshold[i] = xOffset + prefA[i] + widthA[i]/2
// i=0: 135 + 0 + 5 = 140 → 140/300 = 46.67%
// i=1: 135 + 10 + 5 = 150 → 150/300 = 50.00%
// i=2: 135 + 20 + 5 = 160 → 160/300 = 53.33%
const line = makeLine([
{ char: 'A', widthA: 10, widthB: 10 },
{ char: 'B', widthA: 10, widthB: 10 },
{ char: 'C', widthA: 10, widthB: 10 },
]);
// Slider just past B's threshold (50%) but not C's (53.33%).
const model = compute(line, 51, 300, 0);
expect(model.leftText).toBe('AB');
expect(model.rightText).toBe('C');
});
it('centers window of size 3 on the split index', () => {
const line = makeLine([
{ char: 'A', widthA: 10, widthB: 10 },
{ char: 'B', widthA: 10, widthB: 10 },
{ char: 'C', widthA: 10, widthB: 10 },
{ char: 'D', widthA: 10, widthB: 10 },
{ char: 'E', widthA: 10, widthB: 10 },
]);
// Slider past A and B (~thresholds 43.33%, 46.67%); not past C (50%).
// split = 2 → halfWindow = 1 → windowStart = 1, windowEnd = 4
const model = compute(line, 48, 300, 3);
expect(model.leftText).toBe('A');
expect(model.windowChars.map(w => w.char)).toEqual(['B', 'C', 'D']);
expect(model.rightText).toBe('E');
});
it('clamps window at line start when slider is near 0', () => {
const line = makeLine([
{ char: 'A', widthA: 10, widthB: 10 },
{ char: 'B', widthA: 10, widthB: 10 },
{ char: 'C', widthA: 10, widthB: 10 },
{ char: 'D', widthA: 10, widthB: 10 },
{ char: 'E', widthA: 10, widthB: 10 },
]);
const model = compute(line, 0, 300, 3);
expect(model.leftText).toBe('');
expect(model.windowChars.map(w => w.char)).toEqual(['A', 'B', 'C']);
expect(model.rightText).toBe('DE');
});
it('clamps window at line end when slider is near 100', () => {
const line = makeLine([
{ char: 'A', widthA: 10, widthB: 10 },
{ char: 'B', widthA: 10, widthB: 10 },
{ char: 'C', widthA: 10, widthB: 10 },
{ char: 'D', widthA: 10, widthB: 10 },
{ char: 'E', widthA: 10, widthB: 10 },
]);
const model = compute(line, 100, 300, 3);
expect(model.leftText).toBe('AB');
expect(model.windowChars.map(w => w.char)).toEqual(['C', 'D', 'E']);
expect(model.rightText).toBe('');
});
it('treats whole line as window when line is shorter than windowSize', () => {
const line = makeLine([
{ char: 'A', widthA: 10, widthB: 10 },
{ char: 'B', widthA: 10, widthB: 10 },
]);
const model = compute(line, 50, 300, 5);
expect(model.leftText).toBe('');
expect(model.windowChars.map(w => w.char)).toEqual(['A', 'B']);
expect(model.rightText).toBe('');
});
it('produces stable keys across slider movement within the same line', () => {
const line = makeLine([
{ char: 'A', widthA: 10, widthB: 10 },
{ char: 'B', widthA: 10, widthB: 10 },
{ char: 'C', widthA: 10, widthB: 10 },
{ char: 'D', widthA: 10, widthB: 10 },
{ char: 'E', widthA: 10, widthB: 10 },
]);
const a = compute(line, 40, 300, 3);
const b = compute(line, 60, 300, 3);
// Chars that appear in both windows must carry identical keys.
for (const charA of a.windowChars) {
const charB = b.windowChars.find(w => w.char === charA.char);
if (charB !== undefined) {
expect(charB.key).toBe(charA.key);
}
}
});
it('marks isPast=true for chars before the split and false for chars after', () => {
const line = makeLine([
{ char: 'A', widthA: 10, widthB: 10 },
{ char: 'B', widthA: 10, widthB: 10 },
{ char: 'C', widthA: 10, widthB: 10 },
{ char: 'D', widthA: 10, widthB: 10 },
{ char: 'E', widthA: 10, widthB: 10 },
]);
// split = 2 → A,B past; C,D,E not
const model = compute(line, 48, 300, 5);
const expected = new Map([['A', true], ['B', true], ['C', false], ['D', false], ['E', false]]);
for (const wc of model.windowChars) {
expect(wc.isPast).toBe(expected.get(wc.char));
}
});
});
describe('findSplitIndex', () => {
it('returns 0 for empty line', () => {
const line = makeLine([]);
expect(findSplitIndex(line, 50, 500)).toBe(0);
});
it('returns 0 when slider is before all char thresholds', () => {
const line = makeLine([
{ char: 'A', widthA: 10, widthB: 10 },
{ char: 'B', widthA: 10, widthB: 10 },
{ char: 'C', widthA: 10, widthB: 10 },
]);
expect(findSplitIndex(line, 0, 300)).toBe(0);
});
it('returns chars.length when slider is past all char thresholds', () => {
const line = makeLine([
{ char: 'A', widthA: 10, widthB: 10 },
{ char: 'B', widthA: 10, widthB: 10 },
{ char: 'C', widthA: 10, widthB: 10 },
]);
expect(findSplitIndex(line, 100, 300)).toBe(3);
});
});
@@ -1,133 +0,0 @@
import type { ComparisonLine } from '../DualFontLayout/DualFontLayout';
/**
* Per-line render slice consumed by Line.svelte. The window is centered on the
* slider's split index and clamps at line boundaries.
*/
export interface LineRenderModel {
/**
* Chars before the window joined into a single string, rendered as one fontA text run.
*/
leftText: string;
/**
* Window chars — each rendered as its own Character element with crossfade slots.
*/
windowChars: Array<{
/**
* Stable key for Svelte keyed each — survives slider movement within the same line.
*/
key: string;
/**
* Grapheme cluster to render.
*/
char: string;
/**
* True once the slider has crossed this char's threshold.
*/
isPast: boolean;
}>;
/**
* Chars after the window joined into a single string, rendered as one fontB text run.
*/
rightText: string;
}
/**
* Returns the count of chars whose flip threshold the slider has crossed.
*
* Exposed as a separate step so consumers can pass the resulting primitive
* `split` across component boundaries: when split is unchanged tick-to-tick,
* downstream `$derived` reads of `computeLineRenderModel(line, split, ...)`
* short-circuit on value equality and skip re-rendering.
*
* For each candidate split `i`, the line's hypothetical width at that moment is
* `prefA[i] + widthA[i] + sufB[i+1]` (past chars in fontA, char `i` flipping, future
* chars in fontB). The threshold is the x of char `i`'s center in the centered line.
* Thresholds are monotonically non-decreasing in `i`, so the scan short-circuits on
* the first miss.
*/
export function findSplitIndex(
line: ComparisonLine,
sliderPos: number,
containerWidth: number,
): number {
const chars = line.chars;
const n = chars.length;
if (n === 0) {
return 0;
}
const sliderX = (sliderPos / 100) * containerWidth;
const prefA = new Float64Array(n + 1);
const sufB = new Float64Array(n + 1);
for (let i = 0, j = n - 1; i < n; i++, j--) {
prefA[i + 1] = prefA[i] + chars[i].widthA;
sufB[j] = sufB[j + 1] + chars[j].widthB;
}
let split = 0;
for (let i = 0; i < n; i++) {
const totalWidth = prefA[i] + chars[i].widthA + sufB[i + 1];
const xOffset = (containerWidth - totalWidth) / 2;
const threshold = xOffset + prefA[i] + chars[i].widthA / 2;
if (sliderX > threshold) {
split = i + 1;
} else {
break;
}
}
return split;
}
/**
* Slices a laid-out line into three regions around a precomputed split index:
* a fontA bulk run, an N-char crossfade window, and a fontB bulk run.
*
* Pure and allocation-bounded: two strings plus a `windowSize`-length array per call.
* Takes `split` as a primitive so callers can feed it into a `$derived` and
* skip re-evaluation on ticks where the split index is unchanged.
*
* @param line Line from `DualFontLayout.layout()`. Empty `chars` yields an empty model.
* @param split Count of chars the slider has passed, in `[0, line.chars.length]`.
* @param windowSize Number of chars in the crossfade window. Clamped to `[0, line.chars.length]`.
* At line edges the window is shifted (not shrunk) to keep its size.
*/
export function computeLineRenderModel(
line: ComparisonLine,
split: number,
windowSize: number,
): LineRenderModel {
const chars = line.chars;
const n = chars.length;
if (n === 0) {
return { leftText: '', windowChars: [], rightText: '' };
}
const halfWindow = Math.floor(Math.max(0, windowSize) / 2);
let windowStart = clamp(split - halfWindow, 0, n);
let windowEnd = clamp(windowStart + Math.max(0, windowSize), 0, n);
windowStart = Math.max(0, windowEnd - Math.max(0, windowSize));
const leftText = chars.slice(0, windowStart).map(c => c.char).join('');
const rightText = chars.slice(windowEnd).map(c => c.char).join('');
const windowChars = chars.slice(windowStart, windowEnd).map((c, idx) => ({
key: `${windowStart + idx}-${c.char}`,
char: c.char,
isPast: (windowStart + idx) < split,
}));
return { leftText, windowChars, rightText };
}
/**
* Clamps `value` into the inclusive range `[lo, hi]`. Assumes `lo <= hi`.
*/
function clamp(value: number, lo: number, hi: number): number {
if (value < lo) {
return lo;
}
if (value > hi) {
return hi;
}
return value;
}
-11
View File
@@ -1,11 +0,0 @@
export {
type ComparisonLine,
type ComparisonResult,
DualFontLayout,
} from './DualFontLayout/DualFontLayout';
export {
computeLineRenderModel,
findSplitIndex,
type LineRenderModel,
} from './computeLineRenderModel/computeLineRenderModel';
export { windowSizeForLine } from './windowSizeForLine/windowSizeForLine';
@@ -1,38 +0,0 @@
import {
describe,
expect,
it,
} from 'vitest';
import { windowSizeForLine } from './windowSizeForLine';
describe('windowSizeForLine', () => {
it('returns 0 for an empty or non-positive line', () => {
expect(windowSizeForLine(0)).toBe(0);
expect(windowSizeForLine(-3)).toBe(0);
});
it('floors non-empty short lines at the minimum window of 1', () => {
expect(windowSizeForLine(1)).toBe(1);
expect(windowSizeForLine(2)).toBe(1);
expect(windowSizeForLine(3)).toBe(1);
});
it('scales with round(n / 3) in the mid range', () => {
expect(windowSizeForLine(6)).toBe(2);
expect(windowSizeForLine(12)).toBe(4);
});
it('caps at the maximum window of 5', () => {
expect(windowSizeForLine(15)).toBe(5);
expect(windowSizeForLine(16)).toBe(5);
expect(windowSizeForLine(100)).toBe(5);
});
it('rounds to nearest at fractional boundaries', () => {
// round(4/3)=1, round(5/3)=2, round(13/3)=4, round(14/3)=5
expect(windowSizeForLine(4)).toBe(1);
expect(windowSizeForLine(5)).toBe(2);
expect(windowSizeForLine(13)).toBe(4);
expect(windowSizeForLine(14)).toBe(5);
});
});
@@ -1,39 +0,0 @@
/**
* Crossfade-window sizing policy for the dual-font slider.
*
* The slider renders a band of per-char `Character` cells that opacity-crossfade
* between the two fonts; everything outside the band is committed native bulk
* text. A fixed band looked wrong on short lines — a 6-grapheme line left almost
* no bulk, so nearly the whole line shimmered as per-char DOM. The band size
* therefore scales with the line's grapheme count and caps so long lines don't
* pay for an oversized per-char DOM band.
*/
/**
* Fraction of a line's graphemes that sit in the crossfade band.
*/
const WINDOW_RATIO = 1 / 3;
/**
* Smallest band for a non-empty line — guarantees at least one crossfading char.
*
* Accepted tradeoff: short lines now get a band of 12, so a fast slider drag
* can unmount a char before its ~100ms opacity crossfade finishes, a slight pop.
* Worth it for the "bulk committed, small band shimmering" look on short lines;
* raising this trades that pop back for less committed bulk.
*/
const WINDOW_MIN = 1;
/**
* Largest band regardless of line length — bounds per-char DOM cost.
*/
const WINDOW_MAX = 5;
/**
* Crossfade window size, in graphemes, for a line of `n` graphemes.
* `clamp(round(n / 3), 1, 5)`; an empty/non-positive line gets no window.
*/
export function windowSizeForLine(n: number): number {
if (n <= 0) {
return 0;
}
return Math.min(WINDOW_MAX, Math.max(WINDOW_MIN, Math.round(n * WINDOW_RATIO)));
}
-93
View File
@@ -1,93 +0,0 @@
export {
computeLineRenderModel,
DualFontLayout,
findSplitIndex,
windowSizeForLine,
} from './domain';
export type {
ComparisonLine,
ComparisonResult,
LineRenderModel,
} from './domain';
export {
createFontRowSizeResolver,
FontNetworkError,
FontResponseError,
getFontUrl,
} from './lib';
export type { FontRowSizeResolverOptions } from './lib';
export {
FontApplicator,
FontSampler,
FontVirtualList,
} from './ui';
// Pure model surface (types + constants).
export {
DEFAULT_FONT_SIZE,
DEFAULT_FONT_WEIGHT,
DEFAULT_LETTER_SPACING,
DEFAULT_LINE_HEIGHT,
FONT_SIZE_STEP,
FONT_WEIGHT_STEP,
LETTER_SPACING_STEP,
LINE_HEIGHT_STEP,
MAX_FONT_SIZE,
MAX_FONT_WEIGHT,
MAX_LETTER_SPACING,
MAX_LINE_HEIGHT,
MIN_FONT_SIZE,
MIN_FONT_WEIGHT,
MIN_LETTER_SPACING,
MIN_LINE_HEIGHT,
VIRTUAL_INDEX_NOT_LOADED,
} from './model/const/const';
export type {
FilterGroup,
FilterType,
FontCategory,
FontCollectionFilters,
FontCollectionSort,
FontCollectionState,
FontFeatures,
FontFilters,
FontLoadRequestConfig,
FontLoadStatus,
FontMetadata,
FontProvider,
FontStyleUrls,
FontSubset,
FontVariant,
FontWeight,
FontWeightItalic,
UnifiedFont,
UnifiedFontVariant,
} from './model/types';
/*
* Stores are exposed as lazy accessors / classes (not eager singletons): the
* entity's public API is complete, so consumers go through this barrel instead
* of deep-importing `./model` (FSD public-API boundary). Construction happens on
* first call, so this is inert at import. The slice root already transitively
* loads `@tanstack/query-core` via `./ui` (FontVirtualList), so surfacing the
* stores here adds no new eager cost.
*/
export {
FontLifecycleManager,
FontsByIdsStore,
getFontCatalog,
getFontLifecycleManager,
} from './model';
export type { FontCatalogStore } from './model';
/*
* `./api` (proxy clients: `fetchProxyFonts`, `seedFontCache`, …) is intentionally
* NOT re-exported here — those are not part of the entity's consumed surface and
* importing them eagerly constructs the TanStack `queryClient`. Import via the
* segment: `import { fetchProxyFonts } from '$entities/Font/api'`.
*/
// `./testing` is intentionally not re-exported: fixtures must not leak into the
// production public API. Import them via `$entities/Font/testing`.
@@ -1,111 +0,0 @@
import {
describe,
expect,
it,
} from 'vitest';
import type { UnifiedFont } from '../../model/types';
import { createFontLoadRequestContfig } from './createFontLoadRequestContfig';
/**
* Minimal UnifiedFont mock — override only the fields a case exercises.
*/
function createMockFont(overrides: Partial<UnifiedFont> = {}): UnifiedFont {
const baseFont: UnifiedFont = {
id: 'test-font',
name: 'Test Font',
provider: 'google',
category: 'sans-serif',
subsets: ['latin'],
variants: [],
styles: {},
metadata: {
cachedAt: Date.now(),
},
features: {
isVariable: false,
tags: [],
},
};
return { ...baseFont, ...overrides };
}
describe('createFontLoadRequestContfig', () => {
it('builds a single-element config when a URL resolves', () => {
const font = createMockFont({
id: 'roboto',
name: 'Roboto',
styles: { variants: { '400': 'https://example.com/roboto-400.woff2' } },
});
const result = createFontLoadRequestContfig(font, 400);
expect(result).toEqual([
{
id: 'roboto',
name: 'Roboto',
weight: 400,
url: 'https://example.com/roboto-400.woff2',
isVariable: false,
},
]);
});
it('returns an empty array when no URL resolves (flatMap drops the font)', () => {
const font = createMockFont({ styles: {} });
expect(createFontLoadRequestContfig(font, 400)).toEqual([]);
});
it('forwards isVariable from font features', () => {
const font = createMockFont({
features: { isVariable: true, tags: [] },
styles: { variants: { '700': 'https://example.com/inter-vf.woff2' } },
});
const [config] = createFontLoadRequestContfig(font, 700);
expect(config.isVariable).toBe(true);
});
it('sets isVariable to undefined when features is absent', () => {
// features is non-optional on UnifiedFont, but upstream data can be partial —
// the optional chain must not throw, and isVariable stays undefined.
const font = createMockFont({
styles: { variants: { '400': 'https://example.com/font.woff2' } },
});
// @ts-expect-error — deliberately drop the guaranteed field to exercise the optional chain
font.features = undefined;
const [config] = createFontLoadRequestContfig(font, 400);
expect(config.isVariable).toBeUndefined();
});
it('uses the resolved fallback URL, not just exact matches', () => {
// getFontUrl falls back to styles.regular when the exact weight is missing;
// the config must carry whatever URL actually resolved.
const font = createMockFont({
styles: { regular: 'https://example.com/font-regular.woff2' },
});
const [config] = createFontLoadRequestContfig(font, 900);
expect(config.url).toBe('https://example.com/font-regular.woff2');
expect(config.weight).toBe(900);
});
it('carries the requested weight even when the URL is a shared fallback', () => {
const font = createMockFont({
styles: { variants: { '400': 'https://example.com/shared.woff2' } },
});
expect(createFontLoadRequestContfig(font, 700)[0].weight).toBe(700);
});
it('propagates the invalid-weight error from getFontUrl', () => {
const font = createMockFont();
expect(() => createFontLoadRequestContfig(font, 450)).toThrow('Invalid weight: 450');
});
});
@@ -1,33 +0,0 @@
import type {
FontLoadRequestConfig,
UnifiedFont,
} from '../../model';
import { getFontUrl } from '../getFontUrl/getFontUrl';
/**
* Build the font-lifecycle load request for a single font at a given weight.
*
* Returns a 0-or-1 element array rather than `FontLoadRequestConfig | undefined`
* so call sites can `flatMap` over a font list — resolve the URL and drop fonts
* that have none in a single pass, with no separate filter step. An empty array
* means the font has no loadable asset for this weight (or its fallbacks) and is
* silently skipped.
*
* `isVariable` is forwarded from the font's features so the lifecycle manager can
* dedupe variable fonts per ID (they load once regardless of weight) while still
* loading static fonts per weight.
*
* @param font - Unified font to load
* @param weight - Numeric weight (100-900)
* @returns Single-element config array, or `[]` when no URL resolves
* @throws Error when weight is outside the valid 100-900 range (propagated from `getFontUrl`)
*/
export function createFontLoadRequestContfig(font: UnifiedFont, weight: number): FontLoadRequestConfig[] {
const url = getFontUrl(font, weight);
if (!url) {
return [];
}
return [{ id: font.id, name: font.name, weight, url, isVariable: font.features?.isVariable }];
}
@@ -1,51 +0,0 @@
import {
FontNetworkError,
FontResponseError,
} from './errors';
describe('FontNetworkError', () => {
it('has correct name', () => {
const err = new FontNetworkError();
expect(err.name).toBe('FontNetworkError');
});
it('is instance of Error', () => {
expect(new FontNetworkError()).toBeInstanceOf(Error);
});
it('stores cause', () => {
const cause = new Error('network down');
const err = new FontNetworkError(cause);
expect(err.cause).toBe(cause);
});
it('has default message', () => {
expect(new FontNetworkError().message).toBe('Failed to fetch fonts from proxy API');
});
});
describe('FontResponseError', () => {
it('has correct name', () => {
const err = new FontResponseError('response', undefined);
expect(err.name).toBe('FontResponseError');
});
it('is instance of Error', () => {
expect(new FontResponseError('response.fonts', null)).toBeInstanceOf(Error);
});
it('stores field', () => {
const err = new FontResponseError('response.fonts', 42);
expect(err.field).toBe('response.fonts');
});
it('stores received value', () => {
const err = new FontResponseError('response.fonts', 42);
expect(err.received).toBe(42);
});
it('message includes field name', () => {
const err = new FontResponseError('response.fonts', null);
expect(err.message).toContain('response.fonts');
});
});
-32
View File
@@ -1,32 +0,0 @@
import { NonRetryableError } from '$shared/api/nonRetryableError';
/**
* Thrown when the network request to the proxy API fails.
* Wraps the underlying fetch error (timeout, DNS failure, connection refused, etc.).
*/
export class FontNetworkError extends Error {
readonly name = 'FontNetworkError';
constructor(public readonly cause?: unknown) {
super('Failed to fetch fonts from proxy API');
}
}
/**
* Thrown when the proxy API returns a response with an unexpected shape.
* Extends NonRetryableError because schema mismatches are not transient —
* retrying will produce the same failure and only delay surfacing the bug.
*
* @property field - The name of the field that failed validation (e.g. `'response'`, `'response.fonts'`).
* @property received - The actual value received at that field, for debugging.
*/
export class FontResponseError extends NonRetryableError {
readonly name = 'FontResponseError';
constructor(
public readonly field: string,
public readonly received: unknown,
) {
super(`Invalid proxy API response: ${field}`);
}
}
@@ -1,592 +0,0 @@
import {
describe,
expect,
it,
} from 'vitest';
import type { UnifiedFont } from '../../model/types';
import { getFontUrl } from './getFontUrl';
/**
* Helper function to create a minimal UnifiedFont mock for testing
*/
function createMockFont(
overrides: Partial<UnifiedFont> = {},
): UnifiedFont {
const baseFont: UnifiedFont = {
id: 'test-font',
name: 'Test Font',
provider: 'google',
category: 'sans-serif',
subsets: ['latin'],
variants: [],
styles: {},
metadata: {
cachedAt: Date.now(),
},
features: {
isVariable: false,
tags: [],
},
};
return { ...baseFont, ...overrides };
}
describe('getFontUrl', () => {
describe('basic logic', () => {
it('returns URL for exact weight match in variants', () => {
const font = createMockFont({
styles: {
variants: {
'400': 'https://example.com/font-400.woff2',
'700': 'https://example.com/font-700.woff2',
},
},
});
const result = getFontUrl(font, 400);
expect(result).toBe('https://example.com/font-400.woff2');
});
it('returns URL for weight 700', () => {
const font = createMockFont({
styles: {
variants: {
'700': 'https://example.com/font-700.woff2',
},
},
});
const result = getFontUrl(font, 700);
expect(result).toBe('https://example.com/font-700.woff2');
});
it('returns URL for weight 100 (lightest)', () => {
const font = createMockFont({
styles: {
variants: {
'100': 'https://example.com/font-100.woff2',
},
},
});
const result = getFontUrl(font, 100);
expect(result).toBe('https://example.com/font-100.woff2');
});
it('returns URL for weight 900 (boldest)', () => {
const font = createMockFont({
styles: {
variants: {
'900': 'https://example.com/font-900.woff2',
},
},
});
const result = getFontUrl(font, 900);
expect(result).toBe('https://example.com/font-900.woff2');
});
it('returns URL for variable font (backend maps weight to VF URL)', () => {
const font = createMockFont({
styles: {
variants: {
'400': 'https://example.com/font-variable.woff2',
'700': 'https://example.com/font-variable.woff2',
},
},
});
const result400 = getFontUrl(font, 400);
const result700 = getFontUrl(font, 700);
expect(result400).toBe('https://example.com/font-variable.woff2');
expect(result700).toBe('https://example.com/font-variable.woff2');
});
});
describe('fallback logic', () => {
it('falls back to regular when exact weight not found', () => {
const font = createMockFont({
styles: {
regular: 'https://example.com/font-regular.woff2',
variants: {
'400': 'https://example.com/font-400.woff2',
},
},
});
const result = getFontUrl(font, 700);
expect(result).toBe('https://example.com/font-regular.woff2');
});
it('falls back to variant 400 when exact weight and regular not found', () => {
const font = createMockFont({
styles: {
variants: {
'400': 'https://example.com/font-400.woff2',
},
},
});
const result = getFontUrl(font, 700);
expect(result).toBe('https://example.com/font-400.woff2');
});
it('falls back to variant regular when exact weight, regular, and 400 not found', () => {
const font = createMockFont({
styles: {
variants: {
'700': 'https://example.com/font-700.woff2',
'regular': 'https://example.com/font-regular.woff2',
},
},
});
const result = getFontUrl(font, 400);
expect(result).toBe('https://example.com/font-regular.woff2');
});
it('prefers regular over variants.400 for fallback', () => {
const font = createMockFont({
styles: {
regular: 'https://example.com/font-regular.woff2',
variants: {
'400': 'https://example.com/font-400.woff2',
},
},
});
const result = getFontUrl(font, 700);
expect(result).toBe('https://example.com/font-regular.woff2');
});
it('returns undefined when no fallback options available', () => {
const font = createMockFont({
styles: {
variants: {
'700': 'https://example.com/font-700.woff2',
},
},
});
const result = getFontUrl(font, 400);
expect(result).toBeUndefined();
});
it('returns undefined for font with empty styles', () => {
const font = createMockFont({
styles: {},
});
const result = getFontUrl(font, 400);
expect(result).toBeUndefined();
});
it('throws error for font with undefined styles (invalid font data)', () => {
const font = createMockFont({
styles: undefined as any,
});
expect(() => getFontUrl(font, 400)).toThrow();
});
});
describe('edge cases', () => {
it('handles font with only regular URL (legacy format)', () => {
const font = createMockFont({
styles: {
regular: 'https://example.com/font-regular.woff2',
},
});
const result = getFontUrl(font, 700);
expect(result).toBe('https://example.com/font-regular.woff2');
});
it('handles font with only variants object', () => {
const font = createMockFont({
styles: {
variants: {
'400': 'https://example.com/font-400.woff2',
'700': 'https://example.com/font-700.woff2',
},
},
});
const result400 = getFontUrl(font, 400);
const result700 = getFontUrl(font, 700);
expect(result400).toBe('https://example.com/font-400.woff2');
expect(result700).toBe('https://example.com/font-700.woff2');
});
it('handles font with variants but no requested weight', () => {
const font = createMockFont({
styles: {
variants: {
'400': 'https://example.com/font-400.woff2',
},
},
});
const result = getFontUrl(font, 700);
expect(result).toBe('https://example.com/font-400.woff2');
});
it('handles Google Fonts style with legacy URLs', () => {
const font = createMockFont({
styles: {
regular: 'https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu72xKOzY.woff2',
bold: 'https://fonts.gstatic.com/s/roboto/v30/KFOlCnqEu92Fr1Mu72xWUlvAx05IsDqlA.woff2',
},
});
const result = getFontUrl(font, 700);
expect(result).toBe('https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu72xKOzY.woff2');
});
it('handles Fontshare fonts with multiple weights', () => {
const font = createMockFont({
styles: {
variants: {
'100': 'https://cdn.fontshare.com/wf/font-100.woff2',
'200': 'https://cdn.fontshare.com/wf/font-200.woff2',
'300': 'https://cdn.fontshare.com/wf/font-300.woff2',
'400': 'https://cdn.fontshare.com/wf/font-400.woff2',
'500': 'https://cdn.fontshare.com/wf/font-500.woff2',
'600': 'https://cdn.fontshare.com/wf/font-600.woff2',
'700': 'https://cdn.fontshare.com/wf/font-700.woff2',
'800': 'https://cdn.fontshare.com/wf/font-800.woff2',
'900': 'https://cdn.fontshare.com/wf/font-900.woff2',
},
},
});
// Test all valid weights
for (const weight of [100, 200, 300, 400, 500, 600, 700, 800, 900]) {
const result = getFontUrl(font, weight);
expect(result).toBe(`https://cdn.fontshare.com/wf/font-${weight}.woff2`);
}
});
it('handles font with partial weight coverage', () => {
const font = createMockFont({
styles: {
variants: {
'400': 'https://example.com/font-regular.woff2',
'700': 'https://example.com/font-bold.woff2',
},
},
});
const result400 = getFontUrl(font, 400);
const result700 = getFontUrl(font, 700);
const result500 = getFontUrl(font, 500);
expect(result400).toBe('https://example.com/font-regular.woff2');
expect(result700).toBe('https://example.com/font-bold.woff2');
expect(result500).toBe('https://example.com/font-regular.woff2'); // Fallback
});
it('handles font with variants.regular as fallback', () => {
const font = createMockFont({
styles: {
variants: {
'700': 'https://example.com/font-bold.woff2',
'regular': 'https://example.com/font-regular.woff2',
},
},
});
const result = getFontUrl(font, 400);
expect(result).toBe('https://example.com/font-regular.woff2');
});
it('handles empty variants object', () => {
const font = createMockFont({
styles: {
variants: {},
},
});
const result = getFontUrl(font, 400);
expect(result).toBeUndefined();
});
it('returns undefined when variant URL is null and no fallback available', () => {
const font = createMockFont({
styles: {
variants: {
'400': null as any,
'700': 'https://example.com/font-bold.woff2',
},
},
});
const result = getFontUrl(font, 400);
// null is falsy, so it falls back to regular, 400, and then regular variant
// All are undefined, so returns undefined
expect(result).toBeUndefined();
});
});
describe('boundary tests', () => {
it('handles lowest valid weight (100)', () => {
const font = createMockFont({
styles: {
variants: {
'100': 'https://example.com/font-100.woff2',
},
},
});
const result = getFontUrl(font, 100);
expect(result).toBe('https://example.com/font-100.woff2');
});
it('handles highest valid weight (900)', () => {
const font = createMockFont({
styles: {
variants: {
'900': 'https://example.com/font-900.woff2',
},
},
});
const result = getFontUrl(font, 900);
expect(result).toBe('https://example.com/font-900.woff2');
});
it('handles middle weight (500)', () => {
const font = createMockFont({
styles: {
variants: {
'500': 'https://example.com/font-500.woff2',
},
},
});
const result = getFontUrl(font, 500);
expect(result).toBe('https://example.com/font-500.woff2');
});
});
describe('invalid weights', () => {
it('throws error for weight below 100', () => {
const font = createMockFont({
styles: {
variants: {
'400': 'https://example.com/font-400.woff2',
},
},
});
expect(() => getFontUrl(font, 99)).toThrow('Invalid weight: 99');
});
it('throws error for weight above 900', () => {
const font = createMockFont({
styles: {
variants: {
'400': 'https://example.com/font-400.woff2',
},
},
});
expect(() => getFontUrl(font, 901)).toThrow('Invalid weight: 901');
});
it('throws error for weight 0', () => {
const font = createMockFont({
styles: {
variants: {
'400': 'https://example.com/font-400.woff2',
},
},
});
expect(() => getFontUrl(font, 0)).toThrow('Invalid weight: 0');
});
it('throws error for negative weight', () => {
const font = createMockFont({
styles: {
variants: {
'400': 'https://example.com/font-400.woff2',
},
},
});
expect(() => getFontUrl(font, -100)).toThrow('Invalid weight: -100');
});
it('throws error for non-numeric weight', () => {
const font = createMockFont({
styles: {
variants: {
'400': 'https://example.com/font-400.woff2',
},
},
});
// @ts-ignore - Testing invalid input type
expect(() => getFontUrl(font, '400' as any)).toThrow('Invalid weight: 400');
});
it('throws error for decimal weight', () => {
const font = createMockFont({
styles: {
variants: {
'400': 'https://example.com/font-400.woff2',
},
},
});
expect(() => getFontUrl(font, 450.5)).toThrow('Invalid weight: 450.5');
});
it('throws error for weight with step of 50 (not supported)', () => {
const font = createMockFont({
styles: {
variants: {
'400': 'https://example.com/font-400.woff2',
},
},
});
expect(() => getFontUrl(font, 450)).toThrow('Invalid weight: 450');
});
it('throws error for weight with step of 10 (not supported)', () => {
const font = createMockFont({
styles: {
variants: {
'400': 'https://example.com/font-400.woff2',
},
},
});
expect(() => getFontUrl(font, 410)).toThrow('Invalid weight: 410');
});
it('throws error for NaN weight', () => {
const font = createMockFont({
styles: {
variants: {
'400': 'https://example.com/font-400.woff2',
},
},
});
expect(() => getFontUrl(font, NaN)).toThrow('Invalid weight: NaN');
});
it('throws error for Infinity weight', () => {
const font = createMockFont({
styles: {
variants: {
'400': 'https://example.com/font-400.woff2',
},
},
});
expect(() => getFontUrl(font, Infinity)).toThrow('Invalid weight: Infinity');
});
it('throws descriptive error message', () => {
const font = createMockFont({
styles: {
variants: {
'400': 'https://example.com/font-400.woff2',
},
},
});
try {
getFontUrl(font, 999);
expect.fail('Expected function to throw');
} catch (error) {
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toBe('Invalid weight: 999');
}
});
});
describe('provider-specific tests', () => {
it('handles Google Fonts with variable fonts', () => {
const font = createMockFont({
provider: 'google',
styles: {
variants: {
'400': 'https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu72xKOzY.woff2',
'700': 'https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu72xKOzY.woff2',
},
},
});
const result400 = getFontUrl(font, 400);
const result700 = getFontUrl(font, 700);
// Variable fonts return the same URL for all weights
expect(result400).toBe(result700);
});
it('handles Fontshare fonts with static weights', () => {
const font = createMockFont({
provider: 'fontshare',
styles: {
variants: {
'400': 'https://cdn.fontshare.com/wf/satoshi-regular.woff2',
'700': 'https://cdn.fontshare.com/wf/satoshi-bold.woff2',
},
},
});
const result400 = getFontUrl(font, 400);
const result700 = getFontUrl(font, 700);
expect(result400).toBe('https://cdn.fontshare.com/wf/satoshi-regular.woff2');
expect(result700).toBe('https://cdn.fontshare.com/wf/satoshi-bold.woff2');
expect(result400).not.toBe(result700);
});
});
describe('all valid weights test', () => {
it('handles all valid weight values', () => {
const validWeights = [100, 200, 300, 400, 500, 600, 700, 800, 900];
validWeights.forEach(weight => {
const font = createMockFont({
styles: {
variants: {
[weight.toString()]: `https://example.com/font-${weight}.woff2`,
},
},
});
const result = getFontUrl(font, weight);
expect(result).toBe(`https://example.com/font-${weight}.woff2`);
});
});
});
});
@@ -1,48 +0,0 @@
import type {
FontWeight,
UnifiedFont,
} from '../../model';
/**
* Valid font weight values (100-900 in increments of 100)
*/
const SIZES = [100, 200, 300, 400, 500, 600, 700, 800, 900];
/**
* Gets the URL for a font file at a specific weight
*
* Constructs the appropriate URL for loading a font file based on
* the font object and requested weight. Handles variable fonts and
* provides fallbacks for static fonts.
*
* @param font - Unified font object containing style URLs
* @param weight - Font weight (100-900)
* @returns URL string for the font file, or undefined if not found
* @throws Error if weight is not a valid value (100-900)
*
* @example
* ```ts
* const url = getFontUrl(roboto, 700); // Returns URL for Roboto Bold
*
* // Variable fonts: backend maps weight to VF URL
* const vfUrl = getFontUrl(inter, 450); // Returns variable font URL
*
* // Fallback for missing weights
* const fallback = getFontUrl(font, 900); // Falls back to regular/400 if 900 missing
* ```
*/
export function getFontUrl(font: UnifiedFont, weight: number): string | undefined {
if (!SIZES.includes(weight)) {
throw new Error(`Invalid weight: ${weight}`);
}
const weightKey = weight.toString() as FontWeight;
// Try exact match (backend maps weight to VF URL for variable fonts)
if (font.styles.variants?.[weightKey]) {
return font.styles.variants[weightKey];
}
// Fallbacks for static fonts when exact weight is missing
return font.styles.regular || font.styles.variants?.['400'] || font.styles.variants?.['regular'];
}
-9
View File
@@ -1,9 +0,0 @@
export { getFontUrl } from './getFontUrl/getFontUrl';
export {
FontNetworkError,
FontResponseError,
} from './errors/errors';
export { createFontRowSizeResolver } from './sizeResolver/createFontRowSizeResolver';
export type { FontRowSizeResolverOptions } from './sizeResolver/createFontRowSizeResolver';
@@ -1,180 +0,0 @@
// @vitest-environment jsdom
import { installCanvasMock } from '$shared/lib/helpers/__mocks__/canvas';
import {
clearCache,
layout,
} from '@chenglou/pretext';
// Wrap pretext's `layout` in a spy-able mock so tests can assert call counts.
// `vi.mock` is hoisted, so the import above receives the mocked module.
vi.mock('@chenglou/pretext', async () => {
const actual = await vi.importActual<typeof import('@chenglou/pretext')>('@chenglou/pretext');
return {
...actual,
layout: vi.fn(actual.layout),
};
});
import { mockUnifiedFont } from '$entities/Font/testing';
import {
beforeEach,
describe,
expect,
it,
vi,
} from 'vitest';
import type { FontLoadStatus } from '../../model/types';
import { createFontRowSizeResolver } from './createFontRowSizeResolver';
// Fixed-width canvas mock: every character is 10px wide regardless of font.
// This makes wrapping math predictable: N chars × 10px = N×10 total width.
const CHAR_WIDTH = 10;
const LINE_HEIGHT = 20;
const CONTAINER_WIDTH = 200;
const CONTENT_PADDING_X = 32; // p-4 × 2 sides = 32px
const CHROME_HEIGHT = 56;
const FALLBACK_HEIGHT = 220;
const FONT_SIZE_PX = 16;
describe('createFontRowSizeResolver', () => {
let statusMap: Map<string, FontLoadStatus>;
let getStatus: (key: string) => FontLoadStatus | undefined;
beforeEach(() => {
installCanvasMock((_font, text) => text.length * CHAR_WIDTH);
clearCache();
statusMap = new Map();
getStatus = key => statusMap.get(key);
});
function makeResolver(overrides?: Partial<Parameters<typeof createFontRowSizeResolver>[0]>) {
const font = mockUnifiedFont({ id: 'inter', name: 'Inter' });
return {
font,
resolver: createFontRowSizeResolver({
getFonts: () => [font],
getWeight: () => 400,
getPreviewText: () => 'Hello',
getContainerWidth: () => CONTAINER_WIDTH,
getFontSizePx: () => FONT_SIZE_PX,
getLineHeightPx: () => LINE_HEIGHT,
getStatus,
contentHorizontalPadding: CONTENT_PADDING_X,
chromeHeight: CHROME_HEIGHT,
fallbackHeight: FALLBACK_HEIGHT,
...overrides,
}),
};
}
it('returns fallbackHeight when font status is undefined', () => {
const { resolver } = makeResolver();
expect(resolver(0)).toBe(FALLBACK_HEIGHT);
});
it('returns fallbackHeight when font status is "loading"', () => {
const { resolver } = makeResolver();
statusMap.set('inter@400', 'loading');
expect(resolver(0)).toBe(FALLBACK_HEIGHT);
});
it('returns fallbackHeight when font status is "error"', () => {
const { resolver } = makeResolver();
statusMap.set('inter@400', 'error');
expect(resolver(0)).toBe(FALLBACK_HEIGHT);
});
it('returns fallbackHeight when containerWidth is 0', () => {
const { resolver } = makeResolver({ getContainerWidth: () => 0 });
statusMap.set('inter@400', 'loaded');
expect(resolver(0)).toBe(FALLBACK_HEIGHT);
});
it('returns fallbackHeight when previewText is empty', () => {
const { resolver } = makeResolver({ getPreviewText: () => '' });
statusMap.set('inter@400', 'loaded');
expect(resolver(0)).toBe(FALLBACK_HEIGHT);
});
it('returns fallbackHeight for out-of-bounds rowIndex', () => {
const { resolver } = makeResolver();
statusMap.set('inter@400', 'loaded');
expect(resolver(99)).toBe(FALLBACK_HEIGHT);
});
it('returns computed height (totalHeight + chromeHeight) when font is loaded', () => {
const { resolver } = makeResolver();
statusMap.set('inter@400', 'loaded');
// 'Hello' = 5 chars × 10px = 50px. contentWidth = 200 - 32 = 168px. Fits on one line.
// totalHeight = 1 × LINE_HEIGHT = 20. result = 20 + CHROME_HEIGHT = 76.
const result = resolver(0);
expect(result).toBe(LINE_HEIGHT + CHROME_HEIGHT);
});
it('returns increased height when text wraps due to narrow container', () => {
// contentWidth = 40 - 32 = 8px — 'Hello' (50px) forces wrapping onto many lines
const { resolver } = makeResolver({ getContainerWidth: () => 40 });
statusMap.set('inter@400', 'loaded');
const result = resolver(0);
expect(result).toBeGreaterThan(LINE_HEIGHT + CHROME_HEIGHT);
});
it('does not call layout() again on second call with same arguments', () => {
const { resolver } = makeResolver();
statusMap.set('inter@400', 'loaded');
const layoutSpy = vi.mocked(layout);
layoutSpy.mockClear();
resolver(0);
resolver(0);
expect(layoutSpy).toHaveBeenCalledTimes(1);
});
it('calls layout() again when containerWidth changes (cache miss)', () => {
let width = CONTAINER_WIDTH;
const { resolver } = makeResolver({ getContainerWidth: () => width });
statusMap.set('inter@400', 'loaded');
const layoutSpy = vi.mocked(layout);
layoutSpy.mockClear();
resolver(0);
width = 100;
resolver(0);
expect(layoutSpy).toHaveBeenCalledTimes(2);
});
it('returns greater height when container narrows (more wrapping)', () => {
let width = CONTAINER_WIDTH;
const { resolver } = makeResolver({ getContainerWidth: () => width });
statusMap.set('inter@400', 'loaded');
const h1 = resolver(0);
width = 100; // narrower → more wrapping
const h2 = resolver(0);
expect(h2).toBeGreaterThanOrEqual(h1);
});
it('uses variable font key for variable fonts', () => {
const vfFont = mockUnifiedFont({ id: 'roboto', name: 'Roboto', features: { isVariable: true } });
const { resolver } = makeResolver({ getFonts: () => [vfFont] });
// Variable fonts use '{id}@vf' key, not '{id}@{weight}'
statusMap.set('roboto@vf', 'loaded');
const result = resolver(0);
expect(result).not.toBe(FALLBACK_HEIGHT);
expect(result).toBeGreaterThan(0);
});
it('returns fallbackHeight for variable font when static key is set instead', () => {
const vfFont = mockUnifiedFont({ id: 'roboto', name: 'Roboto', features: { isVariable: true } });
const { resolver } = makeResolver({ getFonts: () => [vfFont] });
// Setting the static key should NOT unlock computed height for variable fonts
statusMap.set('roboto@400', 'loaded');
expect(resolver(0)).toBe(FALLBACK_HEIGHT);
});
});
@@ -1,140 +0,0 @@
import {
layout,
prepare,
} from '@chenglou/pretext';
import { generateFontKey } from '../../model/store/fontLifecycleManager/utils/generateFontKey/generateFontKey';
import type {
FontLoadStatus,
UnifiedFont,
} from '../../model/types';
/**
* Options for {@link createFontRowSizeResolver}.
*
* All getter functions are called on every resolver invocation. When called
* inside a Svelte `$derived.by` block, any reactive state read within them
* (e.g. `SvelteMap.get()`) is automatically tracked as a dependency.
*/
export interface FontRowSizeResolverOptions {
/**
* Returns the current fonts array. Index `i` corresponds to row `i`.
*/
getFonts: () => UnifiedFont[];
/**
* Returns the active font weight (e.g. 400).
*/
getWeight: () => number;
/**
* Returns the preview text string.
*/
getPreviewText: () => string;
/**
* Returns the scroll container's inner width in pixels. Returns 0 before mount.
*/
getContainerWidth: () => number;
/**
* Returns the font size in pixels (e.g. `controlManager.renderedSize`).
*/
getFontSizePx: () => number;
/**
* Returns the computed line height in pixels.
* Typically `controlManager.height * controlManager.renderedSize`.
*/
getLineHeightPx: () => number;
/**
* Returns the font load status for a given font key (`'{id}@{weight}'` or `'{id}@vf'`).
*
* In production: `(key) => fontLifecycleManager.statuses.get(key)`.
* Injected for testability — avoids a module-level singleton dependency in tests.
* The call to `.get()` on a `SvelteMap` must happen inside a `$derived.by` context
* for reactivity to work. This is satisfied when `itemHeight` is called by
* `createVirtualizer`'s `estimateSize`.
*/
getStatus: (fontKey: string) => FontLoadStatus | undefined;
/**
* Total horizontal padding of the text content area in pixels.
* Use the smallest breakpoint value (mobile `p-4` = 32px) to guarantee
* the content width is never over-estimated, keeping the height estimate safe.
*/
contentHorizontalPadding: number;
/**
* Fixed height in pixels of chrome that is not text content (header bar, etc.).
*/
chromeHeight: number;
/**
* Height in pixels to return when the font is not loaded or container width is 0.
*/
fallbackHeight: number;
}
/**
* Creates a row-height resolver for `FontSampler` rows in `VirtualList`.
*
* The returned function is suitable as the `itemHeight` prop of `VirtualList`.
* Pass it from the widget layer (`SampleList`) so that typography values from
* `controlManager` are injected as getter functions rather than imported directly,
* keeping `$entities/Font` free of `$features` dependencies.
*
* **Reactivity:** When the returned function reads `getStatus()` inside a
* `$derived.by` block (as `estimateSize` does in `createVirtualizer`), any
* `SvelteMap.get()` call within `getStatus` registers a Svelte 5 dependency.
* When a font's status changes to `'loaded'`, `offsets` recomputes automatically —
* no DOM snap occurs.
*
* **Caching:** A `Map` keyed by `fontCssString|text|contentWidth|lineHeightPx`
* prevents redundant `pretext.layout()` calls. The cache is invalidated
* naturally because a change in any input produces a different cache key.
*
* @param options - Configuration and getter functions (all injected for testability).
* @returns A function `(rowIndex: number) => number` for use as `VirtualList.itemHeight`.
*/
export function createFontRowSizeResolver(options: FontRowSizeResolverOptions): (rowIndex: number) => number {
// Key: `${fontCssString}|${text}|${contentWidth}|${lineHeightPx}`
const cache = new Map<string, number>();
return function resolveRowHeight(rowIndex: number): number {
const fonts = options.getFonts();
const font = fonts[rowIndex];
if (!font) {
return options.fallbackHeight;
}
const containerWidth = options.getContainerWidth();
const previewText = options.getPreviewText();
if (containerWidth <= 0 || !previewText) {
return options.fallbackHeight;
}
const weight = options.getWeight();
// generateFontKey: '{id}@{weight}' for static fonts, '{id}@vf' for variable fonts.
const fontKey = generateFontKey({ id: font.id, weight, isVariable: font.features?.isVariable });
// Reading via getStatus() allows the caller to pass fontLifecycleManager.statuses.get(),
// which creates a Svelte 5 reactive dependency when called inside $derived.by.
const status = options.getStatus(fontKey);
if (status !== 'loaded') {
return options.fallbackHeight;
}
const fontSizePx = options.getFontSizePx();
const lineHeightPx = options.getLineHeightPx();
const contentWidth = containerWidth - options.contentHorizontalPadding;
const fontCssString = `${weight} ${fontSizePx}px "${font.name}"`;
const cacheKey = `${fontCssString}|${previewText}|${contentWidth}|${lineHeightPx}`;
const cached = cache.get(cacheKey);
if (cached !== undefined) {
return cached;
}
// Pretext docs recommend `layout()` (not `layoutWithLines`) for the
// resize hot path — pure arithmetic on cached segment widths, no canvas
// calls, no string allocations.
const prepared = prepare(previewText, fontCssString);
const { height: totalHeight } = layout(prepared, contentWidth, lineHeightPx);
const result = totalHeight + options.chromeHeight;
cache.set(cacheKey, result);
return result;
};
}
-37
View File
@@ -1,37 +0,0 @@
/**
* Font size constants
*/
export const DEFAULT_FONT_SIZE = 48;
export const MIN_FONT_SIZE = 8;
export const MAX_FONT_SIZE = 100;
export const FONT_SIZE_STEP = 1;
/**
* Font weight constants
*/
export const DEFAULT_FONT_WEIGHT = 400;
export const MIN_FONT_WEIGHT = 100;
export const MAX_FONT_WEIGHT = 900;
export const FONT_WEIGHT_STEP = 100;
/**
* Line height constants
*/
export const DEFAULT_LINE_HEIGHT = 1.5;
export const MIN_LINE_HEIGHT = 1;
export const MAX_LINE_HEIGHT = 2;
export const LINE_HEIGHT_STEP = 0.05;
/**
* Letter spacing constants
*/
export const DEFAULT_LETTER_SPACING = 0;
export const MIN_LETTER_SPACING = -0.1;
export const MAX_LETTER_SPACING = 0.5;
export const LETTER_SPACING_STEP = 0.01;
/**
* Index value for items not yet loaded in a virtualized list.
* Treated as being at the very bottom of the infinite scroll.
*/
export const VIRTUAL_INDEX_NOT_LOADED = Infinity;
-51
View File
@@ -1,51 +0,0 @@
export {
DEFAULT_FONT_SIZE,
DEFAULT_FONT_WEIGHT,
DEFAULT_LETTER_SPACING,
DEFAULT_LINE_HEIGHT,
FONT_SIZE_STEP,
FONT_WEIGHT_STEP,
LETTER_SPACING_STEP,
LINE_HEIGHT_STEP,
MAX_FONT_SIZE,
MAX_FONT_WEIGHT,
MAX_LETTER_SPACING,
MAX_LINE_HEIGHT,
MIN_FONT_SIZE,
MIN_FONT_WEIGHT,
MIN_LETTER_SPACING,
MIN_LINE_HEIGHT,
VIRTUAL_INDEX_NOT_LOADED,
} from './const/const';
// Stores (lazy accessors + classes)
export {
__resetFontLifecycleManager,
FontLifecycleManager,
FontsByIdsStore,
getFontCatalog,
getFontLifecycleManager,
} from './store';
export type { FontCatalogStore } from './store';
export type {
FilterGroup,
FilterType,
FontCategory,
FontCollectionFilters,
FontCollectionSort,
FontCollectionState,
FontFeatures,
FontFilters,
FontLoadRequestConfig,
FontLoadStatus,
FontMetadata,
FontProvider,
FontStyleUrls,
FontSubset,
FontVariant,
FontWeight,
FontWeightItalic,
UnifiedFont,
UnifiedFontVariant,
} from './types';
@@ -1,641 +0,0 @@
import {
generateMixedCategoryFonts,
generateMockFonts,
} from '$entities/Font/testing';
import { flushSync } from 'svelte';
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi,
} from 'vitest';
import {
FontNetworkError,
FontResponseError,
} from '../../../lib/errors/errors';
import type { UnifiedFont } from '../../types';
import { FontCatalogStore } from './fontCatalogStore.svelte';
vi.mock('$shared/api/queryClient', async importOriginal => {
/**
* Import QueryClient inside the factory rather than referencing the top-level binding.
* A hoisted vi.mock factory that touches a module-level import can hit that import
* before it is initialized (ReferenceError) when the import sits in a circular/eager
* barrel chain — which it now does via $shared/lib → BaseQueryStore → query-core.
*/
const { QueryClient } = await import('@tanstack/query-core');
const actual = await importOriginal<typeof import('$shared/api/queryClient')>();
const mockClient = new QueryClient({
defaultOptions: { queries: { retry: 0, gcTime: 0 } },
});
return {
...actual,
getQueryClient: () => mockClient,
};
});
vi.mock('../../../api', () => ({ fetchProxyFonts: vi.fn() }));
import { getQueryClient } from '$shared/api/queryClient';
import { fetchProxyFonts } from '../../../api';
const queryClient = getQueryClient();
const fetch = fetchProxyFonts as ReturnType<typeof vi.fn>;
type FontPage = { fonts: UnifiedFont[]; total: number; limit: number; offset: number };
const makeResponse = (
fonts: UnifiedFont[],
meta: { total?: number; limit?: number; offset?: number } = {},
): FontPage => ({
fonts,
total: meta.total ?? fonts.length,
limit: meta.limit ?? 10,
offset: meta.offset ?? 0,
});
function makeStore(params = {}) {
return new FontCatalogStore({ limit: 10, ...params });
}
async function fetchedStore(params = {}, fonts = generateMockFonts(5), meta: Parameters<typeof makeResponse>[1] = {}) {
fetch.mockResolvedValue(makeResponse(fonts, meta));
const store = makeStore(params);
await store.refetch();
flushSync();
return store;
}
describe('FontCatalogStore', () => {
afterEach(() => {
queryClient.clear();
vi.resetAllMocks();
});
describe('construction', () => {
it('stores initial params', () => {
const store = makeStore({ limit: 20 });
expect(store.params.limit).toBe(20);
store.destroy();
});
it('defaults limit to 50 when not provided', () => {
const store = new FontCatalogStore();
expect(store.params.limit).toBe(50);
store.destroy();
});
it('starts with empty fonts', () => {
const store = makeStore();
expect(store.fonts).toEqual([]);
store.destroy();
});
it('starts with isEmpty false — observer is gated until setParams enables it', () => {
// The observer is disabled on construction (no auto-fetch) — see
// `#enabled` in the store. isEmpty must still be false so the UI
// doesn't flash "no results" before bindings configures the query.
const store = makeStore();
expect(store.isEmpty).toBe(false);
store.destroy();
});
});
describe('state after fetch', () => {
it('exposes loaded fonts', async () => {
const store = await fetchedStore({}, generateMockFonts(7));
expect(store.fonts).toHaveLength(7);
store.destroy();
});
it('isEmpty is false when fonts are present', async () => {
const store = await fetchedStore();
expect(store.isEmpty).toBe(false);
store.destroy();
});
it('isLoading is false after fetch', async () => {
const store = await fetchedStore();
expect(store.isLoading).toBe(false);
store.destroy();
});
it('isFetching is false after fetch', async () => {
const store = await fetchedStore();
expect(store.isFetching).toBe(false);
store.destroy();
});
it('isError is false on success', async () => {
const store = await fetchedStore();
expect(store.isError).toBe(false);
store.destroy();
});
it('error is null on success', async () => {
const store = await fetchedStore();
expect(store.error).toBeNull();
store.destroy();
});
});
describe('error states', () => {
it('isError is false before any fetch', () => {
const store = makeStore();
expect(store.isError).toBe(false);
store.destroy();
});
it('wraps network failures in FontNetworkError', async () => {
fetch.mockRejectedValue(new Error('network down'));
const store = makeStore();
await store.refetch().catch(() => {});
flushSync();
expect(store.error).toBeInstanceOf(FontNetworkError);
expect(store.isError).toBe(true);
store.destroy();
});
it('exposes FontResponseError for falsy response', async () => {
const store = makeStore();
fetch.mockResolvedValue(null);
await store.refetch().catch(() => {});
flushSync();
expect(store.error).toBeInstanceOf(FontResponseError);
expect((store.error as FontResponseError).field).toBe('response');
store.destroy();
});
it('exposes FontResponseError for missing fonts field', async () => {
fetch.mockResolvedValue({ total: 0, limit: 10, offset: 0 });
const store = makeStore();
await store.refetch().catch(() => {});
flushSync();
expect(store.error).toBeInstanceOf(FontResponseError);
expect((store.error as FontResponseError).field).toBe('response.fonts');
store.destroy();
});
it('exposes FontResponseError for non-array fonts', async () => {
fetch.mockResolvedValue({ fonts: 'bad', total: 0, limit: 10, offset: 0 });
const store = makeStore();
await store.refetch().catch(() => {});
flushSync();
expect(store.error).toBeInstanceOf(FontResponseError);
expect((store.error as FontResponseError).received).toBe('bad');
store.destroy();
});
});
describe('font accumulation', () => {
it('replaces fonts when refetching the first page', async () => {
const store = makeStore();
fetch.mockResolvedValue(makeResponse(generateMockFonts(3)));
await store.refetch();
flushSync();
const second = generateMockFonts(2);
fetch.mockResolvedValue(makeResponse(second));
await store.refetch();
flushSync();
// refetch at offset=0 re-fetches all pages; only one page loaded → new data replaces old
expect(store.fonts).toHaveLength(2);
expect(store.fonts[0].id).toBe(second[0].id);
store.destroy();
});
it('appends fonts after nextPage', async () => {
const page1 = generateMockFonts(3);
const store = await fetchedStore({ limit: 3 }, page1, { total: 6, limit: 3, offset: 0 });
const page2 = generateMockFonts(3).map((f, i) => ({ ...f, id: `p2-${i}` }));
fetch.mockResolvedValue(makeResponse(page2, { total: 6, limit: 3, offset: 3 }));
await store.nextPage();
flushSync();
expect(store.fonts).toHaveLength(6);
expect(store.fonts.slice(0, 3).map(f => f.id)).toEqual(page1.map(f => f.id));
expect(store.fonts.slice(3).map(f => f.id)).toEqual(page2.map(f => f.id));
store.destroy();
});
});
describe('pagination state', () => {
it('returns zero-value defaults before any fetch', () => {
const store = makeStore();
expect(store.pagination).toMatchObject({ total: 0, hasMore: false, page: 1, totalPages: 0 });
store.destroy();
});
it('reflects response metadata after fetch', async () => {
const store = await fetchedStore({}, generateMockFonts(10), { total: 30, limit: 10, offset: 0 });
expect(store.pagination.total).toBe(30);
expect(store.pagination.hasMore).toBe(true);
expect(store.pagination.page).toBe(1);
expect(store.pagination.totalPages).toBe(3);
store.destroy();
});
it('hasMore is false on the last page', async () => {
const store = await fetchedStore({}, generateMockFonts(10), { total: 10, limit: 10, offset: 0 });
expect(store.pagination.hasMore).toBe(false);
store.destroy();
});
it('page count increments after nextPage', async () => {
const store = await fetchedStore({ limit: 10 }, generateMockFonts(10), { total: 30, limit: 10, offset: 0 });
expect(store.pagination.page).toBe(1);
fetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 30, limit: 10, offset: 10 }));
await store.nextPage();
flushSync();
expect(store.pagination.page).toBe(2);
store.destroy();
});
});
describe('setParams', () => {
it('merges updates into existing params', () => {
const store = makeStore({ limit: 10 });
store.setParams({ limit: 20 });
expect(store.params.limit).toBe(20);
store.destroy();
});
it('retains unmodified params', () => {
const store = makeStore({ limit: 10 });
store.setCategories(['serif']);
store.setParams({ limit: 25 });
expect(store.params.categories).toEqual(['serif']);
store.destroy();
});
});
describe('filter change resets', () => {
it('clears accumulated fonts when a filter changes', async () => {
const store = await fetchedStore({}, generateMockFonts(5));
store.setSearch('roboto');
flushSync();
// TQ switches to a new queryKey → data.pages reset → fonts = []
expect(store.fonts).toHaveLength(0);
store.destroy();
});
it('isEmpty is false immediately after filter change — fetch is in progress', async () => {
const store = await fetchedStore({}, generateMockFonts(5));
// Hang the next fetch so we can observe the transitioning state
fetch.mockReturnValue(new Promise(() => {}));
store.setSearch('roboto');
flushSync();
// fonts = [] AND isFetching = true → isEmpty must be false (no "no results" flash)
expect(store.isEmpty).toBe(false);
store.destroy();
});
it('does NOT reset fonts when the same filter value is set again', async () => {
const store = await fetchedStore({}, generateMockFonts(5));
store.setCategories(['serif']);
flushSync();
// First change: clears fonts (expected)
store.setCategories(['serif']); // same value — same queryKey — TQ keeps data.pages
flushSync();
// Because queryKey hasn't changed, TQ returns cached data — fonts restored from cache
// (actual font count depends on cache; key assertion is no extra reset)
expect(store.isError).toBe(false);
store.destroy();
});
});
describe('staleTime in buildOptions', () => {
it('is 5 minutes with no active filters', () => {
const store = makeStore();
expect((store as any).buildOptions().staleTime).toBe(5 * 60 * 1000);
store.destroy();
});
it('is 0 when a search query is active', () => {
const store = makeStore();
store.setSearch('roboto');
expect((store as any).buildOptions().staleTime).toBe(0);
store.destroy();
});
it('is 0 when a category filter is active', () => {
const store = makeStore();
store.setCategories(['serif']);
expect((store as any).buildOptions().staleTime).toBe(0);
store.destroy();
});
it('gcTime is 10 minutes always', () => {
const store = makeStore();
expect((store as any).buildOptions().gcTime).toBe(10 * 60 * 1000);
store.destroy();
});
});
describe('buildQueryKey', () => {
it('omits empty-string params', () => {
const store = makeStore();
store.setSearch('');
const [root, normalized] = (store as any).buildQueryKey(store.params);
expect(root).toBe('fonts');
expect(normalized).not.toHaveProperty('q');
store.destroy();
});
it('omits empty-array params', () => {
const store = makeStore();
store.setProviders([]);
const [, normalized] = (store as any).buildQueryKey(store.params);
expect(normalized).not.toHaveProperty('providers');
store.destroy();
});
it('includes non-empty filter values', () => {
const store = makeStore();
store.setCategories(['serif']);
const [, normalized] = (store as any).buildQueryKey(store.params);
expect(normalized).toHaveProperty('categories', ['serif']);
store.destroy();
});
it('does not include offset (offset is the TQ page param, not a query key component)', () => {
const store = makeStore();
const [, normalized] = (store as any).buildQueryKey(store.params);
expect(normalized).not.toHaveProperty('offset');
store.destroy();
});
});
describe('destroy', () => {
it('does not throw', () => {
const store = makeStore();
expect(() => store.destroy()).not.toThrow();
});
it('is idempotent', () => {
const store = makeStore();
store.destroy();
expect(() => store.destroy()).not.toThrow();
});
});
describe('refetch', () => {
it('triggers a fetch', async () => {
const store = makeStore();
fetch.mockResolvedValue(makeResponse(generateMockFonts(3)));
await store.refetch();
expect(fetch).toHaveBeenCalled();
store.destroy();
});
it('uses params current at call time', async () => {
const store = makeStore({ limit: 10 });
store.setParams({ limit: 20 });
fetch.mockResolvedValue(makeResponse(generateMockFonts(20)));
await store.refetch();
expect(fetch).toHaveBeenCalledWith(expect.objectContaining({ limit: 20 }));
store.destroy();
});
});
describe('nextPage', () => {
let store: FontCatalogStore;
beforeEach(async () => {
fetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 30, limit: 10, offset: 0 }));
store = new FontCatalogStore({ limit: 10 });
await store.refetch();
flushSync();
});
afterEach(() => {
store.destroy();
});
it('fetches the next page and appends fonts', async () => {
fetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 30, limit: 10, offset: 10 }));
await store.nextPage();
flushSync();
expect(store.fonts).toHaveLength(20);
expect(store.pagination.offset).toBe(10);
});
it('is a no-op when hasMore is false', async () => {
// Set up a store where all fonts fit in one page (hasMore = false)
queryClient.clear();
fetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 10, limit: 10, offset: 0 }));
store = new FontCatalogStore({ limit: 10 });
await store.refetch();
flushSync();
expect(store.pagination.hasMore).toBe(false);
await store.nextPage(); // should not trigger another fetch
expect(store.fonts).toHaveLength(10);
});
});
describe('prevPage and goToPage', () => {
it('prevPage is a no-op — infinite scroll does not support backward navigation', async () => {
const store = await fetchedStore({}, generateMockFonts(5));
store.prevPage();
expect(store.fonts).toHaveLength(5); // unchanged
store.destroy();
});
it('goToPage is a no-op — infinite scroll does not support arbitrary page jumps', async () => {
const store = await fetchedStore({}, generateMockFonts(5));
store.goToPage(3);
expect(store.fonts).toHaveLength(5); // unchanged
store.destroy();
});
});
describe('prefetch', () => {
it('triggers a fetch for the provided params', async () => {
const store = makeStore();
fetch.mockResolvedValue(makeResponse(generateMockFonts(5)));
await store.prefetch({ limit: 5 });
expect(fetch).toHaveBeenCalledWith(expect.objectContaining({ limit: 5, offset: 0 }));
store.destroy();
});
});
describe('getCachedData / setQueryData', () => {
it('getCachedData returns undefined before any fetch', () => {
queryClient.clear();
const store = new FontCatalogStore({ limit: 10 });
expect(store.getCachedData()).toBeUndefined();
store.destroy();
});
it('getCachedData returns flattened fonts after fetch', async () => {
const store = await fetchedStore();
expect(store.getCachedData()).toHaveLength(5);
store.destroy();
});
it('setQueryData writes to cache', () => {
const store = makeStore();
const font = generateMockFonts(1)[0];
store.setQueryData(() => [font]);
expect(store.getCachedData()).toHaveLength(1);
store.destroy();
});
it('setQueryData updater receives existing flattened fonts', async () => {
const store = await fetchedStore();
const updater = vi.fn((old: UnifiedFont[] | undefined) => old ?? []);
store.setQueryData(updater);
expect(updater).toHaveBeenCalledWith(expect.any(Array));
store.destroy();
});
});
describe('invalidate', () => {
it('calls invalidateQueries', async () => {
const store = await fetchedStore();
const spy = vi.spyOn(queryClient, 'invalidateQueries');
store.invalidate();
expect(spy).toHaveBeenCalledOnce();
store.destroy();
});
});
describe('setLimit', () => {
it('updates the limit param', () => {
const store = makeStore({ limit: 10 });
store.setLimit(25);
expect(store.params.limit).toBe(25);
store.destroy();
});
});
describe('filter shortcut methods', () => {
let store: FontCatalogStore;
beforeEach(() => {
store = makeStore();
});
afterEach(() => {
store.destroy();
});
it('setProviders updates providers param', () => {
store.setProviders(['google']);
expect(store.params.providers).toEqual(['google']);
});
it('setCategories updates categories param', () => {
store.setCategories(['serif']);
expect(store.params.categories).toEqual(['serif']);
});
it('setSubsets updates subsets param', () => {
store.setSubsets(['cyrillic']);
expect(store.params.subsets).toEqual(['cyrillic']);
});
it('setSearch sets q param', () => {
store.setSearch('roboto');
expect(store.params.q).toBe('roboto');
});
it('setSearch with empty string clears q', () => {
store.setSearch('roboto');
store.setSearch('');
expect(store.params.q).toBeUndefined();
});
it('setSort updates sort param', () => {
store.setSort('popularity');
expect(store.params.sort).toBe('popularity');
});
});
describe('category getters', () => {
it('each getter returns only fonts of that category', async () => {
const fonts = generateMixedCategoryFonts(2); // 2 of each category = 10 total
fetch.mockResolvedValue(makeResponse(fonts, { total: fonts.length, limit: fonts.length }));
const store = makeStore({ limit: 50 });
await store.refetch();
flushSync();
expect(store.sansSerifFonts.every(f => f.category === 'sans-serif')).toBe(true);
expect(store.serifFonts.every(f => f.category === 'serif')).toBe(true);
expect(store.displayFonts.every(f => f.category === 'display')).toBe(true);
expect(store.handwritingFonts.every(f => f.category === 'handwriting')).toBe(true);
expect(store.monospaceFonts.every(f => f.category === 'monospace')).toBe(true);
expect(store.sansSerifFonts).toHaveLength(2);
store.destroy();
});
});
describe('fetchAllPagesTo', () => {
beforeEach(() => {
fetch.mockReset();
queryClient.clear();
});
it('fetches all missing pages in parallel up to targetIndex', async () => {
// First page already loaded (offset 0, limit 10, total 50)
const firstFonts = generateMockFonts(10);
fetch.mockResolvedValueOnce(makeResponse(firstFonts, { total: 50, limit: 10, offset: 0 }));
const store = makeStore();
await store.refetch();
flushSync();
expect(store.fonts).toHaveLength(10);
// Mock remaining pages
for (let offset = 10; offset < 50; offset += 10) {
fetch.mockResolvedValueOnce(
makeResponse(generateMockFonts(10), { total: 50, limit: 10, offset }),
);
}
await store.fetchAllPagesTo(40);
flushSync();
expect(store.fonts).toHaveLength(50);
});
it('skips pages that fail and still merges successful ones', async () => {
const firstFonts = generateMockFonts(10);
fetch.mockResolvedValueOnce(makeResponse(firstFonts, { total: 30, limit: 10, offset: 0 }));
const store = makeStore();
await store.refetch();
flushSync();
// offset=10 fails, offset=20 succeeds
fetch.mockRejectedValueOnce(new Error('network error'));
fetch.mockResolvedValueOnce(
makeResponse(generateMockFonts(10), { total: 30, limit: 10, offset: 20 }),
);
await store.fetchAllPagesTo(25);
flushSync();
// Page at offset=20 merged, page at offset=10 missing — 20 total
expect(store.fonts).toHaveLength(20);
});
it('is a no-op when target is within already-loaded data', async () => {
const firstFonts = generateMockFonts(10);
fetch.mockResolvedValueOnce(makeResponse(firstFonts, { total: 50, limit: 10, offset: 0 }));
const store = makeStore();
await store.refetch();
flushSync();
const callsBefore = fetch.mock.calls.length;
await store.fetchAllPagesTo(5);
expect(fetch.mock.calls.length).toBe(callsBefore);
});
});
});
@@ -1,495 +0,0 @@
import {
DEFAULT_QUERY_GC_TIME_MS,
DEFAULT_QUERY_STALE_TIME_MS,
getQueryClient,
} from '$shared/api/queryClient';
import { createSingleton } from '$shared/lib/helpers/createSingleton/createSingleton';
import {
type InfiniteData,
InfiniteQueryObserver,
type InfiniteQueryObserverResult,
type QueryFunctionContext,
} from '@tanstack/query-core';
import {
type ProxyFontsParams,
type ProxyFontsResponse,
fetchProxyFonts,
} from '../../../api';
import {
FontNetworkError,
FontResponseError,
} from '../../../lib/errors/errors';
import type { UnifiedFont } from '../../types';
type PageParam = { offset: number };
/**
* Filter params + limit — offset is managed by TQ as a page param, not a user param.
*/
type FontStoreParams = Omit<ProxyFontsParams, 'offset'>;
type FontStoreResult = InfiniteQueryObserverResult<InfiniteData<ProxyFontsResponse, PageParam>, Error>;
export class FontCatalogStore {
#params = $state<FontStoreParams>({ limit: 50 });
/**
* Gates the initial fetch. The observer starts disabled so the constructor
* cannot race ahead of the bindings module — which is the single source of
* truth for query params. The first setParams flips this on, producing a
* single fetch with the correctly merged queryKey.
*/
#enabled = $state(false);
#result = $state<FontStoreResult>({} as FontStoreResult);
#observer: InfiniteQueryObserver<
ProxyFontsResponse,
Error,
InfiniteData<ProxyFontsResponse, PageParam>,
readonly unknown[],
PageParam
>;
#qc = getQueryClient();
#unsubscribe: () => void;
constructor(params: FontStoreParams = {}) {
this.#params = { limit: 50, ...params };
this.#observer = new InfiniteQueryObserver(this.#qc, this.buildOptions());
// Seed result synchronously; subscribe may not fire on disabled observers.
this.#result = this.#observer.getCurrentResult();
this.#unsubscribe = this.#observer.subscribe(r => {
this.#result = r;
});
}
/**
* Current filter and limit configuration
*/
get params(): FontStoreParams {
return this.#params;
}
/**
* Flattened list of all fonts loaded across all pages (reactive)
*/
get fonts(): UnifiedFont[] {
return this.#result.data?.pages.flatMap((p: ProxyFontsResponse) => p.fonts) ?? [];
}
/**
* True if the first page is currently being fetched
*/
get isLoading(): boolean {
return this.#result.isLoading;
}
/**
* True if any background fetch is in progress (initial or pagination)
*/
get isFetching(): boolean {
return this.#result.isFetching;
}
/**
* True if the last fetch attempt resulted in an error
*/
get isError(): boolean {
return this.#result.isError;
}
/**
* Last caught error from the query observer
*/
get error(): Error | null {
return this.#result.error ?? null;
}
/**
* True if no fonts were found for the current filter criteria.
* Always false until the observer has been enabled (via setParams) — otherwise
* the UI would briefly render "no results" on mount before bindings configures
* the query.
*/
get isEmpty(): boolean {
return this.#enabled && !this.isLoading && !this.isFetching && this.fonts.length === 0;
}
/**
* Pagination metadata derived from the last loaded page
*/
get pagination() {
const pages = this.#result.data?.pages;
const last = pages?.at(-1);
if (!last) {
return {
total: 0,
limit: this.#params.limit ?? 50,
offset: 0,
hasMore: false,
page: 1,
totalPages: 0,
};
}
return {
total: last.total,
limit: last.limit,
offset: last.offset,
hasMore: this.#result.hasNextPage,
page: pages!.length,
totalPages: Math.ceil(last.total / last.limit),
};
}
/**
* Cleans up subscriptions and destroys the observer
*/
destroy() {
this.#unsubscribe();
this.#observer.destroy();
}
/**
* Merge new parameters into existing state and trigger a refetch.
* The first call also enables the observer (see `#enabled`).
*/
setParams(updates: Partial<FontStoreParams>) {
this.#params = { ...this.#params, ...updates };
this.#enabled = true;
this.#observer.setOptions(this.buildOptions());
}
/**
* Forcefully invalidate and refetch the current query from the network
*/
invalidate() {
this.#qc.invalidateQueries({ queryKey: this.buildQueryKey(this.#params) });
}
/**
* Manually trigger a query refetch
*/
async refetch() {
await this.#observer.refetch();
}
/**
* Prime the cache with data for a specific parameter set
*/
async prefetch(params: FontStoreParams) {
await this.#qc.prefetchInfiniteQuery(this.buildOptions(params));
}
/**
* Abort any active network requests for this store
*/
cancel() {
this.#qc.cancelQueries({ queryKey: this.buildQueryKey(this.#params) });
}
/**
* Retrieve current font list from cache without triggering a fetch
*/
getCachedData(): UnifiedFont[] | undefined {
const data = this.#qc.getQueryData<InfiniteData<ProxyFontsResponse, PageParam>>(
this.buildQueryKey(this.#params),
);
if (!data) {
return undefined;
}
return data.pages.flatMap(p => p.fonts);
}
/**
* Manually update the cached font data (useful for optimistic updates)
*/
setQueryData(updater: (old: UnifiedFont[] | undefined) => UnifiedFont[]) {
const key = this.buildQueryKey(this.#params);
this.#qc.setQueryData<InfiniteData<ProxyFontsResponse, PageParam>>(
key,
old => {
const flatFonts = old?.pages.flatMap(p => p.fonts);
const newFonts = updater(flatFonts);
// Re-distribute the updated fonts back into the existing page structure
// Define the first page. If old data exists, we merge into the first page template.
const limit = typeof this.#params.limit === 'number' ? this.#params.limit : 50;
const template = old?.pages[0] ?? {
total: newFonts.length,
limit,
offset: 0,
};
const updatedPage: ProxyFontsResponse = {
...template,
fonts: newFonts,
total: newFonts.length, // Synchronize total with the new font count
};
return {
pages: [updatedPage],
pageParams: [{ offset: 0 }],
};
},
);
}
/**
* Shortcut to update provider filters
*/
setProviders(v: ProxyFontsParams['providers']) {
this.setParams({ providers: v });
}
/**
* Shortcut to update category filters
*/
setCategories(v: ProxyFontsParams['categories']) {
this.setParams({ categories: v });
}
/**
* Shortcut to update subset filters
*/
setSubsets(v: ProxyFontsParams['subsets']) {
this.setParams({ subsets: v });
}
/**
* Shortcut to update search query
*/
setSearch(v: string) {
this.setParams({ q: v || undefined });
}
/**
* Shortcut to update sort order
*/
setSort(v: ProxyFontsParams['sort']) {
this.setParams({ sort: v });
}
/**
* Fetch the next page of results if available
*/
async nextPage(): Promise<void> {
await this.#observer.fetchNextPage();
}
#isCatchingUp = false;
#inFlightOffsets = new Set<number>();
/**
* Fetch all pages between the current loaded count and targetIndex in parallel.
* Pages are merged into the cache as they arrive (sorted by offset).
* Failed pages are silently skipped — normal scroll will re-fetch them on demand.
*/
async fetchAllPagesTo(targetIndex: number): Promise<void> {
if (this.#isCatchingUp) {
return;
}
const pageSize = typeof this.#params.limit === 'number' ? this.#params.limit : 50;
const key = this.buildQueryKey(this.#params);
const existing = this.#qc.getQueryData<InfiniteData<ProxyFontsResponse, PageParam>>(key);
if (!existing) {
return;
}
const loadedOffsets = new Set(existing.pageParams.map(p => p.offset));
// Collect offsets for all missing and not-in-flight pages
const missingOffsets: number[] = [];
for (let offset = 0; offset <= targetIndex; offset += pageSize) {
if (!loadedOffsets.has(offset) && !this.#inFlightOffsets.has(offset)) {
missingOffsets.push(offset);
}
}
if (missingOffsets.length === 0) {
return;
}
this.#isCatchingUp = true;
// Sorted merge buffer — flush in offset order as pages arrive
const buffer = new Map<number, ProxyFontsResponse>();
const failed = new Set<number>();
let nextFlushOffset = (existing.pageParams.at(-1)?.offset ?? -pageSize) + pageSize;
const flush = () => {
while (buffer.has(nextFlushOffset) || failed.has(nextFlushOffset)) {
if (buffer.has(nextFlushOffset)) {
this.#appendPageToCache(buffer.get(nextFlushOffset)!);
buffer.delete(nextFlushOffset);
}
failed.delete(nextFlushOffset);
nextFlushOffset += pageSize;
}
};
try {
await Promise.allSettled(
missingOffsets.map(async offset => {
this.#inFlightOffsets.add(offset);
try {
const page = await this.fetchPage({ ...this.#params, offset });
buffer.set(offset, page);
} catch {
failed.add(offset);
} finally {
this.#inFlightOffsets.delete(offset);
}
flush();
}),
);
} finally {
this.#isCatchingUp = false;
}
}
/**
* Backward pagination (no-op: infinite scroll accumulates forward only)
*/
prevPage(): void {}
/**
* Jump to specific page (no-op for infinite scroll)
*/
goToPage(_page: number): void {}
/**
* Update the number of items fetched per page
*/
setLimit(limit: number) {
this.setParams({ limit });
}
/**
* Derived list of sans-serif fonts in the current set
*/
get sansSerifFonts() {
return this.fonts.filter(f => f.category === 'sans-serif');
}
/**
* Derived list of serif fonts in the current set
*/
get serifFonts() {
return this.fonts.filter(f => f.category === 'serif');
}
/**
* Derived list of display fonts in the current set
*/
get displayFonts() {
return this.fonts.filter(f => f.category === 'display');
}
/**
* Derived list of handwriting fonts in the current set
*/
get handwritingFonts() {
return this.fonts.filter(f => f.category === 'handwriting');
}
/**
* Derived list of monospace fonts in the current set
*/
get monospaceFonts() {
return this.fonts.filter(f => f.category === 'monospace');
}
/**
* Merge a single page into the InfiniteQuery cache in offset order.
* Called by fetchAllPagesTo as each parallel fetch resolves.
*/
#appendPageToCache(page: ProxyFontsResponse): void {
const key = this.buildQueryKey(this.#params);
const existing = this.#qc.getQueryData<InfiniteData<ProxyFontsResponse, PageParam>>(key);
if (!existing) {
return;
}
// Guard against duplicates
const loadedOffsets = new Set(existing.pageParams.map(p => p.offset));
if (loadedOffsets.has(page.offset)) {
return;
}
const allPages = [...existing.pages, page].sort((a, b) => a.offset - b.offset);
const allParams = [...existing.pageParams, { offset: page.offset }].sort(
(a, b) => a.offset - b.offset,
);
this.#qc.setQueryData<InfiniteData<ProxyFontsResponse, PageParam>>(key, {
pages: allPages,
pageParams: allParams,
});
}
private buildQueryKey(params: FontStoreParams): readonly unknown[] {
const filtered: Record<string, any> = {};
for (const [key, value] of Object.entries(params)) {
// Ensure we DO NOT 'continue' or skip the limit key here.
// The limit is a fundamental part of the data identity.
if (
value !== undefined
&& value !== null
&& value !== ''
&& !(Array.isArray(value) && value.length === 0)
) {
filtered[key] = value;
}
}
return ['fonts', filtered];
}
private buildOptions(params = this.#params) {
const activeParams = { ...params };
const hasFilters = !!(
activeParams.q
|| (Array.isArray(activeParams.providers) && activeParams.providers.length > 0)
|| (Array.isArray(activeParams.categories) && activeParams.categories.length > 0)
|| (Array.isArray(activeParams.subsets) && activeParams.subsets.length > 0)
);
return {
queryKey: this.buildQueryKey(activeParams),
queryFn: ({ pageParam }: QueryFunctionContext<readonly unknown[], PageParam>) =>
this.fetchPage({ ...activeParams, ...pageParam }),
initialPageParam: { offset: 0 } as PageParam,
getNextPageParam: (lastPage: ProxyFontsResponse): PageParam | undefined => {
const next = lastPage.offset + lastPage.limit;
return next < lastPage.total ? { offset: next } : undefined;
},
enabled: this.#enabled,
staleTime: hasFilters ? 0 : DEFAULT_QUERY_STALE_TIME_MS,
gcTime: DEFAULT_QUERY_GC_TIME_MS,
};
}
private async fetchPage(params: ProxyFontsParams): Promise<ProxyFontsResponse> {
let response: ProxyFontsResponse;
try {
response = await fetchProxyFonts(params);
} catch (cause) {
// Preserve non-retryable validation errors so the query client doesn't
// burn the retry budget on a deterministic schema mismatch.
if (cause instanceof FontResponseError) {
throw cause;
}
throw new FontNetworkError(cause);
}
if (!response) {
throw new FontResponseError('response', response);
}
if (!response.fonts) {
throw new FontResponseError('response.fonts', response.fonts);
}
if (!Array.isArray(response.fonts)) {
throw new FontResponseError('response.fonts', response.fonts);
}
return {
fonts: response.fonts,
total: response.total ?? 0,
limit: response.limit ?? params.limit ?? 50,
offset: response.offset ?? params.offset ?? 0,
};
}
}
const catalog = createSingleton(
() => new FontCatalogStore({ limit: 50 }),
instance => instance.destroy(),
);
export const getFontCatalog = catalog.get;
// test-only reset, so specs don't share a live observer
export const __resetFontCatalog = catalog.reset;
@@ -1,35 +0,0 @@
/**
* Thrown by {@link FontBufferCache} when a font file cannot be retrieved from the network or cache.
*
* @property url - The URL that was requested.
* @property cause - The underlying error, if any.
* @property status - HTTP status code. Present on HTTP errors, absent on network failures.
*/
export class FontFetchError extends Error {
readonly name = 'FontFetchError';
constructor(
public readonly url: string,
public readonly cause?: unknown,
public readonly status?: number,
) {
super(status ? `HTTP ${status} fetching font: ${url}` : `Network error fetching font: ${url}`);
}
}
/**
* Thrown by {@link loadFont} when a font buffer cannot be parsed into a {@link FontFace}.
*
* @property fontName - The display name of the font that failed to parse.
* @property cause - The underlying error from the FontFace API.
*/
export class FontParseError extends Error {
readonly name = 'FontParseError';
constructor(
public readonly fontName: string,
public readonly cause?: unknown,
) {
super(`Failed to parse font: ${fontName}`);
}
}
@@ -1,435 +0,0 @@
import { createSingleton } from '$shared/lib/helpers/createSingleton/createSingleton';
import { SvelteMap } from 'svelte/reactivity';
import {
type FontLoadRequestConfig,
type FontLoadStatus,
} from '../../types';
import {
FontFetchError,
FontParseError,
} from './errors';
import {
generateFontKey,
getEffectiveConcurrency,
loadFont,
yieldToMainThread,
} from './utils';
import { FontBufferCache } from './utils/fontBufferCache/FontBufferCache';
import { FontEvictionPolicy } from './utils/fontEvictionPolicy/FontEvictionPolicy';
import { FontLoadQueue } from './utils/fontLoadQueue/FontLoadQueue';
/**
* How often the periodic eviction sweep runs.
*/
const PURGE_INTERVAL_MS = 60000;
/**
* Timeout for `requestIdleCallback`. After this elapses, the callback is
* forced to run regardless of whether the browser is idle.
*/
const IDLE_CALLBACK_TIMEOUT_MS = 150;
/**
* setTimeout fallback delay when `requestIdleCallback` is unavailable.
* ~16ms ≈ one frame at 60fps.
*/
const SCHEDULE_FALLBACK_MS = 16;
/**
* How often the parse loop yields back to the main thread when the browser
* does not provide `isInputPending` (non-Chromium fallback).
*/
const YIELD_INTERVAL_MS = 8;
/**
* Font weights treated as "critical" in data-saver mode. Other weights are
* skipped to reduce network usage; variable fonts bypass this filter.
*/
const CRITICAL_FONT_WEIGHTS = [400, 700];
interface FontLifecycleManagerDeps {
cache?: FontBufferCache;
eviction?: FontEvictionPolicy;
queue?: FontLoadQueue;
}
/**
* Manages web font loading with caching, adaptive concurrency, and automatic cleanup.
*
* **Two-Phase Loading Strategy:**
* 1. *Concurrent Fetching*: Font files fetched in parallel (network I/O is non-blocking)
* 2. *Sequential Parsing*: Buffers parsed into FontFace objects one at a time with periodic yields
*
* **Yielding Strategy:**
* - Chromium: Yields only when user input is pending (via `scheduler.yield()` + `isInputPending()`)
* - Others: Time-based fallback, yields every 8ms
*
* **Network Adaptation:**
* - 2G: 1 concurrent request, 3G: 2, 4G+: 4 (via Network Information API)
* - Respects `saveData` mode to defer non-critical weights
*
* **Cache Integration:** Cache API with best-effort fallback (handles private browsing, quota limits)
*
* **Cleanup:** LRU-style eviction after 5 minutes of inactivity; cleanup runs every 60 seconds
*
* **Font Identity:** Variable fonts use `{id}@vf`, static fonts use `{id}@{weight}`
*
* **Browser APIs Used:** `scheduler.yield()`, `isInputPending()`, `requestIdleCallback`, Cache API, Network Information API
*/
export class FontLifecycleManager {
// Injected collaborators - each handles one concern for better testability
readonly #cache: FontBufferCache;
readonly #eviction: FontEvictionPolicy;
readonly #queue: FontLoadQueue;
// Loaded FontFace instances registered with document.fonts. Key: `{id}@{weight}` or `{id}@vf`
#loadedFonts = new Map<string, FontFace>();
// Maps font key → URL so #purgeUnused() can evict from cache
#urlByKey = new Map<string, string>();
// Handle for scheduled queue processing (requestIdleCallback or setTimeout)
#timeoutId: ReturnType<typeof setTimeout> | null = null;
// Interval handle for periodic cleanup (runs every PURGE_INTERVAL)
#intervalId: ReturnType<typeof setInterval> | null = null;
// AbortController for canceling in-flight fetches on destroy
#abortController = new AbortController();
// Tracks which callback type is pending ('idle' | 'timeout' | null) for proper cancellation
#pendingType: 'idle' | 'timeout' | null = null;
// Reactive status map for Svelte components to track font states
statuses = new SvelteMap<string, FontLoadStatus>();
// Starts periodic cleanup timer (browser-only).
constructor(
{ cache = new FontBufferCache(), eviction = new FontEvictionPolicy(), queue = new FontLoadQueue() }:
FontLifecycleManagerDeps = {},
) {
// Inject collaborators - defaults provided for production, fakes for testing
this.#cache = cache;
this.#eviction = eviction;
this.#queue = queue;
if (typeof window !== 'undefined') {
this.#intervalId = setInterval(() => this.#purgeUnused(), PURGE_INTERVAL_MS);
}
}
/**
* Requests fonts to be loaded. Updates usage tracking and queues new fonts.
*
* Retry behavior: 'loaded' and 'loading' fonts are skipped; 'error' fonts retry if count < MAX_RETRIES.
* Scheduling: Prefers requestIdleCallback (150ms timeout), falls back to setTimeout(16ms).
*/
touch(configs: FontLoadRequestConfig[]) {
if (this.#abortController.signal.aborted) {
return;
}
try {
const now = Date.now();
let hasNewItems = false;
for (const config of configs) {
const key = generateFontKey(config);
// Update last-used timestamp for LRU eviction policy
this.#eviction.touch(key, now);
const status = this.statuses.get(key);
// Skip fonts that are already loaded or currently loading
if (status === 'loaded' || status === 'loading') {
continue;
}
// Skip fonts already in the queue (avoid duplicates)
if (this.#queue.has(key)) {
continue;
}
// Skip error fonts that have exceeded max retry count
if (status === 'error' && this.#queue.isMaxRetriesReached(key)) {
continue;
}
// Queue this font for loading
this.#queue.enqueue(key, config);
hasNewItems = true;
}
if (hasNewItems && !this.#timeoutId) {
this.#scheduleProcessing();
}
} catch (error) {
console.error(error);
}
}
/**
* Schedules `#processQueue()` via `requestIdleCallback` (150ms timeout) when available,
* falling back to `setTimeout(16ms)` for ~60fps timing.
*/
#scheduleProcessing(): void {
if (typeof requestIdleCallback !== 'undefined') {
this.#timeoutId = requestIdleCallback(
() => this.#processQueue(),
{ timeout: IDLE_CALLBACK_TIMEOUT_MS },
) as unknown as ReturnType<typeof setTimeout>;
this.#pendingType = 'idle';
} else {
this.#timeoutId = setTimeout(() => this.#processQueue(), SCHEDULE_FALLBACK_MS);
this.#pendingType = 'timeout';
}
}
/**
* Returns true if data-saver mode is enabled (defers non-critical weights).
*/
#shouldDeferNonCritical(): boolean {
return (navigator as any).connection?.saveData === true;
}
/**
* Processes queued fonts in two phases:
* 1. Concurrent fetching (network I/O, non-blocking)
* 2. Sequential parsing with periodic yields (CPU-intensive, can block UI)
*
* Yielding: Chromium uses `isInputPending()` for optimal responsiveness; others yield every 8ms.
*/
async #processQueue() {
// Clear timer flags since we're now processing
this.#timeoutId = null;
this.#pendingType = null;
// Get all queued entries and clear the queue atomically
let entries = this.#queue.flush();
if (!entries.length) {
return;
}
// In data-saver mode, only load variable fonts and common weights (400, 700)
if (this.#shouldDeferNonCritical()) {
entries = entries.filter(([, c]) => c.isVariable || CRITICAL_FONT_WEIGHTS.includes(c.weight));
}
// Determine optimal concurrent fetches based on network speed (1-4)
const concurrency = getEffectiveConcurrency();
const buffers = new Map<string, ArrayBuffer>();
// Fetch multiple font files in parallel since network I/O is non-blocking
for (let i = 0; i < entries.length; i += concurrency) {
await this.#fetchChunk(entries.slice(i, i + concurrency), buffers);
}
// Parse buffers one at a time with periodic yields to avoid blocking UI
const hasInputPending = !!(navigator as any).scheduling?.isInputPending;
let lastYield = performance.now();
for (const [key, config] of entries) {
const buffer = buffers.get(key);
// Skip fonts that failed to fetch in phase 1
if (!buffer) {
continue;
}
await this.#processFont(key, config, buffer);
// Yield to main thread if needed (prevents UI blocking)
// Chromium: use isInputPending() for optimal responsiveness
// Others: yield every 8ms as fallback
const shouldYield = hasInputPending
? (navigator as any).scheduling.isInputPending({ includeContinuous: true })
: performance.now() - lastYield > YIELD_INTERVAL_MS;
if (shouldYield) {
await yieldToMainThread();
lastYield = performance.now();
}
}
}
/**
* Fetches a chunk of fonts concurrently and populates `buffers` with successful results.
* Each promise carries its own key and config so results need no index correlation.
* Aborted fetches are silently skipped; other errors set status to `'error'` and increment retry.
*/
async #fetchChunk(
chunk: Array<[string, FontLoadRequestConfig]>,
buffers: Map<string, ArrayBuffer>,
): Promise<void> {
const results = await Promise.all(
chunk.map(async ([key, config]) => {
this.statuses.set(key, 'loading');
try {
const buffer = await this.#cache.get(config.url, this.#abortController.signal);
buffers.set(key, buffer);
return { ok: true as const, key };
} catch (reason) {
return { ok: false as const, key, config, reason };
}
}),
);
for (const result of results) {
if (result.ok) {
continue;
}
const { key, config, reason } = result;
const isAbort = reason instanceof FontFetchError
&& reason.cause instanceof Error
&& reason.cause.name === 'AbortError';
if (isAbort) {
continue;
}
if (reason instanceof FontFetchError) {
console.error(`Font fetch failed: ${config.name}`, reason);
}
this.statuses.set(key, 'error');
this.#queue.incrementRetry(key);
}
}
/**
* Parses a fetched buffer into a {@link FontFace}, registers it with `document.fonts`,
* and updates reactive status. On failure, sets status to `'error'` and increments the retry count.
*/
async #processFont(key: string, config: FontLoadRequestConfig, buffer: ArrayBuffer): Promise<void> {
try {
const font = await loadFont(config, buffer);
this.#loadedFonts.set(key, font);
this.#urlByKey.set(key, config.url);
this.statuses.set(key, 'loaded');
} catch (e) {
if (e instanceof FontParseError) {
console.error(`Font parse failed: ${config.name}`, e);
this.statuses.set(key, 'error');
this.#queue.incrementRetry(key);
}
}
}
/**
* Removes fonts unused within TTL (LRU-style cleanup). Runs every PURGE_INTERVAL. Pinned fonts are never evicted.
*/
#purgeUnused() {
const now = Date.now();
// Iterate through all tracked font keys
for (const key of this.#eviction.keys()) {
// Skip fonts that are still within TTL or are pinned
if (!this.#eviction.shouldEvict(key, now)) {
continue;
}
// Remove FontFace from document to free memory
const font = this.#loadedFonts.get(key);
if (font) {
document.fonts.delete(font);
}
// Evict from cache and cleanup URL mapping
const url = this.#urlByKey.get(key);
if (url) {
this.#cache.evict(url);
this.#urlByKey.delete(key);
}
// Clean up remaining state
this.#loadedFonts.delete(key);
this.statuses.delete(key);
this.#eviction.remove(key);
}
}
/**
* Returns current loading status for a font, or undefined if never requested.
*/
getFontStatus(id: string, weight: number, isVariable = false) {
try {
return this.statuses.get(generateFontKey({ id, weight, isVariable }));
} catch (error) {
console.error(error);
}
}
/**
* Pins a font so it is never evicted by #purgeUnused(), regardless of TTL.
*/
pin(id: string, weight: number, isVariable = false): void {
this.#eviction.pin(generateFontKey({ id, weight, isVariable }));
}
/**
* Unpins a font, allowing it to be evicted by #purgeUnused() once its TTL expires.
*/
unpin(id: string, weight: number, isVariable = false): void {
this.#eviction.unpin(generateFontKey({ id, weight, isVariable }));
}
/**
* Waits for all fonts to finish loading using document.fonts.ready.
*/
async ready(): Promise<void> {
if (typeof document === 'undefined') {
return;
}
try {
await document.fonts.ready;
} catch { /* document unloaded */ }
}
/**
* Aborts all operations, removes fonts from document, and clears state. Manager cannot be reused after.
*/
destroy() {
// Abort all in-flight network requests
this.#abortController.abort();
// Cancel pending queue processing (idle callback or timeout)
if (this.#timeoutId !== null) {
if (this.#pendingType === 'idle' && typeof cancelIdleCallback !== 'undefined') {
cancelIdleCallback(this.#timeoutId as unknown as number);
} else {
clearTimeout(this.#timeoutId);
}
this.#timeoutId = null;
this.#pendingType = null;
}
// Stop periodic cleanup timer
if (this.#intervalId) {
clearInterval(this.#intervalId);
this.#intervalId = null;
}
// Remove all loaded fonts from document
if (typeof document !== 'undefined') {
for (const font of this.#loadedFonts.values()) {
document.fonts.delete(font);
}
}
// Clear all state and collaborators
this.#loadedFonts.clear();
this.#urlByKey.clear();
this.#cache.clear();
this.#eviction.clear();
this.#queue.clear();
this.statuses.clear();
}
}
/**
* App-wide font lifecycle manager, created on first access. Lazy so its
* AbortController / FontFace bookkeeping isn't set up at module load.
*/
const fontLifecycleManager = createSingleton(
() => new FontLifecycleManager(),
instance => instance.destroy(),
);
export const getFontLifecycleManager = fontLifecycleManager.get;
// test-only reset, so specs don't share loaded-font/eviction state
export const __resetFontLifecycleManager = fontLifecycleManager.reset;
@@ -1,318 +0,0 @@
/**
* @vitest-environment jsdom
*/
import { FontFetchError } from './errors';
import { FontLifecycleManager } from './fontLifecycleManager.svelte';
import { FontEvictionPolicy } from './utils/fontEvictionPolicy/FontEvictionPolicy';
class FakeBufferCache {
async get(_url: string): Promise<ArrayBuffer> {
return new ArrayBuffer(8);
}
evict(_url: string): void {}
clear(): void {}
}
/**
* Throws {@link FontFetchError} on every `get()` — simulates network/HTTP failure.
*/
class FailingBufferCache {
async get(url: string): Promise<never> {
throw new FontFetchError(url, new Error('network error'), 500);
}
evict(_url: string): void {}
clear(): void {}
}
const makeConfig = (id: string, overrides: Partial<{ weight: number; isVariable: boolean }> = {}) => ({
id,
name: id,
url: `https://example.com/${id}.woff2`,
weight: 400,
...overrides,
});
describe('FontLifecycleManager', () => {
let manager: FontLifecycleManager;
let eviction: FontEvictionPolicy;
let mockFontFaceSet: { add: ReturnType<typeof vi.fn>; delete: ReturnType<typeof vi.fn> };
beforeEach(() => {
vi.useFakeTimers();
eviction = new FontEvictionPolicy({ ttl: 60000 });
mockFontFaceSet = { add: vi.fn(), delete: vi.fn() };
Object.defineProperty(document, 'fonts', {
value: mockFontFaceSet,
configurable: true,
writable: true,
});
const MockFontFace = vi.fn(function(this: any, name: string, buffer: BufferSource) {
this.name = name;
this.buffer = buffer;
this.load = vi.fn().mockResolvedValue(this);
});
vi.stubGlobal('FontFace', MockFontFace);
manager = new FontLifecycleManager({ cache: new FakeBufferCache() as any, eviction });
});
afterEach(() => {
vi.clearAllTimers();
vi.useRealTimers();
vi.unstubAllGlobals();
});
describe('touch()', () => {
it('queues and loads a new font', async () => {
manager.touch([makeConfig('roboto')]);
await vi.advanceTimersByTimeAsync(50);
expect(manager.getFontStatus('roboto', 400)).toBe('loaded');
});
it('batches multiple fonts into a single queue flush', async () => {
manager.touch([makeConfig('lato'), makeConfig('inter')]);
await vi.advanceTimersByTimeAsync(50);
expect(mockFontFaceSet.add).toHaveBeenCalledTimes(2);
});
it('skips fonts that are already loaded', async () => {
manager.touch([makeConfig('lato')]);
await vi.advanceTimersByTimeAsync(50);
manager.touch([makeConfig('lato')]);
await vi.advanceTimersByTimeAsync(50);
expect(mockFontFaceSet.add).toHaveBeenCalledTimes(1);
});
it('skips fonts that are currently loading', async () => {
manager.touch([makeConfig('lato')]);
// simulate loading state before queue drains
manager.statuses.set('lato@400', 'loading');
manager.touch([makeConfig('lato')]);
await vi.advanceTimersByTimeAsync(50);
expect(mockFontFaceSet.add).toHaveBeenCalledTimes(1);
});
it('skips fonts that have exhausted retries', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const failManager = new FontLifecycleManager({ cache: new FailingBufferCache() as any, eviction });
// exhaust all 3 retries
for (let i = 0; i < 3; i++) {
failManager.statuses.delete('broken@400');
failManager.touch([makeConfig('broken')]);
await vi.advanceTimersByTimeAsync(50);
}
failManager.touch([makeConfig('broken')]);
await vi.advanceTimersByTimeAsync(50);
expect(failManager.getFontStatus('broken', 400)).toBe('error');
expect(mockFontFaceSet.add).not.toHaveBeenCalled();
consoleSpy.mockRestore();
});
it('does nothing after manager is destroyed', async () => {
manager.destroy();
manager.touch([makeConfig('roboto')]);
await vi.advanceTimersByTimeAsync(50);
expect(manager.statuses.size).toBe(0);
});
});
describe('queue processing', () => {
it('filters non-critical weights in data-saver mode', async () => {
(navigator as any).connection = { saveData: true };
manager.touch([
makeConfig('light', { weight: 300 }),
makeConfig('regular', { weight: 400 }),
makeConfig('bold', { weight: 700 }),
]);
await vi.advanceTimersByTimeAsync(50);
expect(manager.getFontStatus('light', 300)).toBeUndefined();
expect(manager.getFontStatus('regular', 400)).toBe('loaded');
expect(manager.getFontStatus('bold', 700)).toBe('loaded');
delete (navigator as any).connection;
});
it('loads variable fonts in data-saver mode regardless of weight', async () => {
(navigator as any).connection = { saveData: true };
manager.touch([makeConfig('vf', { weight: 300, isVariable: true })]);
await vi.advanceTimersByTimeAsync(50);
expect(manager.getFontStatus('vf', 300, true)).toBe('loaded');
delete (navigator as any).connection;
});
});
describe('Phase 1 — fetch', () => {
it('sets status to error on fetch failure', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const failManager = new FontLifecycleManager({ cache: new FailingBufferCache() as any, eviction });
failManager.touch([makeConfig('broken')]);
await vi.advanceTimersByTimeAsync(50);
expect(failManager.getFontStatus('broken', 400)).toBe('error');
consoleSpy.mockRestore();
});
it('logs a console error on fetch failure', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const failManager = new FontLifecycleManager({ cache: new FailingBufferCache() as any, eviction });
failManager.touch([makeConfig('broken')]);
await vi.advanceTimersByTimeAsync(50);
expect(consoleSpy).toHaveBeenCalled();
consoleSpy.mockRestore();
});
it('does not set error status or log for aborted fetches', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const abortingCache = {
async get(url: string): Promise<never> {
throw new FontFetchError(url, Object.assign(new Error('Aborted'), { name: 'AbortError' }));
},
evict() {},
clear() {},
};
const abortManager = new FontLifecycleManager({ cache: abortingCache as any, eviction });
abortManager.touch([makeConfig('aborted')]);
await vi.advanceTimersByTimeAsync(50);
// status is left as 'loading' (not 'error') — abort is not a retriable failure
expect(abortManager.getFontStatus('aborted', 400)).not.toBe('error');
expect(consoleSpy).not.toHaveBeenCalled();
consoleSpy.mockRestore();
});
});
describe('Phase 2 — parse', () => {
it('sets status to error on parse failure', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const FailingFontFace = vi.fn(function(this: any) {
this.load = vi.fn().mockRejectedValue(new Error('parse failed'));
});
vi.stubGlobal('FontFace', FailingFontFace);
manager.touch([makeConfig('broken')]);
await vi.advanceTimersByTimeAsync(50);
expect(manager.getFontStatus('broken', 400)).toBe('error');
consoleSpy.mockRestore();
});
it('logs a console error on parse failure', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const FailingFontFace = vi.fn(function(this: any) {
this.load = vi.fn().mockRejectedValue(new Error('parse failed'));
});
vi.stubGlobal('FontFace', FailingFontFace);
manager.touch([makeConfig('broken')]);
await vi.advanceTimersByTimeAsync(50);
expect(consoleSpy).toHaveBeenCalled();
consoleSpy.mockRestore();
});
});
describe('#purgeUnused', () => {
it('evicts fonts after TTL expires', async () => {
manager.touch([makeConfig('ephemeral')]);
await vi.advanceTimersByTimeAsync(50);
await vi.advanceTimersByTimeAsync(61000);
expect(manager.getFontStatus('ephemeral', 400)).toBeUndefined();
expect(mockFontFaceSet.delete).toHaveBeenCalled();
});
it('removes the evicted key from the eviction policy', async () => {
manager.touch([makeConfig('ephemeral')]);
await vi.advanceTimersByTimeAsync(50);
await vi.advanceTimersByTimeAsync(61000);
expect(Array.from(eviction.keys())).not.toContain('ephemeral@400');
});
it('refreshes TTL when font is re-touched before expiry', async () => {
const config = makeConfig('active');
manager.touch([config]);
await vi.advanceTimersByTimeAsync(50);
await vi.advanceTimersByTimeAsync(40000);
manager.touch([config]); // refresh at t≈40s
await vi.advanceTimersByTimeAsync(25000); // purge at t≈60s sees only ~20s elapsed → not evicted
expect(manager.getFontStatus('active', 400)).toBe('loaded');
});
it('does not evict pinned fonts', async () => {
manager.touch([makeConfig('pinned')]);
await vi.advanceTimersByTimeAsync(50);
manager.pin('pinned', 400);
await vi.advanceTimersByTimeAsync(61000);
expect(manager.getFontStatus('pinned', 400)).toBe('loaded');
expect(mockFontFaceSet.delete).not.toHaveBeenCalled();
});
it('evicts font after it is unpinned and TTL expires', async () => {
manager.touch([makeConfig('toggled')]);
await vi.advanceTimersByTimeAsync(50);
manager.pin('toggled', 400);
manager.unpin('toggled', 400);
await vi.advanceTimersByTimeAsync(61000);
expect(manager.getFontStatus('toggled', 400)).toBeUndefined();
expect(mockFontFaceSet.delete).toHaveBeenCalled();
});
});
describe('destroy()', () => {
it('clears all statuses', async () => {
manager.touch([makeConfig('roboto')]);
await vi.advanceTimersByTimeAsync(50);
manager.destroy();
expect(manager.statuses.size).toBe(0);
});
it('removes all loaded fonts from document.fonts', async () => {
manager.touch([makeConfig('roboto'), makeConfig('inter')]);
await vi.advanceTimersByTimeAsync(50);
manager.destroy();
expect(mockFontFaceSet.delete).toHaveBeenCalledTimes(2);
});
it('prevents further loading after destroy', async () => {
manager.destroy();
manager.touch([makeConfig('roboto')]);
await vi.advanceTimersByTimeAsync(50);
expect(manager.statuses.size).toBe(0);
});
});
});
@@ -1,68 +0,0 @@
/**
* @vitest-environment jsdom
*/
import { FontFetchError } from '../../errors';
import { FontBufferCache } from './FontBufferCache';
const makeBuffer = () => new ArrayBuffer(8);
const makeFetcher = (overrides: Partial<Response> = {}) =>
vi.fn().mockResolvedValue({
ok: true,
status: 200,
arrayBuffer: () => Promise.resolve(makeBuffer()),
clone: () => ({ ok: true, status: 200, arrayBuffer: () => Promise.resolve(makeBuffer()) }),
...overrides,
} as Response);
describe('FontBufferCache', () => {
let cache: FontBufferCache;
let fetcher: ReturnType<typeof makeFetcher>;
beforeEach(() => {
fetcher = makeFetcher();
cache = new FontBufferCache({ fetcher });
});
it('returns buffer from memory on second call without fetching', async () => {
await cache.get('https://example.com/font.woff2');
await cache.get('https://example.com/font.woff2');
expect(fetcher).toHaveBeenCalledOnce();
});
it('throws FontFetchError on HTTP error with correct status', async () => {
const errorFetcher = makeFetcher({ ok: false, status: 404 });
const errorCache = new FontBufferCache({ fetcher: errorFetcher });
const err = await errorCache.get('https://example.com/font.woff2').catch(e => e);
expect(err).toBeInstanceOf(FontFetchError);
expect(err.status).toBe(404);
});
it('throws FontFetchError on network failure without status', async () => {
const networkFetcher = vi.fn().mockRejectedValue(new Error('network down'));
const networkCache = new FontBufferCache({ fetcher: networkFetcher });
const err = await networkCache.get('https://example.com/font.woff2').catch(e => e);
expect(err).toBeInstanceOf(FontFetchError);
expect(err.status).toBeUndefined();
});
it('evict removes url from memory so next call fetches again', async () => {
await cache.get('https://example.com/font.woff2');
cache.evict('https://example.com/font.woff2');
await cache.get('https://example.com/font.woff2');
expect(fetcher).toHaveBeenCalledTimes(2);
});
it('clear wipes all memory cache entries', async () => {
await cache.get('https://example.com/a.woff2');
await cache.get('https://example.com/b.woff2');
cache.clear();
await cache.get('https://example.com/a.woff2');
expect(fetcher).toHaveBeenCalledTimes(3);
});
});
@@ -1,105 +0,0 @@
import { FontFetchError } from '../../errors';
type Fetcher = (url: string, init?: RequestInit) => Promise<Response>;
interface FontBufferCacheOptions {
/**
* Custom fetch implementation. Defaults to `globalThis.fetch`. Inject in tests for isolation.
*/
fetcher?: Fetcher;
/**
* Cache API cache name. Defaults to `'font-cache-v1'`.
*/
cacheName?: string;
}
/**
* Three-tier font buffer cache: in-memory → Cache API → network.
*
* - **Tier 1 (memory):** Fastest — no I/O. Populated after first successful fetch.
* - **Tier 2 (Cache API):** Persists across page loads. Silently skipped in private browsing.
* - **Tier 3 (network):** Raw fetch. Throws {@link FontFetchError} on failure.
*
* The `fetcher` option is injectable for testing — pass a `vi.fn()` to avoid real network calls.
*/
export class FontBufferCache {
#buffersByUrl = new Map<string, ArrayBuffer>();
readonly #fetcher: Fetcher;
readonly #cacheName: string;
constructor(
{ fetcher = globalThis.fetch.bind(globalThis), cacheName = 'font-cache-v1' }: FontBufferCacheOptions = {},
) {
this.#fetcher = fetcher;
this.#cacheName = cacheName;
}
/**
* Retrieves the font buffer for the given URL using the three-tier strategy.
* Stores the result in memory on success.
*
* @throws {@link FontFetchError} if the network request fails or returns a non-OK response.
*/
async get(url: string, signal?: AbortSignal): Promise<ArrayBuffer> {
// Tier 1: in-memory (fastest, no I/O)
const inMemory = this.#buffersByUrl.get(url);
if (inMemory) {
return inMemory;
}
// Tier 2: Cache API
try {
if (typeof caches !== 'undefined') {
const cache = await caches.open(this.#cacheName);
const cached = await cache.match(url);
if (cached) {
const buffer = await cached.arrayBuffer();
this.#buffersByUrl.set(url, buffer);
return buffer;
}
}
} catch {
// Cache unavailable (private browsing, security restrictions) — fall through to network
}
// Tier 3: network
let response: Response;
try {
response = await this.#fetcher(url, { signal });
} catch (cause) {
throw new FontFetchError(url, cause);
}
if (!response.ok) {
throw new FontFetchError(url, undefined, response.status);
}
try {
if (typeof caches !== 'undefined') {
const cache = await caches.open(this.#cacheName);
await cache.put(url, response.clone());
}
} catch {
// Cache write failed (quota, storage pressure) — return font anyway
}
const buffer = await response.arrayBuffer();
this.#buffersByUrl.set(url, buffer);
return buffer;
}
/**
* Removes a URL from the in-memory cache. Next call to `get()` will re-fetch.
*/
evict(url: string): void {
this.#buffersByUrl.delete(url);
}
/**
* Clears all in-memory cached buffers.
*/
clear(): void {
this.#buffersByUrl.clear();
}
}
@@ -1,69 +0,0 @@
import { FontEvictionPolicy } from './FontEvictionPolicy';
describe('FontEvictionPolicy', () => {
let policy: FontEvictionPolicy;
const TTL = 1000;
const t0 = 100000;
beforeEach(() => {
policy = new FontEvictionPolicy({ ttl: TTL });
});
it('shouldEvict returns false within TTL', () => {
policy.touch('a@400', t0);
expect(policy.shouldEvict('a@400', t0 + TTL - 1)).toBe(false);
});
it('shouldEvict returns true at TTL boundary', () => {
policy.touch('a@400', t0);
expect(policy.shouldEvict('a@400', t0 + TTL)).toBe(true);
});
it('shouldEvict returns false for pinned key regardless of TTL', () => {
policy.touch('a@400', t0);
policy.pin('a@400');
expect(policy.shouldEvict('a@400', t0 + TTL * 10)).toBe(false);
});
it('shouldEvict returns true again after unpin past TTL', () => {
policy.touch('a@400', t0);
policy.pin('a@400');
policy.unpin('a@400');
expect(policy.shouldEvict('a@400', t0 + TTL)).toBe(true);
});
it('shouldEvict returns false for untracked key', () => {
expect(policy.shouldEvict('never@touched', t0 + TTL * 100)).toBe(false);
});
it('keys returns all tracked keys', () => {
policy.touch('a@400', t0);
policy.touch('b@vf', t0);
expect(Array.from(policy.keys())).toEqual(expect.arrayContaining(['a@400', 'b@vf']));
});
it('remove deletes key from tracking so it no longer appears in keys()', () => {
policy.touch('a@400', t0);
policy.touch('b@vf', t0);
policy.remove('a@400');
expect(Array.from(policy.keys())).not.toContain('a@400');
expect(Array.from(policy.keys())).toContain('b@vf');
});
it('remove unpins the key so a subsequent touch + TTL would evict it', () => {
policy.touch('a@400', t0);
policy.pin('a@400');
policy.remove('a@400');
// re-touch and check it can be evicted again
policy.touch('a@400', t0);
expect(policy.shouldEvict('a@400', t0 + TTL)).toBe(true);
});
it('clear resets all state', () => {
policy.touch('a@400', t0);
policy.pin('a@400');
policy.clear();
expect(Array.from(policy.keys())).toHaveLength(0);
expect(policy.shouldEvict('a@400', t0 + TTL * 10)).toBe(false);
});
});
@@ -1,93 +0,0 @@
/**
* Default TTL after which an unpinned font is eligible for eviction.
*/
export const DEFAULT_FONT_TTL_MS = 5 * 60 * 1000;
interface FontEvictionPolicyOptions {
/**
* TTL in milliseconds. Defaults to {@link DEFAULT_FONT_TTL_MS}.
*/
ttl?: number;
}
/**
* Tracks font usage timestamps and pinned keys to determine when a font should be evicted.
*
* Pure data — no browser APIs. Accepts explicit `now` timestamps so tests
* never need fake timers.
*/
export class FontEvictionPolicy {
#usageTracker = new Map<string, number>();
#pinnedFonts = new Set<string>();
readonly #TTL: number;
constructor({ ttl = DEFAULT_FONT_TTL_MS }: FontEvictionPolicyOptions = {}) {
this.#TTL = ttl;
}
/**
* Records the last-used time for a font key.
* @param key - Font key in `{id}@{weight}` or `{id}@vf` format.
* @param now - Current timestamp in ms. Defaults to `Date.now()`.
*/
touch(key: string, now: number = Date.now()): void {
this.#usageTracker.set(key, now);
}
/**
* Pins a font key so it is never evicted regardless of TTL.
*/
pin(key: string): void {
this.#pinnedFonts.add(key);
}
/**
* Unpins a font key, allowing it to be evicted once its TTL expires.
*/
unpin(key: string): void {
this.#pinnedFonts.delete(key);
}
/**
* Returns `true` if the font should be evicted.
* A font is evicted when its TTL has elapsed and it is not pinned.
* Returns `false` for untracked keys.
*
* @param key - Font key to check.
* @param now - Current timestamp in ms (pass explicitly for deterministic tests).
*/
shouldEvict(key: string, now: number): boolean {
const lastUsed = this.#usageTracker.get(key);
if (lastUsed === undefined) {
return false;
}
if (this.#pinnedFonts.has(key)) {
return false;
}
return now - lastUsed >= this.#TTL;
}
/**
* Returns an iterator over all tracked font keys.
*/
keys(): IterableIterator<string> {
return this.#usageTracker.keys();
}
/**
* Removes a font key from tracking. Called by the orchestrator after eviction.
*/
remove(key: string): void {
this.#usageTracker.delete(key);
this.#pinnedFonts.delete(key);
}
/**
* Clears all usage timestamps and pinned keys.
*/
clear(): void {
this.#usageTracker.clear();
this.#pinnedFonts.clear();
}
}
@@ -1,65 +0,0 @@
import type { FontLoadRequestConfig } from '../../../../types';
import { FontLoadQueue } from './FontLoadQueue';
const config = (id: string): FontLoadRequestConfig => ({
id,
name: id,
url: `https://example.com/${id}.woff2`,
weight: 400,
});
describe('FontLoadQueue', () => {
let queue: FontLoadQueue;
beforeEach(() => {
queue = new FontLoadQueue();
});
it('enqueue returns true for a new key', () => {
expect(queue.enqueue('a@400', config('a'))).toBe(true);
});
it('enqueue returns false for an already-queued key', () => {
queue.enqueue('a@400', config('a'));
expect(queue.enqueue('a@400', config('a'))).toBe(false);
});
it('has returns true after enqueue, false after flush', () => {
queue.enqueue('a@400', config('a'));
expect(queue.has('a@400')).toBe(true);
queue.flush();
expect(queue.has('a@400')).toBe(false);
});
it('flush returns all entries and atomically clears the queue', () => {
queue.enqueue('a@400', config('a'));
queue.enqueue('b@700', config('b'));
const entries = queue.flush();
expect(entries).toHaveLength(2);
expect(queue.has('a@400')).toBe(false);
expect(queue.has('b@700')).toBe(false);
});
it('isMaxRetriesReached returns false below MAX_RETRIES', () => {
queue.incrementRetry('a@400');
queue.incrementRetry('a@400');
expect(queue.isMaxRetriesReached('a@400')).toBe(false);
});
it('isMaxRetriesReached returns true at MAX_RETRIES (3)', () => {
queue.incrementRetry('a@400');
queue.incrementRetry('a@400');
queue.incrementRetry('a@400');
expect(queue.isMaxRetriesReached('a@400')).toBe(true);
});
it('clear resets queue and retry counts', () => {
queue.enqueue('a@400', config('a'));
queue.incrementRetry('a@400');
queue.incrementRetry('a@400');
queue.incrementRetry('a@400');
queue.clear();
expect(queue.has('a@400')).toBe(false);
expect(queue.isMaxRetriesReached('a@400')).toBe(false);
});
});
@@ -1,69 +0,0 @@
import type { FontLoadRequestConfig } from '../../../../types';
/**
* Maximum number of times a single font key will be retried before it is
* considered permanently failed.
*/
export const FONT_LOAD_MAX_RETRIES = 3;
/**
* Manages the font load queue and per-font retry counts.
*
* Scheduling (when to drain the queue) is handled by the orchestrator —
* this class is purely concerned with what is queued and whether retries are exhausted.
*/
export class FontLoadQueue {
#queue = new Map<string, FontLoadRequestConfig>();
#retryCounts = new Map<string, number>();
/**
* Adds a font to the queue.
* @returns `true` if the key was newly enqueued, `false` if it was already present.
*/
enqueue(key: string, config: FontLoadRequestConfig): boolean {
if (this.#queue.has(key)) {
return false;
}
this.#queue.set(key, config);
return true;
}
/**
* Atomically snapshots and clears the queue.
* @returns All queued entries at the time of the call.
*/
flush(): Array<[string, FontLoadRequestConfig]> {
const entries = Array.from(this.#queue.entries());
this.#queue.clear();
return entries;
}
/**
* Returns `true` if the key is currently in the queue.
*/
has(key: string): boolean {
return this.#queue.has(key);
}
/**
* Increments the retry count for a font key.
*/
incrementRetry(key: string): void {
this.#retryCounts.set(key, (this.#retryCounts.get(key) ?? 0) + 1);
}
/**
* Returns `true` if the font has reached or exceeded the maximum retry limit.
*/
isMaxRetriesReached(key: string): boolean {
return (this.#retryCounts.get(key) ?? 0) >= FONT_LOAD_MAX_RETRIES;
}
/**
* Clears all queued fonts and resets all retry counts.
*/
clear(): void {
this.#queue.clear();
this.#retryCounts.clear();
}
}
@@ -1,25 +0,0 @@
import { generateFontKey } from './generateFontKey';
describe('generateFontKey', () => {
it('should throw an error if font id is not provided', () => {
const config = { weight: 400, isVariable: false };
// @ts-expect-error
expect(() => generateFontKey(config)).toThrow('Font id is required');
});
it('should generate a font key for a variable font', () => {
const config = { id: 'Roboto', weight: 400, isVariable: true };
expect(generateFontKey(config)).toBe('roboto@vf');
});
it('should throw an error if font weight is not provided and is not a variable font', () => {
const config = { id: 'Roboto', isVariable: false };
// @ts-expect-error
expect(() => generateFontKey(config)).toThrow('Font weight is required');
});
it('should generate a font key for a non-variable font', () => {
const config = { id: 'Roboto', weight: 400, isVariable: false };
expect(generateFontKey(config)).toBe('roboto@400');
});
});
@@ -1,22 +0,0 @@
import type { FontLoadRequestConfig } from '../../../../types';
export type PartialConfig = Pick<FontLoadRequestConfig, 'id' | 'weight' | 'isVariable'>;
/**
* Generates a font key for a given font load request configuration.
* @param config - The font load request configuration.
* @returns The generated font key.
*/
export function generateFontKey(config: PartialConfig): string {
if (!config.id) {
throw new Error('Font id is required');
}
if (config.isVariable) {
return `${config.id.toLowerCase()}@vf`;
}
if (!config.weight) {
throw new Error('Font weight is required');
}
return `${config.id.toLowerCase()}@${config.weight}`;
}
@@ -1,41 +0,0 @@
import {
beforeEach,
describe,
expect,
it,
} from 'vitest';
import {
Concurrency,
getEffectiveConcurrency,
} from './getEffectiveConcurrency';
describe('getEffectiveConcurrency', () => {
beforeEach(() => {
const nav = navigator as any;
nav.connection = null;
});
it('should return MAX when connection is not available', () => {
const nav = navigator as any;
nav.connection = null;
expect(getEffectiveConcurrency()).toBe(Concurrency.MAX);
});
it('should return MIN for slow-2g or 2g connection', () => {
const nav = navigator as any;
nav.connection = { effectiveType: 'slow-2g' };
expect(getEffectiveConcurrency()).toBe(Concurrency.MIN);
});
it('should return AVERAGE for 3g connection', () => {
const nav = navigator as any;
nav.connection = { effectiveType: '3g' };
expect(getEffectiveConcurrency()).toBe(Concurrency.AVERAGE);
});
it('should return MAX for other connection types', () => {
const nav = navigator as any;
nav.connection = { effectiveType: '4g' };
expect(getEffectiveConcurrency()).toBe(Concurrency.MAX);
});
});
@@ -1,26 +0,0 @@
export enum Concurrency {
MIN = 1,
AVERAGE = 2,
MAX = 4,
}
/**
* Calculates the amount of fonts for concurrent download based on the user internet connection
*/
export function getEffectiveConcurrency(): number {
const nav = navigator as any;
const connection = nav.connection;
if (!connection) {
return Concurrency.MAX;
}
switch (connection.effectiveType) {
case 'slow-2g':
case '2g':
return Concurrency.MIN;
case '3g':
return Concurrency.AVERAGE;
default:
return Concurrency.MAX;
}
}
@@ -1,4 +0,0 @@
export { generateFontKey } from './generateFontKey/generateFontKey';
export { getEffectiveConcurrency } from './getEffectiveConcurrency/getEffectiveConcurrency';
export { loadFont } from './loadFont/loadFont';
export { yieldToMainThread } from './yieldToMainThread/yieldToMainThread';
@@ -1,95 +0,0 @@
/**
* @vitest-environment jsdom
*/
import { FontParseError } from '../../errors';
import { loadFont } from './loadFont';
describe('loadFont', () => {
let mockFontInstance: any;
let mockFontFaceSet: { add: ReturnType<typeof vi.fn>; delete: ReturnType<typeof vi.fn> };
beforeEach(() => {
mockFontFaceSet = { add: vi.fn(), delete: vi.fn() };
Object.defineProperty(document, 'fonts', { value: mockFontFaceSet, configurable: true, writable: true });
const MockFontFace = vi.fn(
function(this: any, name: string, buffer: BufferSource, options: FontFaceDescriptors) {
this.name = name;
this.buffer = buffer;
this.options = options;
this.load = vi.fn().mockResolvedValue(this);
mockFontInstance = this;
},
);
vi.stubGlobal('FontFace', MockFontFace);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('constructs FontFace with exact weight for static fonts', async () => {
const buffer = new ArrayBuffer(8);
await loadFont({ name: 'Roboto', weight: 400 }, buffer);
expect(FontFace).toHaveBeenCalledWith('Roboto', buffer, expect.objectContaining({ weight: '400' }));
});
it('constructs FontFace with weight range for variable fonts', async () => {
const buffer = new ArrayBuffer(8);
await loadFont({ name: 'Roboto', weight: 400, isVariable: true }, buffer);
expect(FontFace).toHaveBeenCalledWith('Roboto', buffer, expect.objectContaining({ weight: '100 900' }));
});
it('sets style: normal and display: swap on FontFace options', async () => {
await loadFont({ name: 'Lato', weight: 700 }, new ArrayBuffer(8));
expect(FontFace).toHaveBeenCalledWith(
'Lato',
expect.anything(),
expect.objectContaining({ style: 'normal', display: 'swap' }),
);
});
it('passes the buffer as the second argument to FontFace', async () => {
const buffer = new ArrayBuffer(16);
await loadFont({ name: 'Inter', weight: 400 }, buffer);
expect(FontFace).toHaveBeenCalledWith('Inter', buffer, expect.anything());
});
it('calls font.load() and adds the font to document.fonts', async () => {
const buffer = new ArrayBuffer(8);
const result = await loadFont({ name: 'Inter', weight: 400 }, buffer);
expect(mockFontInstance.load).toHaveBeenCalledOnce();
expect(mockFontFaceSet.add).toHaveBeenCalledWith(mockFontInstance);
expect(result).toBe(mockFontInstance);
});
it('throws FontParseError when font.load() rejects', async () => {
const loadError = new Error('parse failed');
const MockFontFace = vi.fn(
function(this: any, _name: string, _buffer: BufferSource, _options: FontFaceDescriptors) {
this.load = vi.fn().mockRejectedValue(loadError);
},
);
vi.stubGlobal('FontFace', MockFontFace);
await expect(loadFont({ name: 'Broken', weight: 400 }, new ArrayBuffer(8))).rejects.toBeInstanceOf(
FontParseError,
);
});
it('throws FontParseError when document.fonts.add throws', async () => {
const addError = new Error('add failed');
mockFontFaceSet.add.mockImplementation(() => {
throw addError;
});
await expect(loadFont({ name: 'Broken', weight: 400 }, new ArrayBuffer(8))).rejects.toBeInstanceOf(
FontParseError,
);
});
});
@@ -1,27 +0,0 @@
import type { FontLoadRequestConfig } from '../../../../types';
import { FontParseError } from '../../errors';
export type PartialConfig = Pick<FontLoadRequestConfig, 'weight' | 'name' | 'isVariable'>;
/**
* Loads a font from a buffer and adds it to the document's font collection.
* @param config - The font load request configuration.
* @param buffer - The buffer containing the font data.
* @returns A promise that resolves to the loaded `FontFace`.
* @throws {@link FontParseError} When the font buffer cannot be parsed or added to the document font set.
*/
export async function loadFont(config: PartialConfig, buffer: BufferSource): Promise<FontFace> {
try {
const weightRange = config.isVariable ? '100 900' : `${config.weight}`;
const font = new FontFace(config.name, buffer, {
weight: weightRange,
style: 'normal',
display: 'swap',
});
await font.load();
document.fonts.add(font);
return font;
} catch (error) {
throw new FontParseError(config.name, error);
}
}
@@ -1,17 +0,0 @@
import { yieldToMainThread } from './yieldToMainThread';
describe('yieldToMainThread', () => {
it('uses scheduler.yield when available', async () => {
const mockYield = vi.fn().mockResolvedValue(undefined);
vi.stubGlobal('scheduler', { yield: mockYield });
await yieldToMainThread();
expect(mockYield).toHaveBeenCalledOnce();
vi.unstubAllGlobals();
});
it('falls back to MessageChannel when scheduler is unavailable', async () => {
// scheduler is not defined in jsdom by default
await expect(yieldToMainThread()).resolves.toBeUndefined();
});
});

Some files were not shown because too many files have changed in this diff Show More