Compare commits
1 Commits
main
...
09c18ec654
| Author | SHA1 | Date | |
|---|---|---|---|
| 09c18ec654 |
562
.gitea/README.md
Normal file
562
.gitea/README.md
Normal 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
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
```
|
||||||
|
|
||||||
|
## 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
.gitea/workflows/build.yml
Normal file
32
.gitea/workflows/build.yml
Normal 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
.gitea/workflows/deploy.yml
Normal file
42
.gitea/workflows/deploy.yml
Normal 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
.gitea/workflows/lint.yml
Normal file
48
.gitea/workflows/lint.yml
Normal 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
.gitea/workflows/test.yml
Normal file
69
.gitea/workflows/test.yml
Normal 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.
|
||||||
@@ -1,60 +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:shadcn-excluded
|
|
||||||
|
|
||||||
publish:
|
|
||||||
needs: build # Only runs if tests/lint pass
|
|
||||||
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 .
|
|
||||||
docker push git.allmy.work/${{ gitea.repository }}:latest
|
|
||||||
15
.gitignore
vendored
15
.gitignore
vendored
@@ -10,9 +10,6 @@ node_modules
|
|||||||
/build
|
/build
|
||||||
/dist
|
/dist
|
||||||
|
|
||||||
# Git worktrees (isolated development branches)
|
|
||||||
.worktrees
|
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
@@ -25,8 +22,6 @@ Thumbs.db
|
|||||||
|
|
||||||
# Yarn
|
# Yarn
|
||||||
.yarn
|
.yarn
|
||||||
.yarn/**
|
|
||||||
.yarn/install-state.gz
|
|
||||||
.pnp.*
|
.pnp.*
|
||||||
|
|
||||||
# Zed
|
# Zed
|
||||||
@@ -37,13 +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*
|
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
<!--
|
|
||||||
Component: Decorator
|
|
||||||
Global Storybook decorator that wraps all stories with necessary providers.
|
|
||||||
|
|
||||||
This provides:
|
|
||||||
- ResponsiveManager context for breakpoint tracking
|
|
||||||
- TooltipProvider for shadcn Tooltip components
|
|
||||||
-->
|
|
||||||
<script lang="ts">
|
|
||||||
import { createResponsiveManager } from '$shared/lib';
|
|
||||||
import type { ResponsiveManager } from '$shared/lib';
|
|
||||||
import { Provider as TooltipProvider } from '$shared/shadcn/ui/tooltip';
|
|
||||||
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>
|
|
||||||
|
|
||||||
<TooltipProvider delayDuration={200} skipDelayDuration={300}>
|
|
||||||
{@render children()}
|
|
||||||
</TooltipProvider>
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
interface Props {
|
|
||||||
children: import('svelte').Snippet;
|
|
||||||
width?: string; // Optional width override
|
|
||||||
}
|
|
||||||
|
|
||||||
let { children, width = '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 {width}">
|
|
||||||
<div class="relative flex justify-center items-center text-foreground">
|
|
||||||
{@render children()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -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()}
|
|
||||||
@@ -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;
|
|
||||||
@@ -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>
|
|
||||||
@@ -1,207 +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 = {
|
|
||||||
globalTypes: {
|
|
||||||
viewport: {
|
|
||||||
description: 'Viewport size for responsive design',
|
|
||||||
defaultValue: 'widgetWide',
|
|
||||||
toolbar: {
|
|
||||||
icon: 'view',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
value: 'reset',
|
|
||||||
icon: 'refresh',
|
|
||||||
title: 'Reset viewport',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'mobile1',
|
|
||||||
icon: 'mobile',
|
|
||||||
title: 'iPhone 5/SE',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'mobile2',
|
|
||||||
icon: 'mobile',
|
|
||||||
title: 'iPhone 14 Pro Max',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'tablet',
|
|
||||||
icon: 'tablet',
|
|
||||||
title: 'iPad (Portrait)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'desktop',
|
|
||||||
icon: 'desktop',
|
|
||||||
title: 'Desktop (Small)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'widgetMedium',
|
|
||||||
icon: 'view',
|
|
||||||
title: 'Widget Medium',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'widgetWide',
|
|
||||||
icon: 'view',
|
|
||||||
title: 'Widget Wide',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'widgetExtraWide',
|
|
||||||
icon: 'view',
|
|
||||||
title: 'Widget Extra Wide',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'fullWidth',
|
|
||||||
icon: 'view',
|
|
||||||
title: 'Full Width',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'fullScreen',
|
|
||||||
icon: 'expand',
|
|
||||||
title: 'Full Screen',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
dynamicTitle: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
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 => ({
|
|
||||||
Component: StoryStage,
|
|
||||||
props: {
|
|
||||||
children: story(),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
export default preview;
|
|
||||||
@@ -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]);
|
|
||||||
BIN
.yarn/install-state.gz
Normal file
BIN
.yarn/install-state.gz
Normal file
Binary file not shown.
@@ -1,5 +0,0 @@
|
|||||||
:3000 {
|
|
||||||
root * /usr/share/caddy
|
|
||||||
file_server
|
|
||||||
try_files {path} /index.html
|
|
||||||
}
|
|
||||||
27
Dockerfile
27
Dockerfile
@@ -1,27 +0,0 @@
|
|||||||
# Build stage
|
|
||||||
FROM node:20-alpine AS builder
|
|
||||||
WORKDIR /app
|
|
||||||
# Enable Corepack so we can use Yarn v4
|
|
||||||
RUN corepack enable && corepack prepare yarn@stable --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
|
|
||||||
|
|
||||||
# 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"]
|
|
||||||
84
README.md
84
README.md
@@ -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 shadcn components and 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**: shadcn-svelte (via bits-ui)
|
|
||||||
- **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
|
|
||||||
|
|||||||
@@ -5,11 +5,11 @@
|
|||||||
"baseColor": "zinc"
|
"baseColor": "zinc"
|
||||||
},
|
},
|
||||||
"aliases": {
|
"aliases": {
|
||||||
"components": "$shared/shadcn/ui",
|
"components": "$lib/components",
|
||||||
"utils": "$shared/shadcn/utils/shadcn-utils",
|
"utils": "$lib/utils",
|
||||||
"ui": "$shared/shadcn/ui",
|
"ui": "$lib/components/ui",
|
||||||
"hooks": "$shared/shadcn/hooks",
|
"hooks": "$lib/hooks",
|
||||||
"lib": "$shared"
|
"lib": "$lib"
|
||||||
},
|
},
|
||||||
"typescript": true,
|
"typescript": true,
|
||||||
"registry": "https://shadcn-svelte.com/registry"
|
"registry": "https://shadcn-svelte.com/registry"
|
||||||
|
|||||||
23
dprint.json
23
dprint.json
@@ -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": [
|
||||||
@@ -16,22 +15,14 @@
|
|||||||
"https://plugins.dprint.dev/g-plane/markup_fmt-v0.25.3.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
|
|
||||||
},
|
},
|
||||||
"json": {
|
"json": {
|
||||||
"indentWidth": 2,
|
"indentWidth": 2,
|
||||||
@@ -41,15 +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
|
||||||
|
|
||||||
"vBindStyle": "short",
|
|
||||||
"vOnStyle": "short",
|
|
||||||
"formatComments": true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
6
e2e/demo.test.ts
Normal file
6
e2e/demo.test.ts
Normal 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();
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ pre-push:
|
|||||||
run: yarn tsc --noEmit
|
run: yarn tsc --noEmit
|
||||||
|
|
||||||
svelte-check:
|
svelte-check:
|
||||||
run: yarn check:shadcn-excluded --threshold warning
|
run: yarn svelte-check --threshold warning
|
||||||
|
|
||||||
format-check:
|
format-check:
|
||||||
glob: "*.{ts,js,svelte,json,md}"
|
glob: "*.{ts,js,svelte,json,md}"
|
||||||
|
|||||||
43
package.json
43
package.json
@@ -2,71 +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",
|
||||||
"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",
|
||||||
"check:shadcn-excluded": "svelte-check --no-tsconfig --ignore \"src/shared/shadcn\"",
|
|
||||||
"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": "^4.1.3",
|
"@lucide/svelte": "^0.562.0",
|
||||||
"@internationalized/date": "^3.10.0",
|
|
||||||
"@lucide/svelte": "^0.561.0",
|
|
||||||
"@playwright/test": "^1.57.0",
|
"@playwright/test": "^1.57.0",
|
||||||
"@storybook/addon-a11y": "^10.1.11",
|
|
||||||
"@storybook/addon-docs": "^10.1.11",
|
|
||||||
"@storybook/addon-svelte-csf": "^5.0.10",
|
|
||||||
"@storybook/addon-vitest": "^10.1.11",
|
|
||||||
"@storybook/svelte-vite": "^10.1.11",
|
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
|
||||||
"@testing-library/svelte": "^5.3.1",
|
|
||||||
"@tsconfig/svelte": "^5.0.6",
|
|
||||||
"@types/jsdom": "^27",
|
|
||||||
"@vitest/browser-playwright": "^4.0.16",
|
|
||||||
"@vitest/coverage-v8": "^4.0.16",
|
|
||||||
"bits-ui": "^2.14.4",
|
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dprint": "^0.50.2",
|
"dprint": "^0.50.2",
|
||||||
"jsdom": "^27.4.0",
|
|
||||||
"lefthook": "^2.0.13",
|
"lefthook": "^2.0.13",
|
||||||
"oxlint": "^1.35.0",
|
"oxlint": "^1.35.0",
|
||||||
"playwright": "^1.57.0",
|
|
||||||
"storybook": "^10.1.11",
|
|
||||||
"svelte": "^5.45.6",
|
"svelte": "^5.45.6",
|
||||||
"svelte-check": "^4.3.4",
|
"svelte-check": "^4.3.4",
|
||||||
"svelte-language-server": "^0.17.23",
|
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwind-variants": "^3.2.2",
|
"tailwind-variants": "^3.2.2",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^7.2.6",
|
"vite": "^7.2.6"
|
||||||
"vitest": "^4.0.16",
|
|
||||||
"vitest-browser-svelte": "^2.0.1"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@chenglou/pretext": "^0.0.5",
|
|
||||||
"@tanstack/svelte-query": "^6.0.14"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
import { defineConfig } from '@playwright/test';
|
import { defineConfig } from '@playwright/test';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
webServer: {
|
webServer: { command: 'yarn build && yarn preview', port: 4173 },
|
||||||
command: 'yarn build && yarn preview',
|
|
||||||
port: 4173,
|
|
||||||
reuseExistingServer: true,
|
|
||||||
},
|
|
||||||
testDir: 'e2e',
|
testDir: 'e2e',
|
||||||
});
|
});
|
||||||
|
|||||||
20
src/App.svelte
Normal file
20
src/App.svelte
Normal 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
src/ambient.d.ts
vendored
Normal file
20
src/ambient.d.ts
vendored
Normal 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
src/app.css
Normal file
121
src/app.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
<!--
|
|
||||||
Component: App
|
|
||||||
Application root with query provider and layout
|
|
||||||
-->
|
|
||||||
<script lang="ts">
|
|
||||||
/**
|
|
||||||
* App Component
|
|
||||||
*
|
|
||||||
* Application entry point component. Wraps the main page 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
|
|
||||||
* - Page renders the current route content
|
|
||||||
*/
|
|
||||||
import Page from '$routes/Page.svelte';
|
|
||||||
import { QueryProvider } from './providers';
|
|
||||||
import Layout from './ui/Layout.svelte';
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<QueryProvider>
|
|
||||||
<Layout>
|
|
||||||
<Page />
|
|
||||||
</Layout>
|
|
||||||
</QueryProvider>
|
|
||||||
@@ -1,25 +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 { queryClient } 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();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
{@render children?.()}
|
|
||||||
</QueryClientProvider>
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { default as QueryProvider } from './QueryProvider.svelte';
|
|
||||||
@@ -1,377 +0,0 @@
|
|||||||
@import "tailwindcss";
|
|
||||||
@import "tw-animate-css";
|
|
||||||
|
|
||||||
@variant dark (&:where(.dark, .dark *));
|
|
||||||
|
|
||||||
:root {
|
|
||||||
/* Base font size */
|
|
||||||
--font-size: 16px;
|
|
||||||
|
|
||||||
/* GLYPHDIFF Swiss Design System */
|
|
||||||
/* Primary Colors */
|
|
||||||
--swiss-beige: #f3f0e9;
|
|
||||||
--swiss-red: #ff3b30;
|
|
||||||
--swiss-black: #1a1a1a;
|
|
||||||
--swiss-white: #ffffff;
|
|
||||||
|
|
||||||
/* 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);
|
|
||||||
|
|
||||||
/* Spacing Scale (rem-based) */
|
|
||||||
--space-xs: 0.25rem;
|
|
||||||
--space-sm: 0.5rem;
|
|
||||||
--space-md: 0.75rem;
|
|
||||||
--space-lg: 1rem;
|
|
||||||
--space-xl: 1.5rem;
|
|
||||||
--space-2xl: 2rem;
|
|
||||||
--space-3xl: 3rem;
|
|
||||||
--space-4xl: 4rem;
|
|
||||||
|
|
||||||
/* Typography Scale */
|
|
||||||
--text-2xs: 0.625rem;
|
|
||||||
--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);
|
|
||||||
|
|
||||||
--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;
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer base {
|
|
||||||
* {
|
|
||||||
@apply border-border outline-ring/50;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
@apply bg-background text-foreground;
|
|
||||||
font-family: "Karla", system-ui, -apple-system, "Segoe UI", Inter, Roboto, Arial, sans-serif;
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Global utility - useful across your app */
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
* {
|
|
||||||
animation-duration: 0.01ms !important;
|
|
||||||
animation-iteration-count: 1 !important;
|
|
||||||
transition-duration: 0.01ms !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Performance optimization for collapsible elements */
|
|
||||||
[data-state="open"] {
|
|
||||||
will-change: height;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Smooth focus transitions - good globally */
|
|
||||||
.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 STYLES
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
/* ---- Modern API: color + width (Chrome 121+, FF 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 layer: runs ON TOP in Chrome, standalone in old Safari ---- */
|
|
||||||
/* Handles things scrollbar-width can't: hiding buttons, exact sizing */
|
|
||||||
@supports selector(::-webkit-scrollbar) {
|
|
||||||
::-webkit-scrollbar {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-button {
|
|
||||||
display: none; /* kills arrows */
|
|
||||||
}
|
|
||||||
|
|
||||||
::-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;
|
|
||||||
}
|
|
||||||
50
src/app/types/ambient.d.ts
vendored
50
src/app/types/ambient.d.ts
vendored
@@ -1,50 +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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <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;
|
|
||||||
}
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
<!--
|
|
||||||
Component: Layout
|
|
||||||
Application shell with providers and page wrapper
|
|
||||||
-->
|
|
||||||
<script lang="ts">
|
|
||||||
/**
|
|
||||||
* Layout Component
|
|
||||||
*
|
|
||||||
* Root layout wrapper that provides the application shell structure. Handles favicon,
|
|
||||||
* toolbar provider initialization, and renders child routes with consistent structure.
|
|
||||||
*
|
|
||||||
* Layout structure:
|
|
||||||
* - Header area (currently empty, reserved for future use)
|
|
||||||
*
|
|
||||||
* - Footer area (currently empty, reserved for future use)
|
|
||||||
*/
|
|
||||||
import { BreadcrumbHeader } from '$entities/Breadcrumb';
|
|
||||||
import { themeManager } from '$features/ChangeAppTheme';
|
|
||||||
import GD from '$shared/assets/GD.svg';
|
|
||||||
import { ResponsiveProvider } from '$shared/lib';
|
|
||||||
import { Provider as TooltipProvider } from '$shared/shadcn/ui/tooltip';
|
|
||||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
|
||||||
import {
|
|
||||||
type Snippet,
|
|
||||||
onDestroy,
|
|
||||||
onMount,
|
|
||||||
} from 'svelte';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
/**
|
|
||||||
* Content snippet
|
|
||||||
*/
|
|
||||||
children: Snippet;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { children }: Props = $props();
|
|
||||||
let fontsReady = $state(true);
|
|
||||||
const theme = $derived(themeManager.value);
|
|
||||||
|
|
||||||
onMount(() => themeManager.init());
|
|
||||||
onDestroy(() => themeManager.destroy());
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<link rel="icon" href={GD} />
|
|
||||||
|
|
||||||
<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="preload"
|
|
||||||
as="style"
|
|
||||||
href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Space+Grotesk:wght@300..700&family=Space+Mono:ital,wght@0,400;0,700;1,400;1,700&family=Syne:wght@800&display=swap"
|
|
||||||
/>
|
|
||||||
<link
|
|
||||||
rel="stylesheet"
|
|
||||||
href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Space+Grotesk:wght@300..700&family=Space+Mono:ital,wght@0,400;0,700;1,400;1,700&family=Syne:wght@800&display=swap"
|
|
||||||
media="print"
|
|
||||||
onload={(e => ((e.currentTarget as HTMLLinkElement).media = 'all'))}
|
|
||||||
/>
|
|
||||||
<noscript>
|
|
||||||
<link
|
|
||||||
rel="stylesheet"
|
|
||||||
href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Space+Grotesk:wght@300..700&family=Space+Mono:ital,wght@0,400;0,700;1,400;1,700&family=Syne:wght@800&display=swap"
|
|
||||||
/>
|
|
||||||
</noscript>
|
|
||||||
<title>GlyphDiff | Typography & Typefaces</title>
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<ResponsiveProvider>
|
|
||||||
<div
|
|
||||||
id="app-root"
|
|
||||||
class={cn(
|
|
||||||
'min-h-screen w-auto flex flex-col bg-surface dark:bg-dark-bg',
|
|
||||||
theme === 'dark' ? 'dark' : '',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<header>
|
|
||||||
<BreadcrumbHeader />
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- <ScrollArea class="h-screen w-screen"> -->
|
|
||||||
<!-- <main class="flex-1 w-full mx-auto relative"> -->
|
|
||||||
<TooltipProvider>
|
|
||||||
{#if fontsReady}
|
|
||||||
{@render children?.()}
|
|
||||||
{/if}
|
|
||||||
</TooltipProvider>
|
|
||||||
<!-- </main> -->
|
|
||||||
<!-- </ScrollArea> -->
|
|
||||||
<footer></footer>
|
|
||||||
</div>
|
|
||||||
</ResponsiveProvider>
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
/**
|
|
||||||
* Breadcrumb entity
|
|
||||||
*
|
|
||||||
* Tracks page sections using Intersection Observer with scroll direction
|
|
||||||
* detection. Sections appear in breadcrumbs when scrolling down and exiting
|
|
||||||
* the viewport top.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```svelte
|
|
||||||
* <script lang="ts">
|
|
||||||
* import { scrollBreadcrumbsStore } from '$entities/Breadcrumb';
|
|
||||||
* import { onMount } from 'svelte';
|
|
||||||
*
|
|
||||||
* onMount(() => {
|
|
||||||
* const section = document.getElementById('section');
|
|
||||||
* if (section) {
|
|
||||||
* scrollBreadcrumbsStore.add({
|
|
||||||
* index: 0,
|
|
||||||
* title: 'Section',
|
|
||||||
* element: section
|
|
||||||
* }, 80);
|
|
||||||
* }
|
|
||||||
* });
|
|
||||||
* </script>
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
|
|
||||||
export {
|
|
||||||
type NavigationAction,
|
|
||||||
scrollBreadcrumbsStore,
|
|
||||||
} from './model';
|
|
||||||
export {
|
|
||||||
BreadcrumbHeader,
|
|
||||||
NavigationWrapper,
|
|
||||||
} from './ui';
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export * from './store/scrollBreadcrumbsStore.svelte';
|
|
||||||
export * from './types/types.ts';
|
|
||||||
@@ -1,240 +0,0 @@
|
|||||||
/**
|
|
||||||
* Scroll-based breadcrumb tracking store
|
|
||||||
*
|
|
||||||
* Tracks page sections using Intersection Observer with scroll direction
|
|
||||||
* detection. Sections appear in breadcrumbs when scrolling DOWN and exiting
|
|
||||||
* the viewport top. This creates a natural "breadcrumb trail" as users
|
|
||||||
* scroll through content.
|
|
||||||
*
|
|
||||||
* Features:
|
|
||||||
* - Scroll direction detection (up/down)
|
|
||||||
* - Intersection Observer for efficient tracking
|
|
||||||
* - Smooth scrolling to tracked sections
|
|
||||||
* - Configurable scroll offset for sticky headers
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```svelte
|
|
||||||
* <script lang="ts">
|
|
||||||
* import { scrollBreadcrumbsStore } from '$entities/Breadcrumb';
|
|
||||||
*
|
|
||||||
* onMount(() => {
|
|
||||||
* scrollBreadcrumbsStore.add({
|
|
||||||
* index: 0,
|
|
||||||
* title: 'Introduction',
|
|
||||||
* element: document.getElementById('intro')!
|
|
||||||
* }, 80); // 80px offset for sticky header
|
|
||||||
* });
|
|
||||||
* </script>
|
|
||||||
*
|
|
||||||
* <div id="intro">Introduction</div>
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A breadcrumb item representing a tracked section
|
|
||||||
*/
|
|
||||||
export interface BreadcrumbItem {
|
|
||||||
/** Unique index for ordering */
|
|
||||||
index: number;
|
|
||||||
/** Display title for the breadcrumb */
|
|
||||||
title: string;
|
|
||||||
/** DOM element to track */
|
|
||||||
element: HTMLElement;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Scroll-based breadcrumb tracking store
|
|
||||||
*
|
|
||||||
* Uses Intersection Observer to detect when sections scroll out of view
|
|
||||||
* and tracks scroll direction to only show sections the user has scrolled
|
|
||||||
* past while moving down the page.
|
|
||||||
*/
|
|
||||||
class ScrollBreadcrumbsStore {
|
|
||||||
/** All tracked breadcrumb items */
|
|
||||||
#items = $state<BreadcrumbItem[]>([]);
|
|
||||||
/** Set of indices that have scrolled past (exited viewport while scrolling down) */
|
|
||||||
#scrolledPast = $state<Set<number>>(new Set());
|
|
||||||
/** Intersection Observer instance */
|
|
||||||
#observer: IntersectionObserver | null = null;
|
|
||||||
/** Offset for smooth scrolling (sticky header height) */
|
|
||||||
#scrollOffset = 0;
|
|
||||||
/** Current scroll direction */
|
|
||||||
#isScrollingDown = $state(false);
|
|
||||||
/** Previous scroll Y position to determine direction */
|
|
||||||
#prevScrollY = 0;
|
|
||||||
/** Throttled scroll handler */
|
|
||||||
#handleScroll: (() => void) | null = null;
|
|
||||||
/** Listener count for cleanup */
|
|
||||||
#listenerCount = 0;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates scroll direction based on current position
|
|
||||||
*/
|
|
||||||
#updateScrollDirection(): void {
|
|
||||||
const currentScrollY = window.scrollY;
|
|
||||||
this.#isScrollingDown = currentScrollY > this.#prevScrollY;
|
|
||||||
this.#prevScrollY = currentScrollY;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes the Intersection Observer
|
|
||||||
*
|
|
||||||
* Tracks when elements enter/exit viewport with zero threshold
|
|
||||||
* (fires as soon as any part of element crosses viewport edge).
|
|
||||||
*/
|
|
||||||
#initObserver(): void {
|
|
||||||
if (this.#observer) return;
|
|
||||||
|
|
||||||
this.#observer = new IntersectionObserver(
|
|
||||||
entries => {
|
|
||||||
for (const entry of entries) {
|
|
||||||
const item = this.#items.find(i => i.element === entry.target);
|
|
||||||
if (!item) continue;
|
|
||||||
|
|
||||||
if (!entry.isIntersecting && this.#isScrollingDown) {
|
|
||||||
// Element exited viewport while scrolling DOWN - add to breadcrumbs
|
|
||||||
this.#scrolledPast = new Set(this.#scrolledPast).add(item.index);
|
|
||||||
} else if (entry.isIntersecting && !this.#isScrollingDown) {
|
|
||||||
// Element entered viewport while scrolling UP - remove from breadcrumbs
|
|
||||||
const newSet = new Set(this.#scrolledPast);
|
|
||||||
newSet.delete(item.index);
|
|
||||||
this.#scrolledPast = newSet;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
threshold: 0,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Attaches scroll listener for direction detection
|
|
||||||
*/
|
|
||||||
#attachScrollListener(): void {
|
|
||||||
if (this.#listenerCount === 0) {
|
|
||||||
this.#handleScroll = () => this.#updateScrollDirection();
|
|
||||||
window.addEventListener('scroll', this.#handleScroll, { passive: true });
|
|
||||||
}
|
|
||||||
this.#listenerCount++;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detaches scroll listener when no items remain
|
|
||||||
*/
|
|
||||||
#detachScrollListener(): void {
|
|
||||||
this.#listenerCount = Math.max(0, this.#listenerCount - 1);
|
|
||||||
if (this.#listenerCount === 0 && this.#handleScroll) {
|
|
||||||
window.removeEventListener('scroll', this.#handleScroll);
|
|
||||||
this.#handleScroll = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Disconnects observer and removes scroll listener
|
|
||||||
*/
|
|
||||||
#disconnect(): void {
|
|
||||||
if (this.#observer) {
|
|
||||||
this.#observer.disconnect();
|
|
||||||
this.#observer = null;
|
|
||||||
}
|
|
||||||
this.#detachScrollListener();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** All tracked items sorted by index */
|
|
||||||
get items(): BreadcrumbItem[] {
|
|
||||||
return this.#items.slice().sort((a, b) => a.index - b.index);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Items that have scrolled past viewport top (visible in breadcrumbs) */
|
|
||||||
get scrolledPastItems(): BreadcrumbItem[] {
|
|
||||||
return this.items.filter(item => this.#scrolledPast.has(item.index));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Index of the most recently scrolled item (active section) */
|
|
||||||
get activeIndex(): number | null {
|
|
||||||
const past = this.scrolledPastItems;
|
|
||||||
return past.length > 0 ? past[past.length - 1].index : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a specific index has been scrolled past
|
|
||||||
* @param index - Item index to check
|
|
||||||
*/
|
|
||||||
isScrolledPast(index: number): boolean {
|
|
||||||
return this.#scrolledPast.has(index);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add a breadcrumb item to track
|
|
||||||
* @param item - Breadcrumb item with index, title, and element
|
|
||||||
* @param offset - Scroll offset in pixels (for sticky headers)
|
|
||||||
*/
|
|
||||||
add(item: BreadcrumbItem, offset = 0): void {
|
|
||||||
if (this.#items.find(i => i.index === item.index)) return;
|
|
||||||
|
|
||||||
this.#scrollOffset = offset;
|
|
||||||
this.#items.push(item);
|
|
||||||
this.#attachScrollListener();
|
|
||||||
this.#initObserver();
|
|
||||||
// Initialize scroll direction
|
|
||||||
this.#prevScrollY = window.scrollY;
|
|
||||||
this.#observer?.observe(item.element);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove a breadcrumb item from tracking
|
|
||||||
* @param index - Index of item to remove
|
|
||||||
*/
|
|
||||||
remove(index: number): void {
|
|
||||||
const item = this.#items.find(i => i.index === index);
|
|
||||||
if (!item) return;
|
|
||||||
|
|
||||||
this.#observer?.unobserve(item.element);
|
|
||||||
this.#items = this.#items.filter(i => i.index !== index);
|
|
||||||
|
|
||||||
const newSet = new Set(this.#scrolledPast);
|
|
||||||
newSet.delete(index);
|
|
||||||
this.#scrolledPast = newSet;
|
|
||||||
|
|
||||||
if (this.#items.length === 0) {
|
|
||||||
this.#disconnect();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Smooth scroll to a tracked breadcrumb item
|
|
||||||
* @param index - Index of item to scroll to
|
|
||||||
* @param container - Scroll container (window by default)
|
|
||||||
*/
|
|
||||||
scrollTo(index: number, container: HTMLElement | Window = window): void {
|
|
||||||
const item = this.#items.find(i => i.index === index);
|
|
||||||
if (!item) return;
|
|
||||||
|
|
||||||
const rect = item.element.getBoundingClientRect();
|
|
||||||
const scrollTop = container === window ? window.scrollY : (container as HTMLElement).scrollTop;
|
|
||||||
const target = rect.top + scrollTop - this.#scrollOffset;
|
|
||||||
|
|
||||||
if (container === window) {
|
|
||||||
window.scrollTo({ top: target, behavior: 'smooth' });
|
|
||||||
} else {
|
|
||||||
(container as HTMLElement).scrollTo({
|
|
||||||
top: target - (container as HTMLElement).getBoundingClientRect().top
|
|
||||||
+ (container as HTMLElement).scrollTop - window.scrollY,
|
|
||||||
behavior: 'smooth',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a new scroll breadcrumbs store instance
|
|
||||||
*/
|
|
||||||
export function createScrollBreadcrumbsStore(): ScrollBreadcrumbsStore {
|
|
||||||
return new ScrollBreadcrumbsStore();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Singleton scroll breadcrumbs store instance
|
|
||||||
*/
|
|
||||||
export const scrollBreadcrumbsStore = createScrollBreadcrumbsStore();
|
|
||||||
@@ -1,559 +0,0 @@
|
|||||||
/** @vitest-environment jsdom */
|
|
||||||
import {
|
|
||||||
afterEach,
|
|
||||||
beforeEach,
|
|
||||||
describe,
|
|
||||||
expect,
|
|
||||||
it,
|
|
||||||
vi,
|
|
||||||
} from 'vitest';
|
|
||||||
import {
|
|
||||||
type BreadcrumbItem,
|
|
||||||
createScrollBreadcrumbsStore,
|
|
||||||
} from './scrollBreadcrumbsStore.svelte';
|
|
||||||
|
|
||||||
// Mock IntersectionObserver - class variable to track instances
|
|
||||||
let mockObserverInstances: MockIntersectionObserver[] = [];
|
|
||||||
|
|
||||||
class MockIntersectionObserver implements IntersectionObserver {
|
|
||||||
root = null;
|
|
||||||
rootMargin = '';
|
|
||||||
thresholds: number[] = [];
|
|
||||||
readonly callbacks: Array<(entries: IntersectionObserverEntry[], observer: IntersectionObserver) => void> = [];
|
|
||||||
readonly observedElements = new Set<Element>();
|
|
||||||
|
|
||||||
constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) {
|
|
||||||
this.callbacks.push(callback);
|
|
||||||
if (options?.rootMargin) this.rootMargin = options.rootMargin;
|
|
||||||
if (options?.threshold) {
|
|
||||||
this.thresholds = Array.isArray(options.threshold) ? options.threshold : [options.threshold];
|
|
||||||
}
|
|
||||||
mockObserverInstances.push(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
observe(target: Element): void {
|
|
||||||
this.observedElements.add(target);
|
|
||||||
}
|
|
||||||
|
|
||||||
unobserve(target: Element): void {
|
|
||||||
this.observedElements.delete(target);
|
|
||||||
}
|
|
||||||
|
|
||||||
disconnect(): void {
|
|
||||||
this.observedElements.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
takeRecords(): IntersectionObserverEntry[] {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper method for tests to trigger intersection changes
|
|
||||||
triggerIntersection(target: Element, isIntersecting: boolean): void {
|
|
||||||
const entry: Partial<IntersectionObserverEntry> = {
|
|
||||||
target,
|
|
||||||
isIntersecting,
|
|
||||||
intersectionRatio: isIntersecting ? 1 : 0,
|
|
||||||
boundingClientRect: {} as DOMRectReadOnly,
|
|
||||||
intersectionRect: {} as DOMRectReadOnly,
|
|
||||||
rootBounds: null,
|
|
||||||
time: Date.now(),
|
|
||||||
};
|
|
||||||
this.callbacks.forEach(cb => cb([entry as IntersectionObserverEntry], this));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('ScrollBreadcrumbsStore', () => {
|
|
||||||
let scrollListeners: Array<() => void> = [];
|
|
||||||
let addEventListenerSpy: ReturnType<typeof vi.spyOn>;
|
|
||||||
let removeEventListenerSpy: ReturnType<typeof vi.spyOn>;
|
|
||||||
let scrollToSpy: ReturnType<typeof vi.spyOn>;
|
|
||||||
|
|
||||||
// Helper to create mock elements
|
|
||||||
const createMockElement = (): HTMLElement => {
|
|
||||||
const el = document.createElement('div');
|
|
||||||
Object.defineProperty(el, 'getBoundingClientRect', {
|
|
||||||
value: vi.fn(() => ({
|
|
||||||
top: 100,
|
|
||||||
left: 0,
|
|
||||||
bottom: 200,
|
|
||||||
right: 100,
|
|
||||||
width: 100,
|
|
||||||
height: 100,
|
|
||||||
x: 0,
|
|
||||||
y: 100,
|
|
||||||
toJSON: () => ({}),
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
return el;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper to create breadcrumb item
|
|
||||||
const createItem = (index: number, title: string, element?: HTMLElement): BreadcrumbItem => ({
|
|
||||||
index,
|
|
||||||
title,
|
|
||||||
element: element ?? createMockElement(),
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
mockObserverInstances = [];
|
|
||||||
scrollListeners = [];
|
|
||||||
|
|
||||||
// Set up IntersectionObserver mock before creating store
|
|
||||||
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver);
|
|
||||||
|
|
||||||
// Mock window.scrollTo
|
|
||||||
scrollToSpy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
|
|
||||||
|
|
||||||
// Track scroll event listeners
|
|
||||||
addEventListenerSpy = vi.spyOn(window, 'addEventListener').mockImplementation(
|
|
||||||
(event: string, listener: EventListenerOrEventListenerObject, options?: any) => {
|
|
||||||
if (event === 'scroll') {
|
|
||||||
scrollListeners.push(listener as () => void);
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
removeEventListenerSpy = vi.spyOn(window, 'removeEventListener').mockImplementation(
|
|
||||||
(event: string, listener: EventListenerOrEventListenerObject) => {
|
|
||||||
if (event === 'scroll') {
|
|
||||||
const index = scrollListeners.indexOf(listener as () => void);
|
|
||||||
if (index > -1) scrollListeners.splice(index, 1);
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Adding items', () => {
|
|
||||||
it('should add an item and track it', () => {
|
|
||||||
const store = createScrollBreadcrumbsStore();
|
|
||||||
const element = createMockElement();
|
|
||||||
const item = createItem(0, 'Section 1', element);
|
|
||||||
|
|
||||||
store.add(item);
|
|
||||||
|
|
||||||
expect(store.items).toHaveLength(1);
|
|
||||||
expect(store.items[0]).toEqual(item);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should ignore duplicate indices', () => {
|
|
||||||
const store = createScrollBreadcrumbsStore();
|
|
||||||
const element1 = createMockElement();
|
|
||||||
const element2 = createMockElement();
|
|
||||||
|
|
||||||
store.add(createItem(0, 'First', element1));
|
|
||||||
store.add(createItem(0, 'Second', element2));
|
|
||||||
|
|
||||||
expect(store.items).toHaveLength(1);
|
|
||||||
expect(store.items[0].title).toBe('First');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should add multiple items with different indices', () => {
|
|
||||||
const store = createScrollBreadcrumbsStore();
|
|
||||||
store.add(createItem(0, 'First'));
|
|
||||||
store.add(createItem(1, 'Second'));
|
|
||||||
store.add(createItem(2, 'Third'));
|
|
||||||
|
|
||||||
expect(store.items).toHaveLength(3);
|
|
||||||
expect(store.items.map(i => i.index)).toEqual([0, 1, 2]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should attach scroll listener when first item is added', () => {
|
|
||||||
const store = createScrollBreadcrumbsStore();
|
|
||||||
expect(scrollListeners).toHaveLength(0);
|
|
||||||
|
|
||||||
store.add(createItem(0, 'First'));
|
|
||||||
|
|
||||||
expect(scrollListeners).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should initialize observer with element', () => {
|
|
||||||
const store = createScrollBreadcrumbsStore();
|
|
||||||
const element = createMockElement();
|
|
||||||
store.add(createItem(0, 'Test', element));
|
|
||||||
|
|
||||||
expect(mockObserverInstances).toHaveLength(1);
|
|
||||||
expect(mockObserverInstances[0].observedElements.has(element)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Removing items', () => {
|
|
||||||
it('should remove an item by index', () => {
|
|
||||||
const store = createScrollBreadcrumbsStore();
|
|
||||||
store.add(createItem(0, 'First'));
|
|
||||||
store.add(createItem(1, 'Second'));
|
|
||||||
store.add(createItem(2, 'Third'));
|
|
||||||
|
|
||||||
store.remove(1);
|
|
||||||
|
|
||||||
expect(store.items).toHaveLength(2);
|
|
||||||
expect(store.items.map(i => i.index)).toEqual([0, 2]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should do nothing when removing non-existent index', () => {
|
|
||||||
const store = createScrollBreadcrumbsStore();
|
|
||||||
store.add(createItem(0, 'First'));
|
|
||||||
store.add(createItem(1, 'Second'));
|
|
||||||
|
|
||||||
store.remove(999);
|
|
||||||
|
|
||||||
expect(store.items).toHaveLength(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should unobserve element when removed', () => {
|
|
||||||
const store = createScrollBreadcrumbsStore();
|
|
||||||
const element = createMockElement();
|
|
||||||
store.add(createItem(0, 'First', element));
|
|
||||||
|
|
||||||
expect(mockObserverInstances[0].observedElements.has(element)).toBe(true);
|
|
||||||
|
|
||||||
store.remove(0);
|
|
||||||
|
|
||||||
expect(mockObserverInstances[0].observedElements.has(element)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should disconnect observer when no items remain', () => {
|
|
||||||
const store = createScrollBreadcrumbsStore();
|
|
||||||
store.add(createItem(0, 'First'));
|
|
||||||
store.add(createItem(1, 'Second'));
|
|
||||||
|
|
||||||
expect(addEventListenerSpy).toHaveBeenCalled();
|
|
||||||
const initialCallCount = addEventListenerSpy.mock.calls.length;
|
|
||||||
|
|
||||||
store.remove(0);
|
|
||||||
// addEventListener was called for the first item, still 1 call
|
|
||||||
expect(addEventListenerSpy.mock.calls.length).toBe(initialCallCount);
|
|
||||||
|
|
||||||
store.remove(1);
|
|
||||||
// The listener count should be 0 now - disconnect was called
|
|
||||||
// We verify the observer was disconnected
|
|
||||||
expect(mockObserverInstances[0].observedElements.size).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reattach listener when adding after cleanup', () => {
|
|
||||||
const store = createScrollBreadcrumbsStore();
|
|
||||||
store.add(createItem(0, 'First'));
|
|
||||||
store.remove(0);
|
|
||||||
|
|
||||||
expect(scrollListeners).toHaveLength(0);
|
|
||||||
|
|
||||||
store.add(createItem(1, 'Second'));
|
|
||||||
expect(scrollListeners).toHaveLength(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Intersection Observer behavior', () => {
|
|
||||||
it('should add to scrolledPast when element exits viewport while scrolling down', () => {
|
|
||||||
// Set initial scrollY before creating store/adding items
|
|
||||||
Object.defineProperty(window, 'scrollY', { value: 0, writable: true, configurable: true });
|
|
||||||
|
|
||||||
const store = createScrollBreadcrumbsStore();
|
|
||||||
const element = createMockElement();
|
|
||||||
store.add(createItem(0, 'First', element));
|
|
||||||
|
|
||||||
// Simulate scrolling down (scrollY increases)
|
|
||||||
Object.defineProperty(window, 'scrollY', { value: 100, writable: true, configurable: true });
|
|
||||||
scrollListeners.forEach(l => l());
|
|
||||||
|
|
||||||
// Trigger intersection: element exits viewport while scrolling down
|
|
||||||
mockObserverInstances[0].triggerIntersection(element, false);
|
|
||||||
|
|
||||||
expect(store.isScrolledPast(0)).toBe(true);
|
|
||||||
expect(store.scrolledPastItems).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not add to scrolledPast when not scrolling down', () => {
|
|
||||||
const store = createScrollBreadcrumbsStore();
|
|
||||||
const element = createMockElement();
|
|
||||||
store.add(createItem(0, 'First', element));
|
|
||||||
|
|
||||||
// scrollY stays at 0 (not scrolling down)
|
|
||||||
Object.defineProperty(window, 'scrollY', { value: 0, writable: true, configurable: true });
|
|
||||||
scrollListeners.forEach(l => l());
|
|
||||||
|
|
||||||
// Element exits viewport
|
|
||||||
mockObserverInstances[0].triggerIntersection(element, false);
|
|
||||||
|
|
||||||
expect(store.isScrolledPast(0)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should remove from scrolledPast when element enters viewport while scrolling up', () => {
|
|
||||||
const store = createScrollBreadcrumbsStore();
|
|
||||||
const element = createMockElement();
|
|
||||||
store.add(createItem(0, 'First', element));
|
|
||||||
|
|
||||||
// First, scroll down and exit viewport
|
|
||||||
Object.defineProperty(window, 'scrollY', { value: 100, writable: true, configurable: true });
|
|
||||||
scrollListeners.forEach(l => l());
|
|
||||||
mockObserverInstances[0].triggerIntersection(element, false);
|
|
||||||
|
|
||||||
expect(store.isScrolledPast(0)).toBe(true);
|
|
||||||
|
|
||||||
// Now scroll up (decrease scrollY) and element enters viewport
|
|
||||||
Object.defineProperty(window, 'scrollY', { value: 50, writable: true, configurable: true });
|
|
||||||
scrollListeners.forEach(l => l());
|
|
||||||
mockObserverInstances[0].triggerIntersection(element, true);
|
|
||||||
|
|
||||||
expect(store.isScrolledPast(0)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('scrollTo method', () => {
|
|
||||||
it('should scroll to item by index with window as container', () => {
|
|
||||||
const store = createScrollBreadcrumbsStore();
|
|
||||||
const element = createMockElement();
|
|
||||||
store.add(createItem(0, 'First', element));
|
|
||||||
|
|
||||||
store.scrollTo(0, window);
|
|
||||||
|
|
||||||
expect(scrollToSpy).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
behavior: 'smooth',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should do nothing when index does not exist', () => {
|
|
||||||
const store = createScrollBreadcrumbsStore();
|
|
||||||
store.add(createItem(0, 'First'));
|
|
||||||
|
|
||||||
store.scrollTo(999);
|
|
||||||
|
|
||||||
expect(scrollToSpy).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should use scroll offset when calculating target position', () => {
|
|
||||||
const store = createScrollBreadcrumbsStore();
|
|
||||||
|
|
||||||
// Reset the mock to clear previous calls
|
|
||||||
scrollToSpy.mockClear();
|
|
||||||
|
|
||||||
// Create fresh mock element with specific getBoundingClientRect
|
|
||||||
const element = document.createElement('div');
|
|
||||||
const getBoundingClientRectMock = vi.fn(() => ({
|
|
||||||
top: 200,
|
|
||||||
left: 0,
|
|
||||||
bottom: 300,
|
|
||||||
right: 100,
|
|
||||||
width: 100,
|
|
||||||
height: 100,
|
|
||||||
x: 0,
|
|
||||||
y: 200,
|
|
||||||
toJSON: () => ({}),
|
|
||||||
}));
|
|
||||||
Object.defineProperty(element, 'getBoundingClientRect', {
|
|
||||||
value: getBoundingClientRectMock,
|
|
||||||
writable: true,
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add item with 80px offset
|
|
||||||
store.add(createItem(0, 'Third', element), 80);
|
|
||||||
|
|
||||||
store.scrollTo(0);
|
|
||||||
|
|
||||||
// The offset should be subtracted from the element position
|
|
||||||
// 200 - 80 = 120 (but in jsdom, getBoundingClientRect might have different behavior)
|
|
||||||
// Let's just verify smooth behavior is used
|
|
||||||
expect(scrollToSpy).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
behavior: 'smooth',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Verify that the scroll position is less than the element top (offset was applied)
|
|
||||||
const scrollToCall = scrollToSpy.mock.calls[0][0] as ScrollToOptions;
|
|
||||||
expect((scrollToCall as any).top).toBeLessThan(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle HTMLElement container', () => {
|
|
||||||
const store = createScrollBreadcrumbsStore();
|
|
||||||
const element = createMockElement();
|
|
||||||
store.add(createItem(0, 'Test', element));
|
|
||||||
|
|
||||||
const container: HTMLElement = {
|
|
||||||
scrollTop: 50,
|
|
||||||
scrollTo: vi.fn(),
|
|
||||||
getBoundingClientRect: () => ({
|
|
||||||
top: 0,
|
|
||||||
bottom: 500,
|
|
||||||
left: 0,
|
|
||||||
right: 400,
|
|
||||||
width: 400,
|
|
||||||
height: 500,
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
toJSON: () => ({}),
|
|
||||||
}),
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
store.scrollTo(0, container);
|
|
||||||
|
|
||||||
expect(container.scrollTo).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
behavior: 'smooth',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Getters', () => {
|
|
||||||
it('should return items sorted by index', () => {
|
|
||||||
const store = createScrollBreadcrumbsStore();
|
|
||||||
store.add(createItem(1, 'Second'));
|
|
||||||
store.add(createItem(0, 'First'));
|
|
||||||
store.add(createItem(2, 'Third'));
|
|
||||||
|
|
||||||
expect(store.items.map(i => i.index)).toEqual([0, 1, 2]);
|
|
||||||
expect(store.items.map(i => i.title)).toEqual(['First', 'Second', 'Third']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return empty scrolledPastItems initially', () => {
|
|
||||||
const store = createScrollBreadcrumbsStore();
|
|
||||||
store.add(createItem(0, 'First'));
|
|
||||||
store.add(createItem(1, 'Second'));
|
|
||||||
|
|
||||||
expect(store.scrolledPastItems).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return items that have been scrolled past', () => {
|
|
||||||
const store = createScrollBreadcrumbsStore();
|
|
||||||
const element = createMockElement();
|
|
||||||
store.add(createItem(0, 'First', element));
|
|
||||||
store.add(createItem(1, 'Second'));
|
|
||||||
|
|
||||||
// Simulate scrolling down and element exiting viewport
|
|
||||||
Object.defineProperty(window, 'scrollY', { value: 100, writable: true, configurable: true });
|
|
||||||
scrollListeners.forEach(l => l());
|
|
||||||
mockObserverInstances[0].triggerIntersection(element, false);
|
|
||||||
|
|
||||||
expect(store.scrolledPastItems).toHaveLength(1);
|
|
||||||
expect(store.scrolledPastItems[0].index).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('activeIndex getter', () => {
|
|
||||||
it('should return null when no items are scrolled past', () => {
|
|
||||||
const store = createScrollBreadcrumbsStore();
|
|
||||||
store.add(createItem(0, 'First'));
|
|
||||||
store.add(createItem(1, 'Second'));
|
|
||||||
|
|
||||||
expect(store.activeIndex).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return the last scrolled item index', () => {
|
|
||||||
// Set initial scroll position
|
|
||||||
Object.defineProperty(window, 'scrollY', { value: 0, writable: true, configurable: true });
|
|
||||||
|
|
||||||
const store = createScrollBreadcrumbsStore();
|
|
||||||
const element0 = createMockElement();
|
|
||||||
const element1 = createMockElement();
|
|
||||||
store.add(createItem(0, 'First', element0));
|
|
||||||
store.add(createItem(1, 'Second', element1));
|
|
||||||
|
|
||||||
// Scroll down, first item exits
|
|
||||||
Object.defineProperty(window, 'scrollY', { value: 100, writable: true, configurable: true });
|
|
||||||
scrollListeners.forEach(l => l());
|
|
||||||
mockObserverInstances[0].triggerIntersection(element0, false);
|
|
||||||
|
|
||||||
expect(store.activeIndex).toBe(0);
|
|
||||||
|
|
||||||
// Second item exits
|
|
||||||
mockObserverInstances[0].triggerIntersection(element1, false);
|
|
||||||
|
|
||||||
expect(store.activeIndex).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should update active index when scrolling back up', () => {
|
|
||||||
const store = createScrollBreadcrumbsStore();
|
|
||||||
const element0 = createMockElement();
|
|
||||||
const element1 = createMockElement();
|
|
||||||
store.add(createItem(0, 'First', element0));
|
|
||||||
store.add(createItem(1, 'Second', element1));
|
|
||||||
|
|
||||||
// Scroll past both items
|
|
||||||
Object.defineProperty(window, 'scrollY', { value: 200, writable: true, configurable: true });
|
|
||||||
scrollListeners.forEach(l => l());
|
|
||||||
mockObserverInstances[0].triggerIntersection(element0, false);
|
|
||||||
mockObserverInstances[0].triggerIntersection(element1, false);
|
|
||||||
|
|
||||||
expect(store.activeIndex).toBe(1);
|
|
||||||
|
|
||||||
// Scroll back up, item 1 enters viewport
|
|
||||||
Object.defineProperty(window, 'scrollY', { value: 100, writable: true, configurable: true });
|
|
||||||
scrollListeners.forEach(l => l());
|
|
||||||
mockObserverInstances[0].triggerIntersection(element1, true);
|
|
||||||
|
|
||||||
expect(store.activeIndex).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('isScrolledPast', () => {
|
|
||||||
it('should return false for items not scrolled past', () => {
|
|
||||||
const store = createScrollBreadcrumbsStore();
|
|
||||||
store.add(createItem(0, 'First'));
|
|
||||||
store.add(createItem(1, 'Second'));
|
|
||||||
|
|
||||||
expect(store.isScrolledPast(0)).toBe(false);
|
|
||||||
expect(store.isScrolledPast(1)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return true for scrolled items', () => {
|
|
||||||
// Set initial scroll position
|
|
||||||
Object.defineProperty(window, 'scrollY', { value: 0, writable: true, configurable: true });
|
|
||||||
|
|
||||||
const store = createScrollBreadcrumbsStore();
|
|
||||||
const element = createMockElement();
|
|
||||||
store.add(createItem(0, 'First', element));
|
|
||||||
store.add(createItem(1, 'Second'));
|
|
||||||
|
|
||||||
// Scroll down, first item exits viewport
|
|
||||||
Object.defineProperty(window, 'scrollY', { value: 100, writable: true, configurable: true });
|
|
||||||
scrollListeners.forEach(l => l());
|
|
||||||
mockObserverInstances[0].triggerIntersection(element, false);
|
|
||||||
|
|
||||||
expect(store.isScrolledPast(0)).toBe(true);
|
|
||||||
expect(store.isScrolledPast(1)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false for non-existent indices', () => {
|
|
||||||
const store = createScrollBreadcrumbsStore();
|
|
||||||
store.add(createItem(0, 'First'));
|
|
||||||
|
|
||||||
expect(store.isScrolledPast(999)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Scroll direction tracking', () => {
|
|
||||||
it('should track scroll direction changes', () => {
|
|
||||||
const store = createScrollBreadcrumbsStore();
|
|
||||||
const element = createMockElement();
|
|
||||||
store.add(createItem(0, 'First', element));
|
|
||||||
|
|
||||||
// Initial scroll position
|
|
||||||
Object.defineProperty(window, 'scrollY', { value: 0, writable: true, configurable: true });
|
|
||||||
scrollListeners.forEach(l => l());
|
|
||||||
|
|
||||||
// Scroll down
|
|
||||||
Object.defineProperty(window, 'scrollY', { value: 100, writable: true, configurable: true });
|
|
||||||
scrollListeners.forEach(l => l());
|
|
||||||
mockObserverInstances[0].triggerIntersection(element, false);
|
|
||||||
|
|
||||||
// Should be in scrolledPast since we scrolled down
|
|
||||||
expect(store.isScrolledPast(0)).toBe(true);
|
|
||||||
|
|
||||||
// Scroll back up
|
|
||||||
Object.defineProperty(window, 'scrollY', { value: 50, writable: true, configurable: true });
|
|
||||||
scrollListeners.forEach(l => l());
|
|
||||||
mockObserverInstances[0].triggerIntersection(element, true);
|
|
||||||
|
|
||||||
// Should be removed since we scrolled up
|
|
||||||
expect(store.isScrolledPast(0)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
/**
|
|
||||||
* Navigation action type for breadcrumb components
|
|
||||||
*
|
|
||||||
* A Svelte action that can be attached to navigation elements
|
|
||||||
* for scroll tracking or other behaviors.
|
|
||||||
*/
|
|
||||||
export type NavigationAction = (node: HTMLElement) => void;
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
<!--
|
|
||||||
Component: BreadcrumbHeader
|
|
||||||
Fixed header for breadcrumbs navigation for sections in the page
|
|
||||||
-->
|
|
||||||
<script lang="ts">
|
|
||||||
import type { ResponsiveManager } from '$shared/lib';
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Label,
|
|
||||||
Logo,
|
|
||||||
} from '$shared/ui';
|
|
||||||
import { getContext } from 'svelte';
|
|
||||||
import { cubicOut } from 'svelte/easing';
|
|
||||||
import { slide } from 'svelte/transition';
|
|
||||||
import {
|
|
||||||
type BreadcrumbItem,
|
|
||||||
scrollBreadcrumbsStore,
|
|
||||||
} from '../../model';
|
|
||||||
|
|
||||||
const breadcrumbs = $derived(scrollBreadcrumbsStore.scrolledPastItems);
|
|
||||||
const responsive = getContext<ResponsiveManager>('responsive');
|
|
||||||
|
|
||||||
function handleClick(item: BreadcrumbItem) {
|
|
||||||
scrollBreadcrumbsStore.scrollTo(item.index);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createButtonText(item: BreadcrumbItem) {
|
|
||||||
const index = String(item.index + 1).padStart(2, '0');
|
|
||||||
if (responsive.isMobileOrTablet) {
|
|
||||||
return index;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${index} // ${item.title}`;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if breadcrumbs.length > 0}
|
|
||||||
<div
|
|
||||||
transition:slide={{ duration: 200 }}
|
|
||||||
class="
|
|
||||||
fixed top-0 left-0 right-0
|
|
||||||
h-14
|
|
||||||
md:h-16 px-4 md:px-6 lg:px-8
|
|
||||||
flex items-center justify-between
|
|
||||||
z-40
|
|
||||||
bg-surface/90 dark:bg-dark-bg/90 backdrop-blur-md
|
|
||||||
border-b border-black/5 dark:border-white/10
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<div class="max-w-8xl px-4 sm:px-6 h-full w-full flex items-center justify-between gap-2 sm:gap-4">
|
|
||||||
<Logo />
|
|
||||||
|
|
||||||
<nav class="flex items-center overflow-x-auto scrollbar-hide">
|
|
||||||
{#each breadcrumbs as item, _ (item.index)}
|
|
||||||
{@const active = scrollBreadcrumbsStore.activeIndex === item.index}
|
|
||||||
{@const text = createButtonText(item)}
|
|
||||||
<div class="ml-1 md:ml-4" transition:slide={{ duration: 200, axis: 'x', easing: cubicOut }}>
|
|
||||||
<Button
|
|
||||||
class="uppercase"
|
|
||||||
variant="tertiary"
|
|
||||||
size="xs"
|
|
||||||
{active}
|
|
||||||
onclick={() => handleClick(item)}
|
|
||||||
>
|
|
||||||
<Label class="text-inherit">
|
|
||||||
{text}
|
|
||||||
</Label>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
/* Hide scrollbar but keep functionality */
|
|
||||||
.scrollbar-hide {
|
|
||||||
-ms-overflow-style: none;
|
|
||||||
scrollbar-width: none;
|
|
||||||
}
|
|
||||||
.scrollbar-hide::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
<!--
|
|
||||||
Component: NavigationWrapper
|
|
||||||
Wrapper for breadcrumb registration with scroll tracking
|
|
||||||
-->
|
|
||||||
<script lang="ts">
|
|
||||||
import { type Snippet } from 'svelte';
|
|
||||||
import {
|
|
||||||
type NavigationAction,
|
|
||||||
scrollBreadcrumbsStore,
|
|
||||||
} from '../../model';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
/**
|
|
||||||
* Navigation index
|
|
||||||
*/
|
|
||||||
index: number;
|
|
||||||
/**
|
|
||||||
* Navigation title
|
|
||||||
*/
|
|
||||||
title: string;
|
|
||||||
/**
|
|
||||||
* Scroll offset
|
|
||||||
* @default 96
|
|
||||||
*/
|
|
||||||
offset?: number;
|
|
||||||
/**
|
|
||||||
* Content snippet
|
|
||||||
*/
|
|
||||||
content: Snippet<[action: NavigationAction]>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { index, title, offset = 96, content }: Props = $props();
|
|
||||||
|
|
||||||
function registerBreadcrumb(node: HTMLElement) {
|
|
||||||
scrollBreadcrumbsStore.add({ index, title, element: node }, offset);
|
|
||||||
return {
|
|
||||||
destroy() {
|
|
||||||
scrollBreadcrumbsStore.remove(index);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{@render content(registerBreadcrumb)}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export { default as BreadcrumbHeader } from './BreadcrumbHeader/BreadcrumbHeader.svelte';
|
|
||||||
export { default as NavigationWrapper } from './NavigationWrapper/NavigationWrapper.svelte';
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
/**
|
|
||||||
* Font API clients exports
|
|
||||||
*
|
|
||||||
* Exports API clients and normalization utilities
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Proxy API (primary)
|
|
||||||
export {
|
|
||||||
fetchFontsByIds,
|
|
||||||
fetchProxyFontById,
|
|
||||||
fetchProxyFonts,
|
|
||||||
} from './proxy/proxyFonts';
|
|
||||||
export type {
|
|
||||||
ProxyFontsParams,
|
|
||||||
ProxyFontsResponse,
|
|
||||||
} from './proxy/proxyFonts';
|
|
||||||
@@ -1,204 +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 { queryClient } from '$shared/api/queryClient';
|
|
||||||
import { fontKeys } from '$shared/api/queryKeys';
|
|
||||||
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 on invalid response (missing fonts array)', async () => {
|
|
||||||
mockApiGet({ total: 0 });
|
|
||||||
|
|
||||||
await expect(fetchProxyFonts()).rejects.toThrow('Proxy API returned invalid response');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should throw on null response data', async () => {
|
|
||||||
vi.mocked(api.get).mockResolvedValueOnce({ data: null, status: 200 });
|
|
||||||
|
|
||||||
await expect(fetchProxyFonts()).rejects.toThrow('Proxy API returned invalid 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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,199 +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 { queryClient } from '$shared/api/queryClient';
|
|
||||||
import { fontKeys } from '$shared/api/queryKeys';
|
|
||||||
import { buildQueryString } from '$shared/lib/utils';
|
|
||||||
import type { QueryParams } from '$shared/lib/utils';
|
|
||||||
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 => {
|
|
||||||
queryClient.setQueryData(fontKeys.detail(font.id), font);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Proxy API base URL
|
|
||||||
*/
|
|
||||||
const PROXY_API_URL = 'https://api.glyphdiff.com/api/v1/fonts' as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
|
||||||
*/
|
|
||||||
export interface ProxyFontsResponse {
|
|
||||||
/** Array of unified font objects */
|
|
||||||
fonts: UnifiedFont[];
|
|
||||||
|
|
||||||
/** Total number of fonts matching the query */
|
|
||||||
total: number;
|
|
||||||
|
|
||||||
/** Limit used for this request */
|
|
||||||
limit: number;
|
|
||||||
|
|
||||||
/** Offset used for this request */
|
|
||||||
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 || !Array.isArray(response.data.fonts)) {
|
|
||||||
throw new Error('Proxy API returned invalid response');
|
|
||||||
}
|
|
||||||
|
|
||||||
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,4 +0,0 @@
|
|||||||
export * from './api';
|
|
||||||
export * from './lib';
|
|
||||||
export * from './model';
|
|
||||||
export * from './ui';
|
|
||||||
@@ -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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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.
|
|
||||||
*
|
|
||||||
* @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 Error {
|
|
||||||
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,46 +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'];
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
export { getFontUrl } from './getFontUrl/getFontUrl';
|
|
||||||
|
|
||||||
// Mock data helpers for Storybook and testing
|
|
||||||
export {
|
|
||||||
createCategoriesFilter,
|
|
||||||
createErrorState,
|
|
||||||
createGenericFilter,
|
|
||||||
createLoadingState,
|
|
||||||
createMockComparisonStore,
|
|
||||||
// Filter mocks
|
|
||||||
createMockFilter,
|
|
||||||
createMockFontApiResponse,
|
|
||||||
createMockFontStoreState,
|
|
||||||
// Store mocks
|
|
||||||
createMockQueryState,
|
|
||||||
createMockReactiveState,
|
|
||||||
createMockStore,
|
|
||||||
createProvidersFilter,
|
|
||||||
createSubsetsFilter,
|
|
||||||
createSuccessState,
|
|
||||||
generateMixedCategoryFonts,
|
|
||||||
generateMockFonts,
|
|
||||||
generatePaginatedFonts,
|
|
||||||
generateSequentialFilter,
|
|
||||||
GENERIC_FILTERS,
|
|
||||||
getAllMockFonts,
|
|
||||||
getFontsByCategory,
|
|
||||||
getFontsByProvider,
|
|
||||||
MOCK_FILTERS,
|
|
||||||
MOCK_FILTERS_ALL_SELECTED,
|
|
||||||
MOCK_FILTERS_EMPTY,
|
|
||||||
MOCK_FILTERS_SELECTED,
|
|
||||||
MOCK_FONT_STORE_STATES,
|
|
||||||
MOCK_STORES,
|
|
||||||
type MockFilterOptions,
|
|
||||||
type MockFilters,
|
|
||||||
type MockFontStoreState,
|
|
||||||
// Font mocks
|
|
||||||
// Types
|
|
||||||
type MockQueryObserverResult,
|
|
||||||
type MockQueryState,
|
|
||||||
mockUnifiedFont,
|
|
||||||
type MockUnifiedFontOptions,
|
|
||||||
UNIFIED_FONTS,
|
|
||||||
} from './mocks';
|
|
||||||
|
|
||||||
export {
|
|
||||||
FontNetworkError,
|
|
||||||
FontResponseError,
|
|
||||||
} from './errors/errors';
|
|
||||||
|
|
||||||
export { createFontRowSizeResolver } from './sizeResolver/createFontRowSizeResolver';
|
|
||||||
export type { FontRowSizeResolverOptions } from './sizeResolver/createFontRowSizeResolver';
|
|
||||||
@@ -1,311 +0,0 @@
|
|||||||
/**
|
|
||||||
* Mock font filter data
|
|
||||||
*
|
|
||||||
* Factory functions and preset mock data for font-related filters.
|
|
||||||
* Used in Storybook stories for font filtering components.
|
|
||||||
*
|
|
||||||
* ## Usage
|
|
||||||
*
|
|
||||||
* ```ts
|
|
||||||
* import {
|
|
||||||
* createMockFilter,
|
|
||||||
* MOCK_FILTERS,
|
|
||||||
* } from '$entities/Font/lib/mocks';
|
|
||||||
*
|
|
||||||
* // Create a custom filter
|
|
||||||
* const customFilter = createMockFilter({
|
|
||||||
* properties: [
|
|
||||||
* { id: 'option1', name: 'Option 1', value: 'option1' },
|
|
||||||
* { id: 'option2', name: 'Option 2', value: 'option2', selected: true },
|
|
||||||
* ],
|
|
||||||
* });
|
|
||||||
*
|
|
||||||
* // Use preset filters
|
|
||||||
* const categoriesFilter = MOCK_FILTERS.categories;
|
|
||||||
* const subsetsFilter = MOCK_FILTERS.subsets;
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type {
|
|
||||||
FontCategory,
|
|
||||||
FontProvider,
|
|
||||||
FontSubset,
|
|
||||||
} from '$entities/Font/model/types';
|
|
||||||
import type { Property } from '$shared/lib';
|
|
||||||
import { createFilter } from '$shared/lib';
|
|
||||||
|
|
||||||
// TYPE DEFINITIONS
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Options for creating a mock filter
|
|
||||||
*/
|
|
||||||
export interface MockFilterOptions {
|
|
||||||
/** Filter properties */
|
|
||||||
properties: Property<string>[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Preset mock filters for font filtering
|
|
||||||
*/
|
|
||||||
export interface MockFilters {
|
|
||||||
/** Provider filter (Google, Fontshare) */
|
|
||||||
providers: ReturnType<typeof createFilter<'google' | 'fontshare'>>;
|
|
||||||
/** Category filter (sans-serif, serif, display, etc.) */
|
|
||||||
categories: ReturnType<typeof createFilter<FontCategory>>;
|
|
||||||
/** Subset filter (latin, latin-ext, cyrillic, etc.) */
|
|
||||||
subsets: ReturnType<typeof createFilter<FontSubset>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// FONT CATEGORIES
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unified categories (combines both providers)
|
|
||||||
*/
|
|
||||||
export const UNIFIED_CATEGORIES: Property<FontCategory>[] = [
|
|
||||||
{ id: 'sans-serif', name: 'Sans Serif', value: 'sans-serif' },
|
|
||||||
{ id: 'serif', name: 'Serif', value: 'serif' },
|
|
||||||
{ id: 'display', name: 'Display', value: 'display' },
|
|
||||||
{ id: 'handwriting', name: 'Handwriting', value: 'handwriting' },
|
|
||||||
{ id: 'monospace', name: 'Monospace', value: 'monospace' },
|
|
||||||
{ id: 'slab', name: 'Slab', value: 'slab' },
|
|
||||||
{ id: 'script', name: 'Script', value: 'script' },
|
|
||||||
];
|
|
||||||
|
|
||||||
// FONT SUBSETS
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Common font subsets
|
|
||||||
*/
|
|
||||||
export const FONT_SUBSETS: Property<FontSubset>[] = [
|
|
||||||
{ id: 'latin', name: 'Latin', value: 'latin' },
|
|
||||||
{ id: 'latin-ext', name: 'Latin Extended', value: 'latin-ext' },
|
|
||||||
{ id: 'cyrillic', name: 'Cyrillic', value: 'cyrillic' },
|
|
||||||
{ id: 'greek', name: 'Greek', value: 'greek' },
|
|
||||||
{ id: 'arabic', name: 'Arabic', value: 'arabic' },
|
|
||||||
{ id: 'devanagari', name: 'Devanagari', value: 'devanagari' },
|
|
||||||
];
|
|
||||||
|
|
||||||
// FONT PROVIDERS
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Font providers
|
|
||||||
*/
|
|
||||||
export const FONT_PROVIDERS: Property<FontProvider>[] = [
|
|
||||||
{ id: 'google', name: 'Google Fonts', value: 'google' },
|
|
||||||
{ id: 'fontshare', name: 'Fontshare', value: 'fontshare' },
|
|
||||||
];
|
|
||||||
|
|
||||||
// FILTER FACTORIES
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a mock filter from properties
|
|
||||||
*/
|
|
||||||
export function createMockFilter<TValue extends string>(
|
|
||||||
options: MockFilterOptions & { properties: Property<TValue>[] },
|
|
||||||
) {
|
|
||||||
return createFilter<TValue>(options);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a mock filter for categories
|
|
||||||
*/
|
|
||||||
export function createCategoriesFilter(options?: { selected?: FontCategory[] }) {
|
|
||||||
const properties = UNIFIED_CATEGORIES.map(cat => ({
|
|
||||||
...cat,
|
|
||||||
selected: options?.selected?.includes(cat.value) ?? false,
|
|
||||||
}));
|
|
||||||
return createFilter<FontCategory>({ properties });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a mock filter for subsets
|
|
||||||
*/
|
|
||||||
export function createSubsetsFilter(options?: { selected?: FontSubset[] }) {
|
|
||||||
const properties = FONT_SUBSETS.map(subset => ({
|
|
||||||
...subset,
|
|
||||||
selected: options?.selected?.includes(subset.value) ?? false,
|
|
||||||
}));
|
|
||||||
return createFilter<FontSubset>({ properties });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a mock filter for providers
|
|
||||||
*/
|
|
||||||
export function createProvidersFilter(options?: { selected?: FontProvider[] }) {
|
|
||||||
const properties = FONT_PROVIDERS.map(provider => ({
|
|
||||||
...provider,
|
|
||||||
selected: options?.selected?.includes(provider.value) ?? false,
|
|
||||||
}));
|
|
||||||
return createFilter<FontProvider>({ properties });
|
|
||||||
}
|
|
||||||
|
|
||||||
// PRESET FILTERS
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Preset mock filters - use these directly in stories
|
|
||||||
*/
|
|
||||||
export const MOCK_FILTERS: MockFilters = {
|
|
||||||
providers: createFilter({
|
|
||||||
properties: FONT_PROVIDERS,
|
|
||||||
}),
|
|
||||||
categories: createFilter({
|
|
||||||
properties: UNIFIED_CATEGORIES,
|
|
||||||
}),
|
|
||||||
subsets: createFilter({
|
|
||||||
properties: FONT_SUBSETS,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Preset filters with some items selected
|
|
||||||
*/
|
|
||||||
export const MOCK_FILTERS_SELECTED: MockFilters = {
|
|
||||||
providers: createFilter({
|
|
||||||
properties: [
|
|
||||||
{ ...FONT_PROVIDERS[0], selected: true },
|
|
||||||
{ ...FONT_PROVIDERS[1] },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
categories: createFilter({
|
|
||||||
properties: [
|
|
||||||
{ ...UNIFIED_CATEGORIES[0], selected: true },
|
|
||||||
{ ...UNIFIED_CATEGORIES[1], selected: true },
|
|
||||||
{ ...UNIFIED_CATEGORIES[2] },
|
|
||||||
{ ...UNIFIED_CATEGORIES[3] },
|
|
||||||
{ ...UNIFIED_CATEGORIES[4] },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
subsets: createFilter({
|
|
||||||
properties: [
|
|
||||||
{ ...FONT_SUBSETS[0], selected: true },
|
|
||||||
{ ...FONT_SUBSETS[1] },
|
|
||||||
{ ...FONT_SUBSETS[2] },
|
|
||||||
{ ...FONT_SUBSETS[3] },
|
|
||||||
{ ...FONT_SUBSETS[4] },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Empty filters (all properties, none selected)
|
|
||||||
*/
|
|
||||||
export const MOCK_FILTERS_EMPTY: MockFilters = {
|
|
||||||
providers: createFilter({
|
|
||||||
properties: FONT_PROVIDERS.map(p => ({ ...p, selected: false })),
|
|
||||||
}),
|
|
||||||
categories: createFilter({
|
|
||||||
properties: UNIFIED_CATEGORIES.map(c => ({ ...c, selected: false })),
|
|
||||||
}),
|
|
||||||
subsets: createFilter({
|
|
||||||
properties: FONT_SUBSETS.map(s => ({ ...s, selected: false })),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* All selected filters
|
|
||||||
*/
|
|
||||||
export const MOCK_FILTERS_ALL_SELECTED: MockFilters = {
|
|
||||||
providers: createFilter({
|
|
||||||
properties: FONT_PROVIDERS.map(p => ({ ...p, selected: true })),
|
|
||||||
}),
|
|
||||||
categories: createFilter({
|
|
||||||
properties: UNIFIED_CATEGORIES.map(c => ({ ...c, selected: true })),
|
|
||||||
}),
|
|
||||||
subsets: createFilter({
|
|
||||||
properties: FONT_SUBSETS.map(s => ({ ...s, selected: true })),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
// GENERIC FILTER MOCKS
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a mock filter with generic string properties
|
|
||||||
* Useful for testing generic filter components
|
|
||||||
*/
|
|
||||||
export function createGenericFilter(
|
|
||||||
items: Array<{ id: string; name: string; selected?: boolean }>,
|
|
||||||
options?: { selected?: string[] },
|
|
||||||
) {
|
|
||||||
const properties = items.map(item => ({
|
|
||||||
id: item.id,
|
|
||||||
name: item.name,
|
|
||||||
value: item.id,
|
|
||||||
selected: options?.selected?.includes(item.id) ?? item.selected ?? false,
|
|
||||||
}));
|
|
||||||
return createFilter({ properties });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Preset generic filters for testing
|
|
||||||
*/
|
|
||||||
export const GENERIC_FILTERS = {
|
|
||||||
/** Small filter with 3 items */
|
|
||||||
small: createFilter({
|
|
||||||
properties: [
|
|
||||||
{ id: 'option-1', name: 'Option 1', value: 'option-1' },
|
|
||||||
{ id: 'option-2', name: 'Option 2', value: 'option-2' },
|
|
||||||
{ id: 'option-3', name: 'Option 3', value: 'option-3' },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
/** Medium filter with 6 items */
|
|
||||||
medium: createFilter({
|
|
||||||
properties: [
|
|
||||||
{ id: 'alpha', name: 'Alpha', value: 'alpha' },
|
|
||||||
{ id: 'beta', name: 'Beta', value: 'beta' },
|
|
||||||
{ id: 'gamma', name: 'Gamma', value: 'gamma' },
|
|
||||||
{ id: 'delta', name: 'Delta', value: 'delta' },
|
|
||||||
{ id: 'epsilon', name: 'Epsilon', value: 'epsilon' },
|
|
||||||
{ id: 'zeta', name: 'Zeta', value: 'zeta' },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
/** Large filter with 12 items */
|
|
||||||
large: createFilter({
|
|
||||||
properties: [
|
|
||||||
{ id: 'jan', name: 'January', value: 'jan' },
|
|
||||||
{ id: 'feb', name: 'February', value: 'feb' },
|
|
||||||
{ id: 'mar', name: 'March', value: 'mar' },
|
|
||||||
{ id: 'apr', name: 'April', value: 'apr' },
|
|
||||||
{ id: 'may', name: 'May', value: 'may' },
|
|
||||||
{ id: 'jun', name: 'June', value: 'jun' },
|
|
||||||
{ id: 'jul', name: 'July', value: 'jul' },
|
|
||||||
{ id: 'aug', name: 'August', value: 'aug' },
|
|
||||||
{ id: 'sep', name: 'September', value: 'sep' },
|
|
||||||
{ id: 'oct', name: 'October', value: 'oct' },
|
|
||||||
{ id: 'nov', name: 'November', value: 'nov' },
|
|
||||||
{ id: 'dec', name: 'December', value: 'dec' },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
/** Filter with some pre-selected items */
|
|
||||||
partial: createFilter({
|
|
||||||
properties: [
|
|
||||||
{ id: 'red', name: 'Red', value: 'red', selected: true },
|
|
||||||
{ id: 'blue', name: 'Blue', value: 'blue', selected: false },
|
|
||||||
{ id: 'green', name: 'Green', value: 'green', selected: true },
|
|
||||||
{ id: 'yellow', name: 'Yellow', value: 'yellow', selected: false },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
/** Filter with all items selected */
|
|
||||||
allSelected: createFilter({
|
|
||||||
properties: [
|
|
||||||
{ id: 'cat', name: 'Cat', value: 'cat', selected: true },
|
|
||||||
{ id: 'dog', name: 'Dog', value: 'dog', selected: true },
|
|
||||||
{ id: 'bird', name: 'Bird', value: 'bird', selected: true },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
/** Empty filter (no items) */
|
|
||||||
empty: createFilter({
|
|
||||||
properties: [],
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a filter with sequential items
|
|
||||||
*/
|
|
||||||
export function generateSequentialFilter(count: number, prefix = 'Item ') {
|
|
||||||
const properties = Array.from({ length: count }, (_, i) => ({
|
|
||||||
id: `item-${i + 1}`,
|
|
||||||
name: `${prefix}${i + 1}`,
|
|
||||||
value: `item-${i + 1}`,
|
|
||||||
}));
|
|
||||||
return createFilter({ properties });
|
|
||||||
}
|
|
||||||
@@ -1,274 +0,0 @@
|
|||||||
/**
|
|
||||||
* ============================================================================
|
|
||||||
* MOCK FONT DATA
|
|
||||||
* ============================================================================
|
|
||||||
*
|
|
||||||
* Factory functions and preset mock data for fonts.
|
|
||||||
* Used in Storybook stories, tests, and development.
|
|
||||||
*
|
|
||||||
* ## Usage
|
|
||||||
*
|
|
||||||
* ```ts
|
|
||||||
* import {
|
|
||||||
* mockGoogleFont,
|
|
||||||
* mockFontshareFont,
|
|
||||||
* mockUnifiedFont,
|
|
||||||
* GOOGLE_FONTS,
|
|
||||||
* FONTHARE_FONTS,
|
|
||||||
* UNIFIED_FONTS,
|
|
||||||
* } from '$entities/Font/lib/mocks';
|
|
||||||
*
|
|
||||||
* // Create a mock Google Font
|
|
||||||
* const roboto = mockGoogleFont({ family: 'Roboto', category: 'sans-serif' });
|
|
||||||
*
|
|
||||||
* // Create a mock Fontshare font
|
|
||||||
* const satoshi = mockFontshareFont({ name: 'Satoshi', slug: 'satoshi' });
|
|
||||||
*
|
|
||||||
* // Create a mock UnifiedFont
|
|
||||||
* const font = mockUnifiedFont({ id: 'roboto', name: 'Roboto' });
|
|
||||||
*
|
|
||||||
* // Use preset fonts
|
|
||||||
* import { UNIFIED_FONTS } from '$entities/Font/lib/mocks';
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type {
|
|
||||||
FontCategory,
|
|
||||||
FontProvider,
|
|
||||||
FontSubset,
|
|
||||||
FontVariant,
|
|
||||||
} from '$entities/Font/model/types';
|
|
||||||
import type {
|
|
||||||
FontFeatures,
|
|
||||||
FontMetadata,
|
|
||||||
FontStyleUrls,
|
|
||||||
UnifiedFont,
|
|
||||||
} from '$entities/Font/model/types';
|
|
||||||
|
|
||||||
// UNIFIED FONT MOCKS
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Options for creating a mock UnifiedFont
|
|
||||||
*/
|
|
||||||
export interface MockUnifiedFontOptions {
|
|
||||||
/** Unique identifier (default: derived from name) */
|
|
||||||
id?: string;
|
|
||||||
/** Font display name (default: 'Mock Font') */
|
|
||||||
name?: string;
|
|
||||||
/** Font provider (default: 'google') */
|
|
||||||
provider?: FontProvider;
|
|
||||||
/** Font category (default: 'sans-serif') */
|
|
||||||
category?: FontCategory;
|
|
||||||
/** Font subsets (default: ['latin']) */
|
|
||||||
subsets?: FontSubset[];
|
|
||||||
/** Font variants (default: ['regular', '700', 'italic', '700italic']) */
|
|
||||||
variants?: FontVariant[];
|
|
||||||
/** Style URLs (if not provided, mock URLs are generated) */
|
|
||||||
styles?: FontStyleUrls;
|
|
||||||
/** Metadata overrides */
|
|
||||||
metadata?: Partial<FontMetadata>;
|
|
||||||
/** Features overrides */
|
|
||||||
features?: Partial<FontFeatures>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Default mock UnifiedFont
|
|
||||||
*/
|
|
||||||
export function mockUnifiedFont(options: MockUnifiedFontOptions = {}): UnifiedFont {
|
|
||||||
const {
|
|
||||||
id,
|
|
||||||
name = 'Mock Font',
|
|
||||||
provider = 'google',
|
|
||||||
category = 'sans-serif',
|
|
||||||
subsets = ['latin'],
|
|
||||||
variants = ['regular', '700', 'italic', '700italic'],
|
|
||||||
styles,
|
|
||||||
metadata,
|
|
||||||
features,
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
const fontId = id ?? name.toLowerCase().replace(/\s+/g, '');
|
|
||||||
const baseUrl = provider === 'google'
|
|
||||||
? `https://fonts.gstatic.com/s/${fontId}/v30`
|
|
||||||
: `//cdn.fontshare.com/wf/${fontId}`;
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: fontId,
|
|
||||||
name,
|
|
||||||
provider,
|
|
||||||
category,
|
|
||||||
subsets,
|
|
||||||
variants: variants as FontVariant[],
|
|
||||||
styles: styles ?? {
|
|
||||||
regular: `${baseUrl}/regular.woff2`,
|
|
||||||
bold: `${baseUrl}/bold.woff2`,
|
|
||||||
italic: `${baseUrl}/italic.woff2`,
|
|
||||||
boldItalic: `${baseUrl}/bolditalic.woff2`,
|
|
||||||
},
|
|
||||||
metadata: {
|
|
||||||
cachedAt: Date.now(),
|
|
||||||
version: '1.0',
|
|
||||||
lastModified: new Date().toISOString().split('T')[0],
|
|
||||||
popularity: 1,
|
|
||||||
...metadata,
|
|
||||||
},
|
|
||||||
features: {
|
|
||||||
isVariable: false,
|
|
||||||
...features,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Preset UnifiedFont mocks
|
|
||||||
*/
|
|
||||||
export const UNIFIED_FONTS: Record<string, UnifiedFont> = {
|
|
||||||
roboto: mockUnifiedFont({
|
|
||||||
id: 'roboto',
|
|
||||||
name: 'Roboto',
|
|
||||||
provider: 'google',
|
|
||||||
category: 'sans-serif',
|
|
||||||
subsets: ['latin', 'latin-ext'],
|
|
||||||
variants: ['100', '300', '400', '500', '700', '900'],
|
|
||||||
metadata: { popularity: 1 },
|
|
||||||
}),
|
|
||||||
openSans: mockUnifiedFont({
|
|
||||||
id: 'open-sans',
|
|
||||||
name: 'Open Sans',
|
|
||||||
provider: 'google',
|
|
||||||
category: 'sans-serif',
|
|
||||||
subsets: ['latin', 'latin-ext'],
|
|
||||||
variants: ['300', '400', '500', '600', '700', '800'],
|
|
||||||
metadata: { popularity: 2 },
|
|
||||||
}),
|
|
||||||
lato: mockUnifiedFont({
|
|
||||||
id: 'lato',
|
|
||||||
name: 'Lato',
|
|
||||||
provider: 'google',
|
|
||||||
category: 'sans-serif',
|
|
||||||
subsets: ['latin', 'latin-ext'],
|
|
||||||
variants: ['100', '300', '400', '700', '900'],
|
|
||||||
metadata: { popularity: 3 },
|
|
||||||
}),
|
|
||||||
playfairDisplay: mockUnifiedFont({
|
|
||||||
id: 'playfair-display',
|
|
||||||
name: 'Playfair Display',
|
|
||||||
provider: 'google',
|
|
||||||
category: 'serif',
|
|
||||||
subsets: ['latin'],
|
|
||||||
variants: ['400', '700', '900'],
|
|
||||||
metadata: { popularity: 10 },
|
|
||||||
}),
|
|
||||||
montserrat: mockUnifiedFont({
|
|
||||||
id: 'montserrat',
|
|
||||||
name: 'Montserrat',
|
|
||||||
provider: 'google',
|
|
||||||
category: 'sans-serif',
|
|
||||||
subsets: ['latin', 'latin-ext'],
|
|
||||||
variants: ['100', '200', '300', '400', '500', '600', '700', '800', '900'],
|
|
||||||
metadata: { popularity: 4 },
|
|
||||||
}),
|
|
||||||
satoshi: mockUnifiedFont({
|
|
||||||
id: 'satoshi',
|
|
||||||
name: 'Satoshi',
|
|
||||||
provider: 'fontshare',
|
|
||||||
category: 'sans-serif',
|
|
||||||
subsets: ['latin'],
|
|
||||||
variants: ['regular', 'bold', 'italic', 'bolditalic'] as FontVariant[],
|
|
||||||
features: { isVariable: true, axes: [{ name: 'wght', property: 'wght', default: 400, min: 300, max: 700 }] },
|
|
||||||
metadata: { popularity: 15000 },
|
|
||||||
}),
|
|
||||||
generalSans: mockUnifiedFont({
|
|
||||||
id: 'general-sans',
|
|
||||||
name: 'General Sans',
|
|
||||||
provider: 'fontshare',
|
|
||||||
category: 'sans-serif',
|
|
||||||
subsets: ['latin'],
|
|
||||||
variants: ['regular', 'bold', 'italic', 'bolditalic'] as FontVariant[],
|
|
||||||
features: { isVariable: true },
|
|
||||||
metadata: { popularity: 12000 },
|
|
||||||
}),
|
|
||||||
clashDisplay: mockUnifiedFont({
|
|
||||||
id: 'clash-display',
|
|
||||||
name: 'Clash Display',
|
|
||||||
provider: 'fontshare',
|
|
||||||
category: 'display',
|
|
||||||
subsets: ['latin'],
|
|
||||||
variants: ['regular', '500', '600', 'bold'] as FontVariant[],
|
|
||||||
features: { tags: ['Headlines', 'Posters', 'Branding'] },
|
|
||||||
metadata: { popularity: 8000 },
|
|
||||||
}),
|
|
||||||
oswald: mockUnifiedFont({
|
|
||||||
id: 'oswald',
|
|
||||||
name: 'Oswald',
|
|
||||||
provider: 'google',
|
|
||||||
category: 'sans-serif',
|
|
||||||
subsets: ['latin'],
|
|
||||||
variants: ['200', '300', '400', '500', '600', '700'],
|
|
||||||
metadata: { popularity: 6 },
|
|
||||||
}),
|
|
||||||
raleway: mockUnifiedFont({
|
|
||||||
id: 'raleway',
|
|
||||||
name: 'Raleway',
|
|
||||||
provider: 'google',
|
|
||||||
category: 'sans-serif',
|
|
||||||
subsets: ['latin'],
|
|
||||||
variants: ['100', '200', '300', '400', '500', '600', '700', '800', '900'],
|
|
||||||
metadata: { popularity: 7 },
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get an array of all preset UnifiedFonts
|
|
||||||
*/
|
|
||||||
export function getAllMockFonts(): UnifiedFont[] {
|
|
||||||
return Object.values(UNIFIED_FONTS);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get fonts by provider
|
|
||||||
*/
|
|
||||||
export function getFontsByProvider(provider: FontProvider): UnifiedFont[] {
|
|
||||||
return getAllMockFonts().filter(font => font.provider === provider);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get fonts by category
|
|
||||||
*/
|
|
||||||
export function getFontsByCategory(category: FontCategory): UnifiedFont[] {
|
|
||||||
return getAllMockFonts().filter(font => font.category === category);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate an array of mock fonts with sequential naming
|
|
||||||
*/
|
|
||||||
export function generateMockFonts(count: number, options?: Omit<MockUnifiedFontOptions, 'id' | 'name'>): UnifiedFont[] {
|
|
||||||
return Array.from({ length: count }, (_, i) =>
|
|
||||||
mockUnifiedFont({
|
|
||||||
...options,
|
|
||||||
id: `mock-font-${i + 1}`,
|
|
||||||
name: `Mock Font ${i + 1}`,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate an array of mock fonts with different categories
|
|
||||||
*/
|
|
||||||
export function generateMixedCategoryFonts(countPerCategory: number = 2): UnifiedFont[] {
|
|
||||||
const categories: FontCategory[] = ['sans-serif', 'serif', 'display', 'handwriting', 'monospace'];
|
|
||||||
const fonts: UnifiedFont[] = [];
|
|
||||||
|
|
||||||
categories.forEach(category => {
|
|
||||||
for (let i = 0; i < countPerCategory; i++) {
|
|
||||||
fonts.push(
|
|
||||||
mockUnifiedFont({
|
|
||||||
id: `${category}-${i + 1}`,
|
|
||||||
name: `${category.replace('-', ' ')} ${i + 1}`,
|
|
||||||
category,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return fonts;
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
/**
|
|
||||||
* ============================================================================
|
|
||||||
* MOCK DATA HELPERS - MAIN EXPORT
|
|
||||||
* ============================================================================
|
|
||||||
*
|
|
||||||
* Comprehensive mock data for Storybook stories, tests, and development.
|
|
||||||
*
|
|
||||||
* ## Quick Start
|
|
||||||
*
|
|
||||||
* ```ts
|
|
||||||
* import {
|
|
||||||
* mockUnifiedFont,
|
|
||||||
* UNIFIED_FONTS,
|
|
||||||
* MOCK_FILTERS,
|
|
||||||
* createMockFontStoreState,
|
|
||||||
* } from '$entities/Font/lib/mocks';
|
|
||||||
*
|
|
||||||
* // Use in stories
|
|
||||||
* const font = mockUnifiedFont({ name: 'My Font', category: 'serif' });
|
|
||||||
* const presets = UNIFIED_FONTS;
|
|
||||||
* const filter = MOCK_FILTERS.categories;
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* @module
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Font mocks
|
|
||||||
export {
|
|
||||||
generateMixedCategoryFonts,
|
|
||||||
generateMockFonts,
|
|
||||||
getAllMockFonts,
|
|
||||||
getFontsByCategory,
|
|
||||||
getFontsByProvider,
|
|
||||||
mockUnifiedFont,
|
|
||||||
type MockUnifiedFontOptions,
|
|
||||||
UNIFIED_FONTS,
|
|
||||||
} from './fonts.mock';
|
|
||||||
|
|
||||||
// Filter mocks
|
|
||||||
export {
|
|
||||||
createCategoriesFilter,
|
|
||||||
createGenericFilter,
|
|
||||||
createMockFilter,
|
|
||||||
createProvidersFilter,
|
|
||||||
createSubsetsFilter,
|
|
||||||
FONT_PROVIDERS,
|
|
||||||
FONT_SUBSETS,
|
|
||||||
generateSequentialFilter,
|
|
||||||
GENERIC_FILTERS,
|
|
||||||
MOCK_FILTERS,
|
|
||||||
MOCK_FILTERS_ALL_SELECTED,
|
|
||||||
MOCK_FILTERS_EMPTY,
|
|
||||||
MOCK_FILTERS_SELECTED,
|
|
||||||
type MockFilterOptions,
|
|
||||||
type MockFilters,
|
|
||||||
UNIFIED_CATEGORIES,
|
|
||||||
} from './filters.mock';
|
|
||||||
|
|
||||||
// Store mocks
|
|
||||||
export {
|
|
||||||
createErrorState,
|
|
||||||
createLoadingState,
|
|
||||||
createMockComparisonStore,
|
|
||||||
createMockFontApiResponse,
|
|
||||||
createMockFontStoreState,
|
|
||||||
createMockQueryState,
|
|
||||||
createMockReactiveState,
|
|
||||||
createMockStore,
|
|
||||||
createSuccessState,
|
|
||||||
generatePaginatedFonts,
|
|
||||||
MOCK_FONT_STORE_STATES,
|
|
||||||
MOCK_STORES,
|
|
||||||
type MockFontStoreState,
|
|
||||||
type MockQueryObserverResult,
|
|
||||||
type MockQueryState,
|
|
||||||
} from './stores.mock';
|
|
||||||
@@ -1,689 +0,0 @@
|
|||||||
/**
|
|
||||||
* ============================================================================
|
|
||||||
* MOCK FONT STORE HELPERS
|
|
||||||
* ============================================================================
|
|
||||||
*
|
|
||||||
* Factory functions and preset mock data for TanStack Query stores and state management.
|
|
||||||
* Used in Storybook stories for components that use reactive stores.
|
|
||||||
*
|
|
||||||
* ## Usage
|
|
||||||
*
|
|
||||||
* ```ts
|
|
||||||
* import {
|
|
||||||
* createMockQueryState,
|
|
||||||
* MOCK_STORES,
|
|
||||||
* } from '$entities/Font/lib/mocks';
|
|
||||||
*
|
|
||||||
* // Create a mock query state
|
|
||||||
* const loadingState = createMockQueryState({ status: 'pending' });
|
|
||||||
* const errorState = createMockQueryState({ status: 'error', error: 'Failed to load' });
|
|
||||||
* const successState = createMockQueryState({ status: 'success', data: mockFonts });
|
|
||||||
*
|
|
||||||
* // Use preset stores
|
|
||||||
* const mockFontStore = createMockFontStore();
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { UnifiedFont } from '$entities/Font/model/types';
|
|
||||||
import type {
|
|
||||||
QueryKey,
|
|
||||||
QueryObserverResult,
|
|
||||||
QueryStatus,
|
|
||||||
} from '@tanstack/svelte-query';
|
|
||||||
import {
|
|
||||||
UNIFIED_FONTS,
|
|
||||||
generateMockFonts,
|
|
||||||
} from './fonts.mock';
|
|
||||||
|
|
||||||
// TANSTACK QUERY MOCK TYPES
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mock TanStack Query state
|
|
||||||
*/
|
|
||||||
export interface MockQueryState<TData = unknown, TError = Error> {
|
|
||||||
status: QueryStatus;
|
|
||||||
data?: TData;
|
|
||||||
error?: TError;
|
|
||||||
isLoading?: boolean;
|
|
||||||
isFetching?: boolean;
|
|
||||||
isSuccess?: boolean;
|
|
||||||
isError?: boolean;
|
|
||||||
isPending?: boolean;
|
|
||||||
dataUpdatedAt?: number;
|
|
||||||
errorUpdatedAt?: number;
|
|
||||||
failureCount?: number;
|
|
||||||
failureReason?: TError;
|
|
||||||
errorUpdateCount?: number;
|
|
||||||
isRefetching?: boolean;
|
|
||||||
isRefetchError?: boolean;
|
|
||||||
isPaused?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mock TanStack Query observer result
|
|
||||||
*/
|
|
||||||
export interface MockQueryObserverResult<TData = unknown, TError = Error> {
|
|
||||||
status?: QueryStatus;
|
|
||||||
data?: TData;
|
|
||||||
error?: TError;
|
|
||||||
isLoading?: boolean;
|
|
||||||
isFetching?: boolean;
|
|
||||||
isSuccess?: boolean;
|
|
||||||
isError?: boolean;
|
|
||||||
isPending?: boolean;
|
|
||||||
dataUpdatedAt?: number;
|
|
||||||
errorUpdatedAt?: number;
|
|
||||||
failureCount?: number;
|
|
||||||
failureReason?: TError;
|
|
||||||
errorUpdateCount?: number;
|
|
||||||
isRefetching?: boolean;
|
|
||||||
isRefetchError?: boolean;
|
|
||||||
isPaused?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// TANSTACK QUERY MOCK FACTORIES
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a mock query state for TanStack Query
|
|
||||||
*/
|
|
||||||
export function createMockQueryState<TData = unknown, TError = Error>(
|
|
||||||
options: MockQueryState<TData, TError>,
|
|
||||||
): MockQueryObserverResult<TData, TError> {
|
|
||||||
const {
|
|
||||||
status,
|
|
||||||
data,
|
|
||||||
error,
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: status ?? 'success',
|
|
||||||
data,
|
|
||||||
error,
|
|
||||||
isLoading: status === 'pending' ? true : false,
|
|
||||||
isFetching: status === 'pending' ? true : false,
|
|
||||||
isSuccess: status === 'success',
|
|
||||||
isError: status === 'error',
|
|
||||||
isPending: status === 'pending',
|
|
||||||
dataUpdatedAt: status === 'success' ? Date.now() : undefined,
|
|
||||||
errorUpdatedAt: status === 'error' ? Date.now() : undefined,
|
|
||||||
failureCount: status === 'error' ? 1 : 0,
|
|
||||||
failureReason: status === 'error' ? error : undefined,
|
|
||||||
errorUpdateCount: status === 'error' ? 1 : 0,
|
|
||||||
isRefetching: false,
|
|
||||||
isRefetchError: false,
|
|
||||||
isPaused: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a loading query state
|
|
||||||
*/
|
|
||||||
export function createLoadingState<TData = unknown>(): MockQueryObserverResult<TData> {
|
|
||||||
return createMockQueryState<TData>({ status: 'pending', data: undefined, error: undefined });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create an error query state
|
|
||||||
*/
|
|
||||||
export function createErrorState<TError = Error>(
|
|
||||||
error: TError,
|
|
||||||
): MockQueryObserverResult<unknown, TError> {
|
|
||||||
return createMockQueryState<unknown, TError>({ status: 'error', data: undefined, error });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a success query state
|
|
||||||
*/
|
|
||||||
export function createSuccessState<TData>(data: TData): MockQueryObserverResult<TData> {
|
|
||||||
return createMockQueryState<TData>({ status: 'success', data, error: undefined });
|
|
||||||
}
|
|
||||||
|
|
||||||
// FONT STORE MOCKS
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mock UnifiedFontStore state
|
|
||||||
*/
|
|
||||||
export interface MockFontStoreState {
|
|
||||||
/** All cached fonts */
|
|
||||||
fonts: Record<string, UnifiedFont>;
|
|
||||||
/** Current page */
|
|
||||||
page: number;
|
|
||||||
/** Total pages available */
|
|
||||||
totalPages: number;
|
|
||||||
/** Items per page */
|
|
||||||
limit: number;
|
|
||||||
/** Total font count */
|
|
||||||
total: number;
|
|
||||||
/** Loading state */
|
|
||||||
isLoading: boolean;
|
|
||||||
/** Error state */
|
|
||||||
error: Error | null;
|
|
||||||
/** Search query */
|
|
||||||
searchQuery: string;
|
|
||||||
/** Selected provider */
|
|
||||||
provider: 'google' | 'fontshare' | 'all';
|
|
||||||
/** Selected category */
|
|
||||||
category: string | null;
|
|
||||||
/** Selected subset */
|
|
||||||
subset: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a mock font store state
|
|
||||||
*/
|
|
||||||
export function createMockFontStoreState(
|
|
||||||
options: Partial<MockFontStoreState> = {},
|
|
||||||
): MockFontStoreState {
|
|
||||||
const {
|
|
||||||
page = 1,
|
|
||||||
limit = 24,
|
|
||||||
isLoading = false,
|
|
||||||
error = null,
|
|
||||||
searchQuery = '',
|
|
||||||
provider = 'all',
|
|
||||||
category = null,
|
|
||||||
subset = null,
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
// Generate mock fonts if not provided
|
|
||||||
const mockFonts = options.fonts ?? Object.fromEntries(
|
|
||||||
Object.values(UNIFIED_FONTS).map(font => [font.id, font]),
|
|
||||||
);
|
|
||||||
|
|
||||||
const fontArray = Object.values(mockFonts);
|
|
||||||
const total = options.total ?? fontArray.length;
|
|
||||||
const totalPages = options.totalPages ?? Math.ceil(total / limit);
|
|
||||||
|
|
||||||
return {
|
|
||||||
fonts: mockFonts,
|
|
||||||
page,
|
|
||||||
totalPages,
|
|
||||||
limit,
|
|
||||||
total,
|
|
||||||
isLoading,
|
|
||||||
error,
|
|
||||||
searchQuery,
|
|
||||||
provider,
|
|
||||||
category,
|
|
||||||
subset,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Preset font store states
|
|
||||||
*/
|
|
||||||
export const MOCK_FONT_STORE_STATES = {
|
|
||||||
/** Initial loading state */
|
|
||||||
loading: createMockFontStoreState({
|
|
||||||
isLoading: true,
|
|
||||||
fonts: {},
|
|
||||||
total: 0,
|
|
||||||
page: 1,
|
|
||||||
}),
|
|
||||||
|
|
||||||
/** Empty state (no fonts found) */
|
|
||||||
empty: createMockFontStoreState({
|
|
||||||
fonts: {},
|
|
||||||
total: 0,
|
|
||||||
page: 1,
|
|
||||||
isLoading: false,
|
|
||||||
}),
|
|
||||||
|
|
||||||
/** First page with fonts */
|
|
||||||
firstPage: createMockFontStoreState({
|
|
||||||
fonts: Object.fromEntries(
|
|
||||||
Object.values(UNIFIED_FONTS).slice(0, 10).map(font => [font.id, font]),
|
|
||||||
),
|
|
||||||
total: 50,
|
|
||||||
page: 1,
|
|
||||||
limit: 10,
|
|
||||||
totalPages: 5,
|
|
||||||
isLoading: false,
|
|
||||||
}),
|
|
||||||
|
|
||||||
/** Second page with fonts */
|
|
||||||
secondPage: createMockFontStoreState({
|
|
||||||
fonts: Object.fromEntries(
|
|
||||||
Object.values(UNIFIED_FONTS).slice(10, 20).map(font => [font.id, font]),
|
|
||||||
),
|
|
||||||
total: 50,
|
|
||||||
page: 2,
|
|
||||||
limit: 10,
|
|
||||||
totalPages: 5,
|
|
||||||
isLoading: false,
|
|
||||||
}),
|
|
||||||
|
|
||||||
/** Last page with fonts */
|
|
||||||
lastPage: createMockFontStoreState({
|
|
||||||
fonts: Object.fromEntries(
|
|
||||||
Object.values(UNIFIED_FONTS).slice(0, 5).map(font => [font.id, font]),
|
|
||||||
),
|
|
||||||
total: 25,
|
|
||||||
page: 3,
|
|
||||||
limit: 10,
|
|
||||||
totalPages: 3,
|
|
||||||
isLoading: false,
|
|
||||||
}),
|
|
||||||
|
|
||||||
/** Error state */
|
|
||||||
error: createMockFontStoreState({
|
|
||||||
fonts: {},
|
|
||||||
error: new Error('Failed to load fonts'),
|
|
||||||
total: 0,
|
|
||||||
page: 1,
|
|
||||||
isLoading: false,
|
|
||||||
}),
|
|
||||||
|
|
||||||
/** With search query */
|
|
||||||
withSearch: createMockFontStoreState({
|
|
||||||
fonts: Object.fromEntries(
|
|
||||||
Object.values(UNIFIED_FONTS).slice(0, 3).map(font => [font.id, font]),
|
|
||||||
),
|
|
||||||
total: 3,
|
|
||||||
page: 1,
|
|
||||||
isLoading: false,
|
|
||||||
searchQuery: 'Roboto',
|
|
||||||
}),
|
|
||||||
|
|
||||||
/** Filtered by category */
|
|
||||||
filteredByCategory: createMockFontStoreState({
|
|
||||||
fonts: Object.fromEntries(
|
|
||||||
Object.values(UNIFIED_FONTS)
|
|
||||||
.filter(f => f.category === 'serif')
|
|
||||||
.slice(0, 5)
|
|
||||||
.map(font => [font.id, font]),
|
|
||||||
),
|
|
||||||
total: 5,
|
|
||||||
page: 1,
|
|
||||||
isLoading: false,
|
|
||||||
category: 'serif',
|
|
||||||
}),
|
|
||||||
|
|
||||||
/** Filtered by provider */
|
|
||||||
filteredByProvider: createMockFontStoreState({
|
|
||||||
fonts: Object.fromEntries(
|
|
||||||
Object.values(UNIFIED_FONTS)
|
|
||||||
.filter(f => f.provider === 'google')
|
|
||||||
.slice(0, 5)
|
|
||||||
.map(font => [font.id, font]),
|
|
||||||
),
|
|
||||||
total: 5,
|
|
||||||
page: 1,
|
|
||||||
isLoading: false,
|
|
||||||
provider: 'google',
|
|
||||||
}),
|
|
||||||
|
|
||||||
/** Large dataset */
|
|
||||||
largeDataset: createMockFontStoreState({
|
|
||||||
fonts: Object.fromEntries(
|
|
||||||
generateMockFonts(50).map(font => [font.id, font]),
|
|
||||||
),
|
|
||||||
total: 500,
|
|
||||||
page: 1,
|
|
||||||
limit: 50,
|
|
||||||
totalPages: 10,
|
|
||||||
isLoading: false,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
// MOCK STORE OBJECT
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a mock store object that mimics TanStack Query behavior
|
|
||||||
* Useful for components that subscribe to store properties
|
|
||||||
*/
|
|
||||||
export function createMockStore<T>(config: {
|
|
||||||
data?: T;
|
|
||||||
isLoading?: boolean;
|
|
||||||
isError?: boolean;
|
|
||||||
error?: Error;
|
|
||||||
isFetching?: boolean;
|
|
||||||
}) {
|
|
||||||
const {
|
|
||||||
data,
|
|
||||||
isLoading = false,
|
|
||||||
isError = false,
|
|
||||||
error,
|
|
||||||
isFetching = false,
|
|
||||||
} = config;
|
|
||||||
|
|
||||||
return {
|
|
||||||
get data() {
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
get isLoading() {
|
|
||||||
return isLoading;
|
|
||||||
},
|
|
||||||
get isError() {
|
|
||||||
return isError;
|
|
||||||
},
|
|
||||||
get error() {
|
|
||||||
return error;
|
|
||||||
},
|
|
||||||
get isFetching() {
|
|
||||||
return isFetching;
|
|
||||||
},
|
|
||||||
get isSuccess() {
|
|
||||||
return !isLoading && !isError && data !== undefined;
|
|
||||||
},
|
|
||||||
get status() {
|
|
||||||
if (isLoading) return 'pending';
|
|
||||||
if (isError) return 'error';
|
|
||||||
return 'success';
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Preset mock stores
|
|
||||||
*/
|
|
||||||
export const MOCK_STORES = {
|
|
||||||
/** Font store in loading state */
|
|
||||||
loadingFontStore: createMockStore<UnifiedFont[]>({
|
|
||||||
isLoading: true,
|
|
||||||
data: undefined,
|
|
||||||
}),
|
|
||||||
|
|
||||||
/** Font store with fonts loaded */
|
|
||||||
successFontStore: createMockStore<UnifiedFont[]>({
|
|
||||||
data: Object.values(UNIFIED_FONTS),
|
|
||||||
isLoading: false,
|
|
||||||
isError: false,
|
|
||||||
}),
|
|
||||||
|
|
||||||
/** Font store with error */
|
|
||||||
errorFontStore: createMockStore<UnifiedFont[]>({
|
|
||||||
data: undefined,
|
|
||||||
isLoading: false,
|
|
||||||
isError: true,
|
|
||||||
error: new Error('Failed to load fonts'),
|
|
||||||
}),
|
|
||||||
|
|
||||||
/** Font store with empty results */
|
|
||||||
emptyFontStore: createMockStore<UnifiedFont[]>({
|
|
||||||
data: [],
|
|
||||||
isLoading: false,
|
|
||||||
isError: false,
|
|
||||||
}),
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a mock UnifiedFontStore-like object
|
|
||||||
* Note: This is a simplified mock for Storybook use
|
|
||||||
*/
|
|
||||||
unifiedFontStore: (state: Partial<MockFontStoreState> = {}) => {
|
|
||||||
const mockState = createMockFontStoreState(state);
|
|
||||||
return {
|
|
||||||
// State properties
|
|
||||||
get fonts() {
|
|
||||||
return mockState.fonts;
|
|
||||||
},
|
|
||||||
get page() {
|
|
||||||
return mockState.page;
|
|
||||||
},
|
|
||||||
get totalPages() {
|
|
||||||
return mockState.totalPages;
|
|
||||||
},
|
|
||||||
get limit() {
|
|
||||||
return mockState.limit;
|
|
||||||
},
|
|
||||||
get total() {
|
|
||||||
return mockState.total;
|
|
||||||
},
|
|
||||||
get isLoading() {
|
|
||||||
return mockState.isLoading;
|
|
||||||
},
|
|
||||||
get error() {
|
|
||||||
return mockState.error;
|
|
||||||
},
|
|
||||||
get searchQuery() {
|
|
||||||
return mockState.searchQuery;
|
|
||||||
},
|
|
||||||
get provider() {
|
|
||||||
return mockState.provider;
|
|
||||||
},
|
|
||||||
get category() {
|
|
||||||
return mockState.category;
|
|
||||||
},
|
|
||||||
get subset() {
|
|
||||||
return mockState.subset;
|
|
||||||
},
|
|
||||||
// Methods (no-op for Storybook)
|
|
||||||
nextPage: () => {},
|
|
||||||
prevPage: () => {},
|
|
||||||
goToPage: (_page: number) => {},
|
|
||||||
setLimit: (_limit: number) => {},
|
|
||||||
setProvider: (_provider: typeof mockState.provider) => {},
|
|
||||||
setCategory: (_category: string | null) => {},
|
|
||||||
setSubset: (_subset: string | null) => {},
|
|
||||||
setSearch: (_query: string) => {},
|
|
||||||
resetFilters: () => {},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
/**
|
|
||||||
* Create a mock FontStore object
|
|
||||||
* Matches FontStore's public API for Storybook use
|
|
||||||
*/
|
|
||||||
fontStore: (config: {
|
|
||||||
fonts?: UnifiedFont[];
|
|
||||||
total?: number;
|
|
||||||
limit?: number;
|
|
||||||
offset?: number;
|
|
||||||
isLoading?: boolean;
|
|
||||||
isFetching?: boolean;
|
|
||||||
isError?: boolean;
|
|
||||||
error?: Error | null;
|
|
||||||
hasMore?: boolean;
|
|
||||||
page?: number;
|
|
||||||
} = {}) => {
|
|
||||||
const {
|
|
||||||
fonts: mockFonts = Object.values(UNIFIED_FONTS).slice(0, 5),
|
|
||||||
total: mockTotal = mockFonts.length,
|
|
||||||
limit = 50,
|
|
||||||
offset = 0,
|
|
||||||
isLoading = false,
|
|
||||||
isFetching = false,
|
|
||||||
isError = false,
|
|
||||||
error = null,
|
|
||||||
hasMore = false,
|
|
||||||
page = 1,
|
|
||||||
} = config;
|
|
||||||
|
|
||||||
const totalPages = Math.ceil(mockTotal / limit);
|
|
||||||
const state = {
|
|
||||||
params: { limit },
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
// State getters
|
|
||||||
get params() {
|
|
||||||
return state.params;
|
|
||||||
},
|
|
||||||
get fonts() {
|
|
||||||
return mockFonts;
|
|
||||||
},
|
|
||||||
get isLoading() {
|
|
||||||
return isLoading;
|
|
||||||
},
|
|
||||||
get isFetching() {
|
|
||||||
return isFetching;
|
|
||||||
},
|
|
||||||
get isError() {
|
|
||||||
return isError;
|
|
||||||
},
|
|
||||||
get error() {
|
|
||||||
return error;
|
|
||||||
},
|
|
||||||
get isEmpty() {
|
|
||||||
return !isLoading && !isFetching && mockFonts.length === 0;
|
|
||||||
},
|
|
||||||
get pagination() {
|
|
||||||
return {
|
|
||||||
total: mockTotal,
|
|
||||||
limit,
|
|
||||||
offset,
|
|
||||||
hasMore,
|
|
||||||
page,
|
|
||||||
totalPages,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
// Category getters
|
|
||||||
get sansSerifFonts() {
|
|
||||||
return mockFonts.filter(f => f.category === 'sans-serif');
|
|
||||||
},
|
|
||||||
get serifFonts() {
|
|
||||||
return mockFonts.filter(f => f.category === 'serif');
|
|
||||||
},
|
|
||||||
get displayFonts() {
|
|
||||||
return mockFonts.filter(f => f.category === 'display');
|
|
||||||
},
|
|
||||||
get handwritingFonts() {
|
|
||||||
return mockFonts.filter(f => f.category === 'handwriting');
|
|
||||||
},
|
|
||||||
get monospaceFonts() {
|
|
||||||
return mockFonts.filter(f => f.category === 'monospace');
|
|
||||||
},
|
|
||||||
// Lifecycle
|
|
||||||
destroy() {},
|
|
||||||
// Param management
|
|
||||||
setParams(_updates: Record<string, unknown>) {},
|
|
||||||
invalidate() {},
|
|
||||||
// Async operations (no-op for Storybook)
|
|
||||||
refetch() {},
|
|
||||||
prefetch() {},
|
|
||||||
cancel() {},
|
|
||||||
getCachedData() {
|
|
||||||
return mockFonts.length > 0 ? mockFonts : undefined;
|
|
||||||
},
|
|
||||||
setQueryData() {},
|
|
||||||
// Filter shortcuts
|
|
||||||
setProviders() {},
|
|
||||||
setCategories() {},
|
|
||||||
setSubsets() {},
|
|
||||||
setSearch() {},
|
|
||||||
setSort() {},
|
|
||||||
// Pagination navigation
|
|
||||||
nextPage() {},
|
|
||||||
prevPage() {},
|
|
||||||
goToPage() {},
|
|
||||||
setLimit(_limit: number) {
|
|
||||||
state.params.limit = _limit;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// REACTIVE STATE MOCKS
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a reactive state object using Svelte 5 runes pattern
|
|
||||||
* Useful for stories that need reactive state
|
|
||||||
*
|
|
||||||
* Note: This uses plain JavaScript objects since Svelte runes
|
|
||||||
* only work in .svelte files. For Storybook, this provides
|
|
||||||
* a similar API for testing.
|
|
||||||
*/
|
|
||||||
export function createMockReactiveState<T>(initialValue: T) {
|
|
||||||
let value = initialValue;
|
|
||||||
|
|
||||||
return {
|
|
||||||
get value() {
|
|
||||||
return value;
|
|
||||||
},
|
|
||||||
set value(newValue: T) {
|
|
||||||
value = newValue;
|
|
||||||
},
|
|
||||||
update(fn: (current: T) => T) {
|
|
||||||
value = fn(value);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mock comparison store for ComparisonSlider component
|
|
||||||
*/
|
|
||||||
export function createMockComparisonStore(config: {
|
|
||||||
fontA?: UnifiedFont;
|
|
||||||
fontB?: UnifiedFont;
|
|
||||||
text?: string;
|
|
||||||
} = {}) {
|
|
||||||
const { fontA, fontB, text = 'The quick brown fox jumps over the lazy dog.' } = config;
|
|
||||||
|
|
||||||
return {
|
|
||||||
get fontA() {
|
|
||||||
return fontA ?? UNIFIED_FONTS.roboto;
|
|
||||||
},
|
|
||||||
get fontB() {
|
|
||||||
return fontB ?? UNIFIED_FONTS.openSans;
|
|
||||||
},
|
|
||||||
get text() {
|
|
||||||
return text;
|
|
||||||
},
|
|
||||||
// Methods (no-op for Storybook)
|
|
||||||
setFontA: (_font: UnifiedFont | undefined) => {},
|
|
||||||
setFontB: (_font: UnifiedFont | undefined) => {},
|
|
||||||
setText: (_text: string) => {},
|
|
||||||
swapFonts: () => {},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// MOCK DATA GENERATORS
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate paginated font data
|
|
||||||
*/
|
|
||||||
export function generatePaginatedFonts(
|
|
||||||
totalCount: number,
|
|
||||||
page: number,
|
|
||||||
limit: number,
|
|
||||||
): {
|
|
||||||
fonts: UnifiedFont[];
|
|
||||||
page: number;
|
|
||||||
totalPages: number;
|
|
||||||
total: number;
|
|
||||||
hasNextPage: boolean;
|
|
||||||
hasPrevPage: boolean;
|
|
||||||
} {
|
|
||||||
const totalPages = Math.ceil(totalCount / limit);
|
|
||||||
const startIndex = (page - 1) * limit;
|
|
||||||
const endIndex = Math.min(startIndex + limit, totalCount);
|
|
||||||
|
|
||||||
return {
|
|
||||||
fonts: generateMockFonts(endIndex - startIndex).map((font, i) => ({
|
|
||||||
...font,
|
|
||||||
id: `font-${startIndex + i + 1}`,
|
|
||||||
name: `Font ${startIndex + i + 1}`,
|
|
||||||
})),
|
|
||||||
page,
|
|
||||||
totalPages,
|
|
||||||
total: totalCount,
|
|
||||||
hasNextPage: page < totalPages,
|
|
||||||
hasPrevPage: page > 1,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create mock API response for fonts
|
|
||||||
*/
|
|
||||||
export function createMockFontApiResponse(config: {
|
|
||||||
fonts?: UnifiedFont[];
|
|
||||||
total?: number;
|
|
||||||
page?: number;
|
|
||||||
limit?: number;
|
|
||||||
} = {}) {
|
|
||||||
const fonts = config.fonts ?? Object.values(UNIFIED_FONTS);
|
|
||||||
const total = config.total ?? fonts.length;
|
|
||||||
const page = config.page ?? 1;
|
|
||||||
const limit = config.limit ?? fonts.length;
|
|
||||||
|
|
||||||
return {
|
|
||||||
data: fonts,
|
|
||||||
meta: {
|
|
||||||
total,
|
|
||||||
page,
|
|
||||||
limit,
|
|
||||||
totalPages: Math.ceil(total / limit),
|
|
||||||
hasNextPage: page < Math.ceil(total / limit),
|
|
||||||
hasPrevPage: page > 1,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
// @vitest-environment jsdom
|
|
||||||
import { TextLayoutEngine } from '$shared/lib';
|
|
||||||
import { installCanvasMock } from '$shared/lib/helpers/__mocks__/canvas';
|
|
||||||
import { clearCache } from '@chenglou/pretext';
|
|
||||||
import {
|
|
||||||
beforeEach,
|
|
||||||
describe,
|
|
||||||
expect,
|
|
||||||
it,
|
|
||||||
vi,
|
|
||||||
} from 'vitest';
|
|
||||||
import type { FontLoadStatus } from '../../model/types';
|
|
||||||
import { mockUnifiedFont } from '../mocks';
|
|
||||||
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.spyOn(TextLayoutEngine.prototype, 'layout');
|
|
||||||
|
|
||||||
resolver(0);
|
|
||||||
resolver(0);
|
|
||||||
|
|
||||||
expect(layoutSpy).toHaveBeenCalledTimes(1);
|
|
||||||
layoutSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
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.spyOn(TextLayoutEngine.prototype, 'layout');
|
|
||||||
|
|
||||||
resolver(0);
|
|
||||||
width = 100;
|
|
||||||
resolver(0);
|
|
||||||
|
|
||||||
expect(layoutSpy).toHaveBeenCalledTimes(2);
|
|
||||||
layoutSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
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,112 +0,0 @@
|
|||||||
import { TextLayoutEngine } from '$shared/lib';
|
|
||||||
import { generateFontKey } from '../../model/store/appliedFontsStore/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) => appliedFontsManager.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 `TextLayoutEngine.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 {
|
|
||||||
const engine = new TextLayoutEngine();
|
|
||||||
// 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 appliedFontsManager.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;
|
|
||||||
|
|
||||||
const { totalHeight } = engine.layout(previewText, fontCssString, contentWidth, lineHeightPx);
|
|
||||||
const result = totalHeight + options.chromeHeight;
|
|
||||||
cache.set(cacheKey, result);
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export * from './store';
|
|
||||||
export * from './types';
|
|
||||||
@@ -1,332 +0,0 @@
|
|||||||
/** @vitest-environment jsdom */
|
|
||||||
import { AppliedFontsManager } from './appliedFontsStore.svelte';
|
|
||||||
import { FontFetchError } from './errors';
|
|
||||||
import { FontEvictionPolicy } from './utils/fontEvictionPolicy/FontEvictionPolicy';
|
|
||||||
|
|
||||||
// ── Fake collaborators ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
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 {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const makeConfig = (id: string, overrides: Partial<{ weight: number; isVariable: boolean }> = {}) => ({
|
|
||||||
id,
|
|
||||||
name: id,
|
|
||||||
url: `https://example.com/${id}.woff2`,
|
|
||||||
weight: 400,
|
|
||||||
...overrides,
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Suite ─────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('AppliedFontsManager', () => {
|
|
||||||
let manager: AppliedFontsManager;
|
|
||||||
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 AppliedFontsManager({ cache: new FakeBufferCache() as any, eviction });
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.clearAllTimers();
|
|
||||||
vi.useRealTimers();
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── touch() ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
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 AppliedFontsManager({ 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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── queue processing ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
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;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Phase 1: fetch ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('Phase 1 — fetch', () => {
|
|
||||||
it('sets status to error on fetch failure', async () => {
|
|
||||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
||||||
const failManager = new AppliedFontsManager({ 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 AppliedFontsManager({ 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 AppliedFontsManager({ 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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Phase 2: parse ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── #purgeUnused ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── destroy() ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
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,379 +0,0 @@
|
|||||||
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';
|
|
||||||
|
|
||||||
interface AppliedFontsManagerDeps {
|
|
||||||
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 AppliedFontsManager {
|
|
||||||
// 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;
|
|
||||||
|
|
||||||
readonly #PURGE_INTERVAL = 60000;
|
|
||||||
|
|
||||||
// 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() }:
|
|
||||||
AppliedFontsManagerDeps = {},
|
|
||||||
) {
|
|
||||||
// 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(), this.#PURGE_INTERVAL);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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: 150 },
|
|
||||||
) as unknown as ReturnType<typeof setTimeout>;
|
|
||||||
this.#pendingType = 'idle';
|
|
||||||
} else {
|
|
||||||
this.#timeoutId = setTimeout(() => this.#processQueue(), 16);
|
|
||||||
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 || [400, 700].includes(c.weight));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine optimal concurrent fetches based on network speed (1-4)
|
|
||||||
const concurrency = getEffectiveConcurrency();
|
|
||||||
const buffers = new Map<string, ArrayBuffer>();
|
|
||||||
|
|
||||||
// ==================== PHASE 1: Concurrent Fetching ====================
|
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== PHASE 2: Sequential Parsing ====================
|
|
||||||
// Parse buffers one at a time with periodic yields to avoid blocking UI
|
|
||||||
const hasInputPending = !!(navigator as any).scheduling?.isInputPending;
|
|
||||||
let lastYield = performance.now();
|
|
||||||
const YIELD_INTERVAL = 8;
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Singleton instance — use throughout the application for unified font loading state. */
|
|
||||||
export const appliedFontsManager = new AppliedFontsManager();
|
|
||||||
@@ -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,66 +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,97 +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,76 +0,0 @@
|
|||||||
interface FontEvictionPolicyOptions {
|
|
||||||
/** TTL in milliseconds. Defaults to 5 minutes. */
|
|
||||||
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 = 5 * 60 * 1000 }: 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,57 +0,0 @@
|
|||||||
import type { FontLoadRequestConfig } from '../../../../types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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>();
|
|
||||||
|
|
||||||
readonly #MAX_RETRIES = 3;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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) >= this.#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,93 +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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
/**
|
|
||||||
* Yields to main thread during CPU-intensive parsing. Uses scheduler.yield() where available or MessageChannel fallback.
|
|
||||||
*/
|
|
||||||
export async function yieldToMainThread(): Promise<void> {
|
|
||||||
// @ts-expect-error - scheduler not in TypeScript lib yet
|
|
||||||
if (typeof scheduler !== 'undefined' && 'yield' in scheduler) {
|
|
||||||
// @ts-expect-error - scheduler.yield not in TypeScript lib yet
|
|
||||||
await scheduler.yield();
|
|
||||||
} else {
|
|
||||||
await new Promise<void>(resolve => {
|
|
||||||
const ch = new MessageChannel();
|
|
||||||
ch.port1.onmessage = () => resolve();
|
|
||||||
ch.port2.postMessage(null);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
import { fontKeys } from '$shared/api/queryKeys';
|
|
||||||
import { BaseQueryStore } from '$shared/lib/helpers/BaseQueryStore.svelte';
|
|
||||||
import {
|
|
||||||
fetchFontsByIds,
|
|
||||||
seedFontCache,
|
|
||||||
} from '../../api/proxy/proxyFonts';
|
|
||||||
import {
|
|
||||||
FontNetworkError,
|
|
||||||
FontResponseError,
|
|
||||||
} from '../../lib/errors/errors';
|
|
||||||
import type { UnifiedFont } from '../../model/types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Internal fetcher that seeds the cache and handles error wrapping.
|
|
||||||
* Standalone function to avoid 'this' issues during construction.
|
|
||||||
*/
|
|
||||||
async function fetchAndSeed(ids: string[]): Promise<UnifiedFont[]> {
|
|
||||||
if (ids.length === 0) return [];
|
|
||||||
|
|
||||||
let response: UnifiedFont[];
|
|
||||||
try {
|
|
||||||
response = await fetchFontsByIds(ids);
|
|
||||||
} catch (cause) {
|
|
||||||
throw new FontNetworkError(cause);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response || !Array.isArray(response)) {
|
|
||||||
throw new FontResponseError('batchResponse', response);
|
|
||||||
}
|
|
||||||
|
|
||||||
seedFontCache(response);
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reactive store for fetching and caching batches of fonts by ID.
|
|
||||||
* Integrates with TanStack Query via BaseQueryStore and handles
|
|
||||||
* normalized cache seeding.
|
|
||||||
*/
|
|
||||||
export class BatchFontStore extends BaseQueryStore<UnifiedFont[]> {
|
|
||||||
constructor(initialIds: string[] = []) {
|
|
||||||
super({
|
|
||||||
queryKey: fontKeys.batch(initialIds),
|
|
||||||
queryFn: () => fetchAndSeed(initialIds),
|
|
||||||
enabled: initialIds.length > 0,
|
|
||||||
retry: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates the IDs to fetch. Triggers a new query.
|
|
||||||
*
|
|
||||||
* @param ids - Array of font IDs
|
|
||||||
*/
|
|
||||||
setIds(ids: string[]): void {
|
|
||||||
this.updateOptions({
|
|
||||||
queryKey: fontKeys.batch(ids),
|
|
||||||
queryFn: () => fetchAndSeed(ids),
|
|
||||||
enabled: ids.length > 0,
|
|
||||||
retry: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Array of fetched fonts
|
|
||||||
*/
|
|
||||||
get fonts(): UnifiedFont[] {
|
|
||||||
return this.result.data ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether the query is currently loading
|
|
||||||
*/
|
|
||||||
get isLoading(): boolean {
|
|
||||||
return this.result.isLoading;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether the query encountered an error
|
|
||||||
*/
|
|
||||||
get isError(): boolean {
|
|
||||||
return this.result.isError;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The error object if the query failed
|
|
||||||
*/
|
|
||||||
get error(): Error | null {
|
|
||||||
return (this.result.error as Error) ?? null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
import { queryClient } from '$shared/api/queryClient';
|
|
||||||
import { fontKeys } from '$shared/api/queryKeys';
|
|
||||||
import {
|
|
||||||
beforeEach,
|
|
||||||
describe,
|
|
||||||
expect,
|
|
||||||
it,
|
|
||||||
vi,
|
|
||||||
} from 'vitest';
|
|
||||||
import * as api from '../../api/proxy/proxyFonts';
|
|
||||||
import {
|
|
||||||
FontNetworkError,
|
|
||||||
FontResponseError,
|
|
||||||
} from '../../lib/errors/errors';
|
|
||||||
import { BatchFontStore } from './batchFontStore.svelte';
|
|
||||||
|
|
||||||
describe('BatchFontStore', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
queryClient.clear();
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Fetch Behavior', () => {
|
|
||||||
it('should skip fetch when initialized with empty IDs', async () => {
|
|
||||||
const spy = vi.spyOn(api, 'fetchFontsByIds');
|
|
||||||
const store = new BatchFontStore([]);
|
|
||||||
expect(spy).not.toHaveBeenCalled();
|
|
||||||
expect(store.fonts).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should fetch and seed cache for valid IDs', async () => {
|
|
||||||
const fonts = [{ id: 'a', name: 'A' }] as any[];
|
|
||||||
vi.spyOn(api, 'fetchFontsByIds').mockResolvedValue(fonts);
|
|
||||||
const store = new BatchFontStore(['a']);
|
|
||||||
await vi.waitFor(() => expect(store.fonts).toEqual(fonts), { timeout: 1000 });
|
|
||||||
expect(queryClient.getQueryData(fontKeys.detail('a'))).toEqual(fonts[0]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Loading States', () => {
|
|
||||||
it('should transition through loading state', async () => {
|
|
||||||
vi.spyOn(api, 'fetchFontsByIds').mockImplementation(() =>
|
|
||||||
new Promise(r => setTimeout(() => r([{ id: 'a' }] as any), 50))
|
|
||||||
);
|
|
||||||
const store = new BatchFontStore(['a']);
|
|
||||||
expect(store.isLoading).toBe(true);
|
|
||||||
await vi.waitFor(() => expect(store.isLoading).toBe(false), { timeout: 1000 });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Error Handling', () => {
|
|
||||||
it('should wrap network failures in FontNetworkError', async () => {
|
|
||||||
vi.spyOn(api, 'fetchFontsByIds').mockRejectedValue(new Error('Network fail'));
|
|
||||||
const store = new BatchFontStore(['a']);
|
|
||||||
await vi.waitFor(() => expect(store.isError).toBe(true), { timeout: 1000 });
|
|
||||||
expect(store.error).toBeInstanceOf(FontNetworkError);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle malformed API responses with FontResponseError', async () => {
|
|
||||||
// Mocking a malformed response that the store should validate
|
|
||||||
vi.spyOn(api, 'fetchFontsByIds').mockResolvedValue(null as any);
|
|
||||||
const store = new BatchFontStore(['a']);
|
|
||||||
await vi.waitFor(() => expect(store.isError).toBe(true), { timeout: 1000 });
|
|
||||||
expect(store.error).toBeInstanceOf(FontResponseError);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have null error in success state', async () => {
|
|
||||||
const fonts = [{ id: 'a' }] as any[];
|
|
||||||
vi.spyOn(api, 'fetchFontsByIds').mockResolvedValue(fonts);
|
|
||||||
const store = new BatchFontStore(['a']);
|
|
||||||
await vi.waitFor(() => expect(store.fonts).toEqual(fonts), { timeout: 1000 });
|
|
||||||
expect(store.error).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Disable Behavior', () => {
|
|
||||||
it('should return empty fonts and not fetch when setIds is called with empty array', async () => {
|
|
||||||
const fonts1 = [{ id: 'a' }] as any[];
|
|
||||||
const spy = vi.spyOn(api, 'fetchFontsByIds').mockResolvedValueOnce(fonts1);
|
|
||||||
|
|
||||||
const store = new BatchFontStore(['a']);
|
|
||||||
await vi.waitFor(() => expect(store.fonts).toEqual(fonts1), { timeout: 1000 });
|
|
||||||
|
|
||||||
spy.mockClear();
|
|
||||||
store.setIds([]);
|
|
||||||
|
|
||||||
await vi.waitFor(() => expect(store.fonts).toEqual([]), { timeout: 1000 });
|
|
||||||
expect(spy).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Reactivity', () => {
|
|
||||||
it('should refetch when setIds is called', async () => {
|
|
||||||
const fonts1 = [{ id: 'a' }] as any[];
|
|
||||||
const fonts2 = [{ id: 'b' }] as any[];
|
|
||||||
vi.spyOn(api, 'fetchFontsByIds')
|
|
||||||
.mockResolvedValueOnce(fonts1)
|
|
||||||
.mockResolvedValueOnce(fonts2);
|
|
||||||
|
|
||||||
const store = new BatchFontStore(['a']);
|
|
||||||
await vi.waitFor(() => expect(store.fonts).toEqual(fonts1), { timeout: 1000 });
|
|
||||||
|
|
||||||
store.setIds(['b']);
|
|
||||||
await vi.waitFor(() => expect(store.fonts).toEqual(fonts2), { timeout: 1000 });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,583 +0,0 @@
|
|||||||
import { QueryClient } from '@tanstack/query-core';
|
|
||||||
import { flushSync } from 'svelte';
|
|
||||||
import {
|
|
||||||
afterEach,
|
|
||||||
beforeEach,
|
|
||||||
describe,
|
|
||||||
expect,
|
|
||||||
it,
|
|
||||||
vi,
|
|
||||||
} from 'vitest';
|
|
||||||
import {
|
|
||||||
FontNetworkError,
|
|
||||||
FontResponseError,
|
|
||||||
} from '../../../lib/errors/errors';
|
|
||||||
import {
|
|
||||||
generateMixedCategoryFonts,
|
|
||||||
generateMockFonts,
|
|
||||||
} from '../../../lib/mocks/fonts.mock';
|
|
||||||
import type { UnifiedFont } from '../../types';
|
|
||||||
import { FontStore } from './fontStore.svelte';
|
|
||||||
|
|
||||||
vi.mock('$shared/api/queryClient', () => ({
|
|
||||||
queryClient: new QueryClient({
|
|
||||||
defaultOptions: { queries: { retry: 0, gcTime: 0 } },
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
vi.mock('../../../api', () => ({ fetchProxyFonts: vi.fn() }));
|
|
||||||
|
|
||||||
import { queryClient } from '$shared/api/queryClient';
|
|
||||||
import { fetchProxyFonts } from '../../../api';
|
|
||||||
|
|
||||||
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 FontStore({ 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('FontStore', () => {
|
|
||||||
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 FontStore();
|
|
||||||
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 — initial fetch is in progress', () => {
|
|
||||||
// The observer starts fetching immediately on construction.
|
|
||||||
// isEmpty must be false so the UI shows a loader, not "no results".
|
|
||||||
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: FontStore;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
fetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 30, limit: 10, offset: 0 }));
|
|
||||||
store = new FontStore({ 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 FontStore({ 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 FontStore({ 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: FontStore;
|
|
||||||
|
|
||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,283 +0,0 @@
|
|||||||
import { queryClient } from '$shared/api/queryClient';
|
|
||||||
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 FontStore {
|
|
||||||
#params = $state<FontStoreParams>({ limit: 50 });
|
|
||||||
#result = $state<FontStoreResult>({} as FontStoreResult);
|
|
||||||
#observer: InfiniteQueryObserver<
|
|
||||||
ProxyFontsResponse,
|
|
||||||
Error,
|
|
||||||
InfiniteData<ProxyFontsResponse, PageParam>,
|
|
||||||
readonly unknown[],
|
|
||||||
PageParam
|
|
||||||
>;
|
|
||||||
#qc = queryClient;
|
|
||||||
#unsubscribe: () => void;
|
|
||||||
|
|
||||||
constructor(params: FontStoreParams = {}) {
|
|
||||||
this.#params = { limit: 50, ...params };
|
|
||||||
this.#observer = new InfiniteQueryObserver(this.#qc, this.buildOptions());
|
|
||||||
this.#unsubscribe = this.#observer.subscribe(r => {
|
|
||||||
this.#result = r;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Public state --
|
|
||||||
|
|
||||||
get params(): FontStoreParams {
|
|
||||||
return this.#params;
|
|
||||||
}
|
|
||||||
get fonts(): UnifiedFont[] {
|
|
||||||
return this.#result.data?.pages.flatMap((p: ProxyFontsResponse) => p.fonts) ?? [];
|
|
||||||
}
|
|
||||||
get isLoading(): boolean {
|
|
||||||
return this.#result.isLoading;
|
|
||||||
}
|
|
||||||
get isFetching(): boolean {
|
|
||||||
return this.#result.isFetching;
|
|
||||||
}
|
|
||||||
get isError(): boolean {
|
|
||||||
return this.#result.isError;
|
|
||||||
}
|
|
||||||
|
|
||||||
get error(): Error | null {
|
|
||||||
return this.#result.error ?? null;
|
|
||||||
}
|
|
||||||
// isEmpty is false during loading/fetching so the UI never flashes "no results"
|
|
||||||
// while a fetch is in progress. The !isFetching guard is specifically for the filter-change
|
|
||||||
// transition: fonts clear synchronously → isFetching becomes true → isEmpty stays false.
|
|
||||||
get isEmpty(): boolean {
|
|
||||||
return !this.isLoading && !this.isFetching && this.fonts.length === 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
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),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Lifecycle --
|
|
||||||
|
|
||||||
destroy() {
|
|
||||||
this.#unsubscribe();
|
|
||||||
this.#observer.destroy();
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Param management --
|
|
||||||
|
|
||||||
setParams(updates: Partial<FontStoreParams>) {
|
|
||||||
this.#params = { ...this.#params, ...updates };
|
|
||||||
this.#observer.setOptions(this.buildOptions());
|
|
||||||
}
|
|
||||||
invalidate() {
|
|
||||||
this.#qc.invalidateQueries({ queryKey: this.buildQueryKey(this.#params) });
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Async operations --
|
|
||||||
|
|
||||||
async refetch() {
|
|
||||||
await this.#observer.refetch();
|
|
||||||
}
|
|
||||||
|
|
||||||
async prefetch(params: FontStoreParams) {
|
|
||||||
await this.#qc.prefetchInfiniteQuery(this.buildOptions(params));
|
|
||||||
}
|
|
||||||
|
|
||||||
cancel() {
|
|
||||||
this.#qc.cancelQueries({ queryKey: this.buildQueryKey(this.#params) });
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 }],
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Filter shortcuts --
|
|
||||||
|
|
||||||
setProviders(v: ProxyFontsParams['providers']) {
|
|
||||||
this.setParams({ providers: v });
|
|
||||||
}
|
|
||||||
setCategories(v: ProxyFontsParams['categories']) {
|
|
||||||
this.setParams({ categories: v });
|
|
||||||
}
|
|
||||||
setSubsets(v: ProxyFontsParams['subsets']) {
|
|
||||||
this.setParams({ subsets: v });
|
|
||||||
}
|
|
||||||
setSearch(v: string) {
|
|
||||||
this.setParams({ q: v || undefined });
|
|
||||||
}
|
|
||||||
setSort(v: ProxyFontsParams['sort']) {
|
|
||||||
this.setParams({ sort: v });
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Pagination navigation --
|
|
||||||
|
|
||||||
async nextPage(): Promise<void> {
|
|
||||||
await this.#observer.fetchNextPage();
|
|
||||||
}
|
|
||||||
prevPage(): void {} // no-op: infinite scroll accumulates forward only; method kept for API compatibility
|
|
||||||
goToPage(_page: number): void {} // no-op
|
|
||||||
|
|
||||||
setLimit(limit: number) {
|
|
||||||
this.setParams({ limit });
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Category views --
|
|
||||||
|
|
||||||
get sansSerifFonts() {
|
|
||||||
return this.fonts.filter(f => f.category === 'sans-serif');
|
|
||||||
}
|
|
||||||
get serifFonts() {
|
|
||||||
return this.fonts.filter(f => f.category === 'serif');
|
|
||||||
}
|
|
||||||
get displayFonts() {
|
|
||||||
return this.fonts.filter(f => f.category === 'display');
|
|
||||||
}
|
|
||||||
get handwritingFonts() {
|
|
||||||
return this.fonts.filter(f => f.category === 'handwriting');
|
|
||||||
}
|
|
||||||
get monospaceFonts() {
|
|
||||||
return this.fonts.filter(f => f.category === 'monospace');
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Private helpers (TypeScript-private so tests can spy via `as any`) --
|
|
||||||
|
|
||||||
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;
|
|
||||||
},
|
|
||||||
staleTime: hasFilters ? 0 : 5 * 60 * 1000,
|
|
||||||
gcTime: 10 * 60 * 1000,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private async fetchPage(params: ProxyFontsParams): Promise<ProxyFontsResponse> {
|
|
||||||
let response: ProxyFontsResponse;
|
|
||||||
try {
|
|
||||||
response = await fetchProxyFonts(params);
|
|
||||||
} catch (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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createFontStore(params: FontStoreParams = {}): FontStore {
|
|
||||||
return new FontStore(params);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const fontStore = new FontStore({ limit: 50 });
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
// Applied fonts manager
|
|
||||||
export { appliedFontsManager } from './appliedFontsStore/appliedFontsStore.svelte';
|
|
||||||
|
|
||||||
// Batch font store
|
|
||||||
export { BatchFontStore } from './batchFontStore.svelte';
|
|
||||||
|
|
||||||
// Single FontStore
|
|
||||||
export {
|
|
||||||
createFontStore,
|
|
||||||
FontStore,
|
|
||||||
fontStore,
|
|
||||||
} from './fontStore/fontStore.svelte';
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
/**
|
|
||||||
* Font domain types
|
|
||||||
*
|
|
||||||
* Shared types for font entities across providers (Google, Fontshare).
|
|
||||||
* Includes categories, subsets, weights, and the unified font model.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unified font category across all providers
|
|
||||||
*/
|
|
||||||
export type FontCategory =
|
|
||||||
| 'sans-serif'
|
|
||||||
| 'serif'
|
|
||||||
| 'display'
|
|
||||||
| 'handwriting'
|
|
||||||
| 'monospace'
|
|
||||||
| 'slab'
|
|
||||||
| 'script';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Font provider identifier
|
|
||||||
*/
|
|
||||||
export type FontProvider = 'google' | 'fontshare';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Character subset support
|
|
||||||
*/
|
|
||||||
export type FontSubset = 'latin' | 'latin-ext' | 'cyrillic' | 'greek' | 'arabic' | 'devanagari';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Combined filter state for font queries
|
|
||||||
*/
|
|
||||||
export interface FontFilters {
|
|
||||||
/** Selected font providers */
|
|
||||||
providers: FontProvider[];
|
|
||||||
/** Selected font categories */
|
|
||||||
categories: FontCategory[];
|
|
||||||
/** Selected character subsets */
|
|
||||||
subsets: FontSubset[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Filter group identifier */
|
|
||||||
export type FilterGroup = 'providers' | 'categories' | 'subsets';
|
|
||||||
|
|
||||||
/** Filter type including search query */
|
|
||||||
export type FilterType = FilterGroup | 'searchQuery';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Numeric font weights (100-900)
|
|
||||||
*/
|
|
||||||
export type FontWeight = '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Italic variant with weight: "100italic", "400italic", "700italic", etc.
|
|
||||||
*/
|
|
||||||
export type FontWeightItalic = `${FontWeight}italic`;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* All possible font variant identifiers
|
|
||||||
*
|
|
||||||
* Includes:
|
|
||||||
* - Numeric weights: "400", "700", etc.
|
|
||||||
* - Italic variants: "400italic", "700italic", etc.
|
|
||||||
* - Legacy names: "regular", "italic", "bold", "bolditalic"
|
|
||||||
*/
|
|
||||||
export type FontVariant =
|
|
||||||
| FontWeight
|
|
||||||
| FontWeightItalic
|
|
||||||
| 'regular'
|
|
||||||
| 'italic'
|
|
||||||
| 'bold'
|
|
||||||
| 'bolditalic';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Standardized font variant alias
|
|
||||||
*/
|
|
||||||
export type UnifiedFontVariant = FontVariant;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Font style URLs
|
|
||||||
*/
|
|
||||||
export interface FontStyleUrls {
|
|
||||||
/** Regular weight URL */
|
|
||||||
regular?: string;
|
|
||||||
/** Italic URL */
|
|
||||||
italic?: string;
|
|
||||||
/** Bold weight URL */
|
|
||||||
bold?: string;
|
|
||||||
/** Bold italic URL */
|
|
||||||
boldItalic?: string;
|
|
||||||
/** Additional variant mapping */
|
|
||||||
variants?: Partial<Record<UnifiedFontVariant, string>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Font metadata
|
|
||||||
*/
|
|
||||||
export interface FontMetadata {
|
|
||||||
/** Timestamp when font was cached */
|
|
||||||
cachedAt: number;
|
|
||||||
/** Font version from provider */
|
|
||||||
version?: string;
|
|
||||||
/** Last modified date from provider */
|
|
||||||
lastModified?: string;
|
|
||||||
/** Popularity rank (if available from provider) */
|
|
||||||
popularity?: number;
|
|
||||||
/**
|
|
||||||
* Normalized popularity score (0-100)
|
|
||||||
*
|
|
||||||
* Normalized across all fonts for consistent ranking
|
|
||||||
* Higher values indicate more popular fonts
|
|
||||||
*/
|
|
||||||
popularityScore?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Font features (variable fonts, axes, tags)
|
|
||||||
*/
|
|
||||||
export interface FontFeatures {
|
|
||||||
/** Whether this is a variable font */
|
|
||||||
isVariable?: boolean;
|
|
||||||
/** Variable font axes (for Fontshare) */
|
|
||||||
axes?: Array<{
|
|
||||||
name: string;
|
|
||||||
property: string;
|
|
||||||
default: number;
|
|
||||||
min: number;
|
|
||||||
max: number;
|
|
||||||
}>;
|
|
||||||
/** Usage tags (for Fontshare) */
|
|
||||||
tags?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unified font model
|
|
||||||
*
|
|
||||||
* Combines Google Fonts and Fontshare data into a common interface
|
|
||||||
* for consistent font handling across the application.
|
|
||||||
*/
|
|
||||||
export interface UnifiedFont {
|
|
||||||
/** Unique identifier (Google: family name, Fontshare: slug) */
|
|
||||||
id: string;
|
|
||||||
/** Font display name */
|
|
||||||
name: string;
|
|
||||||
/** Font provider (google | fontshare) */
|
|
||||||
provider: FontProvider;
|
|
||||||
/**
|
|
||||||
* Provider badge display name
|
|
||||||
*
|
|
||||||
* Human-readable provider name for UI display
|
|
||||||
* e.g., "Google Fonts" or "Fontshare"
|
|
||||||
*/
|
|
||||||
providerBadge?: string;
|
|
||||||
/** Font category classification */
|
|
||||||
category: FontCategory;
|
|
||||||
/** Supported character subsets */
|
|
||||||
subsets: FontSubset[];
|
|
||||||
/** Available font variants (weights, styles) */
|
|
||||||
variants: UnifiedFontVariant[];
|
|
||||||
/** URL mapping for font file downloads */
|
|
||||||
styles: FontStyleUrls;
|
|
||||||
/** Additional metadata */
|
|
||||||
metadata: FontMetadata;
|
|
||||||
/** Advanced font features */
|
|
||||||
features: FontFeatures;
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
/**
|
|
||||||
* ============================================================================
|
|
||||||
* SINGLE EXPORT POINT
|
|
||||||
* ============================================================================
|
|
||||||
*
|
|
||||||
* This is the single export point for all Font types.
|
|
||||||
* All imports should use: `import { X } from '$entities/Font/model/types'`
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Font domain and model types
|
|
||||||
export type {
|
|
||||||
FilterGroup,
|
|
||||||
FilterType,
|
|
||||||
FontCategory,
|
|
||||||
FontFeatures,
|
|
||||||
FontFilters,
|
|
||||||
FontMetadata,
|
|
||||||
FontProvider,
|
|
||||||
FontStyleUrls,
|
|
||||||
FontSubset,
|
|
||||||
FontVariant,
|
|
||||||
FontWeight,
|
|
||||||
FontWeightItalic,
|
|
||||||
UnifiedFont,
|
|
||||||
UnifiedFontVariant,
|
|
||||||
} from './font';
|
|
||||||
|
|
||||||
// Store types
|
|
||||||
export type {
|
|
||||||
FontCollectionFilters,
|
|
||||||
FontCollectionSort,
|
|
||||||
FontCollectionState,
|
|
||||||
} from './store';
|
|
||||||
|
|
||||||
export * from './store/appliedFonts';
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
/**
|
|
||||||
* ============================================================================
|
|
||||||
* STORE TYPES
|
|
||||||
* ============================================================================
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type {
|
|
||||||
FontCategory,
|
|
||||||
FontProvider,
|
|
||||||
FontSubset,
|
|
||||||
UnifiedFont,
|
|
||||||
} from './font';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Font collection state
|
|
||||||
*/
|
|
||||||
export interface FontCollectionState {
|
|
||||||
/** All cached fonts */
|
|
||||||
fonts: Record<string, UnifiedFont>;
|
|
||||||
/** Active filters */
|
|
||||||
filters: FontCollectionFilters;
|
|
||||||
/** Sort configuration */
|
|
||||||
sort: FontCollectionSort;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Font collection filters
|
|
||||||
*/
|
|
||||||
export interface FontCollectionFilters {
|
|
||||||
/** Search query */
|
|
||||||
searchQuery: string;
|
|
||||||
/** Filter by providers */
|
|
||||||
providers?: FontProvider[];
|
|
||||||
/** Filter by categories */
|
|
||||||
categories?: FontCategory[];
|
|
||||||
/** Filter by subsets */
|
|
||||||
subsets?: FontSubset[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Font collection sort configuration
|
|
||||||
*/
|
|
||||||
export interface FontCollectionSort {
|
|
||||||
/** Sort field */
|
|
||||||
field: 'name' | 'popularity' | 'category';
|
|
||||||
/** Sort direction */
|
|
||||||
direction: 'asc' | 'desc';
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
/**
|
|
||||||
* Configuration for a font load request.
|
|
||||||
*/
|
|
||||||
export interface FontLoadRequestConfig {
|
|
||||||
/**
|
|
||||||
* Unique identifier for the font (e.g., "lato", "roboto").
|
|
||||||
*/
|
|
||||||
id: string;
|
|
||||||
/**
|
|
||||||
* Actual font family name recognized by the browser (e.g., "Lato", "Roboto").
|
|
||||||
*/
|
|
||||||
name: string;
|
|
||||||
/**
|
|
||||||
* URL pointing to the font file (typically .ttf or .woff2).
|
|
||||||
*/
|
|
||||||
url: string;
|
|
||||||
/**
|
|
||||||
* Numeric weight (100-900). Variable fonts load once per ID regardless of weight.
|
|
||||||
*/
|
|
||||||
weight: number;
|
|
||||||
/**
|
|
||||||
* Variable fonts load once per ID; static fonts load per weight.
|
|
||||||
*/
|
|
||||||
isVariable?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Loading state of a font.
|
|
||||||
*/
|
|
||||||
export type FontLoadStatus = 'loading' | 'loaded' | 'error';
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
<!--
|
|
||||||
Component: FontApplicator
|
|
||||||
Loads fonts from fontshare with link tag
|
|
||||||
- Loads font only if it's not already applied
|
|
||||||
- Reacts to font load status to show/hide content
|
|
||||||
- Adds smooth transition when font appears
|
|
||||||
-->
|
|
||||||
<script lang="ts">
|
|
||||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
|
||||||
import type { Snippet } from 'svelte';
|
|
||||||
import { prefersReducedMotion } from 'svelte/motion';
|
|
||||||
import {
|
|
||||||
type UnifiedFont,
|
|
||||||
appliedFontsManager,
|
|
||||||
} from '../../model';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
/**
|
|
||||||
* Font to apply
|
|
||||||
*/
|
|
||||||
font: UnifiedFont;
|
|
||||||
/**
|
|
||||||
* Font weight
|
|
||||||
* @default 400
|
|
||||||
*/
|
|
||||||
weight?: number;
|
|
||||||
/**
|
|
||||||
* CSS classes
|
|
||||||
*/
|
|
||||||
className?: string;
|
|
||||||
/**
|
|
||||||
* Content snippet
|
|
||||||
*/
|
|
||||||
children?: Snippet;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
font,
|
|
||||||
weight = 400,
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
const status = $derived(
|
|
||||||
appliedFontsManager.getFontStatus(
|
|
||||||
font.id,
|
|
||||||
weight,
|
|
||||||
font.features?.isVariable,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// The "Show" condition: Font is loaded OR it errored out OR it's a noTouch preview (like in search)
|
|
||||||
const shouldReveal = $derived(status === 'loaded' || status === 'error');
|
|
||||||
|
|
||||||
const transitionClasses = $derived(
|
|
||||||
prefersReducedMotion.current
|
|
||||||
? 'transition-none' // Disable CSS transitions if motion is reduced
|
|
||||||
: 'transition-all duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]',
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style:font-family={shouldReveal
|
|
||||||
? `'${font.name}'`
|
|
||||||
: 'system-ui, -apple-system, sans-serif'}
|
|
||||||
class={cn(
|
|
||||||
transitionClasses,
|
|
||||||
// If reduced motion is on, we skip the transform/blur entirely
|
|
||||||
!shouldReveal
|
|
||||||
&& !prefersReducedMotion.current
|
|
||||||
&& 'opacity-50 scale-[0.95] blur-sm',
|
|
||||||
!shouldReveal && prefersReducedMotion.current && 'opacity-0', // Still hide until font is ready, but no movement
|
|
||||||
shouldReveal && 'opacity-100 scale-100 blur-0',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{@render children?.()}
|
|
||||||
</div>
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
<!--
|
|
||||||
Component: FontVirtualList
|
|
||||||
- Renders a virtualized list of fonts
|
|
||||||
- Handles font registration with the manager
|
|
||||||
-->
|
|
||||||
<script lang="ts">
|
|
||||||
import {
|
|
||||||
Skeleton,
|
|
||||||
VirtualList,
|
|
||||||
} from '$shared/ui';
|
|
||||||
import type {
|
|
||||||
ComponentProps,
|
|
||||||
Snippet,
|
|
||||||
} from 'svelte';
|
|
||||||
import { fade } from 'svelte/transition';
|
|
||||||
import { getFontUrl } from '../../lib';
|
|
||||||
import {
|
|
||||||
type FontLoadRequestConfig,
|
|
||||||
type UnifiedFont,
|
|
||||||
appliedFontsManager,
|
|
||||||
} from '../../model';
|
|
||||||
import { fontStore } from '../../model/store';
|
|
||||||
|
|
||||||
interface Props extends
|
|
||||||
Omit<
|
|
||||||
ComponentProps<typeof VirtualList<UnifiedFont>>,
|
|
||||||
'items' | 'total' | 'isLoading' | 'onVisibleItemsChange' | 'onNearBottom'
|
|
||||||
>
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Visible items callback
|
|
||||||
*/
|
|
||||||
onVisibleItemsChange?: (items: UnifiedFont[]) => void;
|
|
||||||
/**
|
|
||||||
* Font weight
|
|
||||||
*/
|
|
||||||
weight: number;
|
|
||||||
/**
|
|
||||||
* Skeleton snippet
|
|
||||||
*/
|
|
||||||
skeleton?: Snippet;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
children,
|
|
||||||
onVisibleItemsChange,
|
|
||||||
weight,
|
|
||||||
skeleton,
|
|
||||||
...rest
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
const isLoading = $derived(
|
|
||||||
fontStore.isFetching || fontStore.isLoading,
|
|
||||||
);
|
|
||||||
|
|
||||||
function handleInternalVisibleChange(visibleItems: UnifiedFont[]) {
|
|
||||||
const configs: FontLoadRequestConfig[] = [];
|
|
||||||
|
|
||||||
visibleItems.forEach(item => {
|
|
||||||
const url = getFontUrl(item, weight);
|
|
||||||
|
|
||||||
if (url) {
|
|
||||||
configs.push({
|
|
||||||
id: item.id,
|
|
||||||
name: item.name,
|
|
||||||
weight,
|
|
||||||
url,
|
|
||||||
isVariable: item.features?.isVariable,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Auto-register fonts with the manager
|
|
||||||
appliedFontsManager.touch(configs);
|
|
||||||
|
|
||||||
// Forward the call to any external listener
|
|
||||||
// onVisibleItemsChange?.(visibleItems);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load more fonts by moving to the next page
|
|
||||||
*/
|
|
||||||
function loadMore() {
|
|
||||||
if (
|
|
||||||
!fontStore.pagination.hasMore
|
|
||||||
|| fontStore.isFetching
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
fontStore.nextPage();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle scroll near bottom - auto-load next page
|
|
||||||
*
|
|
||||||
* Triggered by VirtualList when the user scrolls within 5 items of the end
|
|
||||||
* of the loaded items. Only fetches if there are more pages available.
|
|
||||||
*/
|
|
||||||
function handleNearBottom(_lastVisibleIndex: number) {
|
|
||||||
const { hasMore } = fontStore.pagination;
|
|
||||||
|
|
||||||
// VirtualList already checks if we're near the bottom of loaded items
|
|
||||||
if (hasMore && !fontStore.isFetching) {
|
|
||||||
loadMore();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="relative w-full h-full">
|
|
||||||
{#if skeleton && isLoading && fontStore.fonts.length === 0}
|
|
||||||
<!-- Show skeleton only on initial load when no fonts are loaded yet -->
|
|
||||||
<div transition:fade={{ duration: 300 }}>
|
|
||||||
{@render skeleton()}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<!-- VirtualList persists during pagination - no destruction/recreation -->
|
|
||||||
<VirtualList
|
|
||||||
items={fontStore.fonts}
|
|
||||||
total={fontStore.pagination.total}
|
|
||||||
isLoading={isLoading}
|
|
||||||
onVisibleItemsChange={handleInternalVisibleChange}
|
|
||||||
onNearBottom={handleNearBottom}
|
|
||||||
{...rest}
|
|
||||||
>
|
|
||||||
{#snippet children(scope)}
|
|
||||||
{@render children(scope)}
|
|
||||||
{/snippet}
|
|
||||||
</VirtualList>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import FontApplicator from './FontApplicator/FontApplicator.svelte';
|
|
||||||
import FontVirtualList from './FontVirtualList/FontVirtualList.svelte';
|
|
||||||
|
|
||||||
export {
|
|
||||||
FontApplicator,
|
|
||||||
FontVirtualList,
|
|
||||||
};
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export * from './model';
|
|
||||||
export * from './ui';
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { themeManager } from './store/ThemeManager/ThemeManager.svelte';
|
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
/**
|
|
||||||
* Theme management with system preference detection
|
|
||||||
*
|
|
||||||
* Manages light/dark theme state with localStorage persistence
|
|
||||||
* and automatic system preference detection. Themes are applied
|
|
||||||
* via CSS class on the document element.
|
|
||||||
*
|
|
||||||
* Features:
|
|
||||||
* - Persists user preference to localStorage
|
|
||||||
* - Detects OS-level theme preference
|
|
||||||
* - Responds to OS theme changes when in "system" mode
|
|
||||||
* - Toggle between light/dark themes
|
|
||||||
* - Reset to follow system preference
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```svelte
|
|
||||||
* <script lang="ts">
|
|
||||||
* import { themeManager } from '$features/ChangeAppTheme';
|
|
||||||
*
|
|
||||||
* // Initialize once on app mount
|
|
||||||
* onMount(() => themeManager.init());
|
|
||||||
* onDestroy(() => themeManager.destroy());
|
|
||||||
* </script>
|
|
||||||
*
|
|
||||||
* <button on:click={() => themeManager.toggle()}>
|
|
||||||
* {themeManager.isDark ? 'Switch to Light' : 'Switch to Dark'}
|
|
||||||
* </button>
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { createPersistentStore } from '$shared/lib';
|
|
||||||
|
|
||||||
type Theme = 'light' | 'dark';
|
|
||||||
type ThemeSource = 'system' | 'user';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Theme manager singleton
|
|
||||||
*
|
|
||||||
* Call init() on app mount and destroy() on app unmount.
|
|
||||||
* Use isDark property to conditionally apply styles.
|
|
||||||
*/
|
|
||||||
class ThemeManager {
|
|
||||||
// Private reactive state
|
|
||||||
/** Current theme value ('light' or 'dark') */
|
|
||||||
#theme = $state<Theme>('light');
|
|
||||||
/** Whether theme is controlled by user or follows system */
|
|
||||||
#source = $state<ThemeSource>('system');
|
|
||||||
/** MediaQueryList for detecting system theme changes */
|
|
||||||
#mediaQuery: MediaQueryList | null = null;
|
|
||||||
/** Persistent storage for user's theme preference */
|
|
||||||
#store = createPersistentStore<Theme | null>('glyphdiff:theme', null);
|
|
||||||
/** Bound handler for system theme change events */
|
|
||||||
#systemChangeHandler = this.#onSystemChange.bind(this);
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
// Derive initial values from stored preference or OS
|
|
||||||
const stored = this.#store.value;
|
|
||||||
if (stored === 'dark' || stored === 'light') {
|
|
||||||
this.#theme = stored;
|
|
||||||
this.#source = 'user';
|
|
||||||
} else {
|
|
||||||
this.#theme = this.#getSystemTheme();
|
|
||||||
this.#source = 'system';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Current theme value */
|
|
||||||
get value(): Theme {
|
|
||||||
return this.#theme;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Source of current theme ('system' or 'user') */
|
|
||||||
get source(): ThemeSource {
|
|
||||||
return this.#source;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether dark theme is active */
|
|
||||||
get isDark(): boolean {
|
|
||||||
return this.#theme === 'dark';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether theme is controlled by user (not following system) */
|
|
||||||
get isUserControlled(): boolean {
|
|
||||||
return this.#source === 'user';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize theme manager
|
|
||||||
*
|
|
||||||
* Applies current theme to DOM and sets up system preference listener.
|
|
||||||
* Call once in root component onMount.
|
|
||||||
*/
|
|
||||||
init(): void {
|
|
||||||
this.#applyToDom(this.#theme);
|
|
||||||
this.#mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
|
||||||
this.#mediaQuery.addEventListener('change', this.#systemChangeHandler);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clean up theme manager
|
|
||||||
*
|
|
||||||
* Removes system preference listener.
|
|
||||||
* Call in root component onDestroy.
|
|
||||||
*/
|
|
||||||
destroy(): void {
|
|
||||||
this.#mediaQuery?.removeEventListener('change', this.#systemChangeHandler);
|
|
||||||
this.#mediaQuery = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set theme explicitly
|
|
||||||
*
|
|
||||||
* Switches to user control and applies specified theme.
|
|
||||||
*
|
|
||||||
* @param theme - Theme to apply ('light' or 'dark')
|
|
||||||
*/
|
|
||||||
setTheme(theme: Theme): void {
|
|
||||||
this.#source = 'user';
|
|
||||||
this.#theme = theme;
|
|
||||||
this.#store.value = theme;
|
|
||||||
this.#applyToDom(theme);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Toggle between light and dark themes
|
|
||||||
*/
|
|
||||||
toggle(): void {
|
|
||||||
this.setTheme(this.value === 'dark' ? 'light' : 'dark');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reset to follow system preference
|
|
||||||
*
|
|
||||||
* Clears user preference and switches to system theme.
|
|
||||||
*/
|
|
||||||
resetToSystem(): void {
|
|
||||||
this.#store.clear();
|
|
||||||
this.#theme = this.#getSystemTheme();
|
|
||||||
this.#source = 'system';
|
|
||||||
this.#applyToDom(this.#theme);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Private helpers
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detect system theme preference
|
|
||||||
* @returns 'dark' if system prefers dark mode, 'light' otherwise
|
|
||||||
*/
|
|
||||||
#getSystemTheme(): Theme {
|
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
return 'light';
|
|
||||||
}
|
|
||||||
|
|
||||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply theme to DOM
|
|
||||||
* @param theme - Theme to apply
|
|
||||||
*/
|
|
||||||
#applyToDom(theme: Theme): void {
|
|
||||||
document.documentElement.classList.toggle('dark', theme === 'dark');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle system theme change
|
|
||||||
* Only updates if currently following system preference
|
|
||||||
*/
|
|
||||||
#onSystemChange(e: MediaQueryListEvent): void {
|
|
||||||
if (this.#source === 'system') {
|
|
||||||
this.#theme = e.matches ? 'dark' : 'light';
|
|
||||||
this.#applyToDom(this.#theme);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Singleton theme manager instance
|
|
||||||
*
|
|
||||||
* Use throughout the app for consistent theme state.
|
|
||||||
*/
|
|
||||||
export const themeManager = new ThemeManager();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ThemeManager class exported for testing purposes
|
|
||||||
* Use the singleton `themeManager` in application code.
|
|
||||||
*/
|
|
||||||
export { ThemeManager };
|
|
||||||
@@ -1,726 +0,0 @@
|
|||||||
/** @vitest-environment jsdom */
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// Mock MediaQueryListEvent for system theme change simulations
|
|
||||||
// Note: Other mocks (ResizeObserver, localStorage, matchMedia) are set up in vitest.setup.unit.ts
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
class MockMediaQueryListEvent extends Event {
|
|
||||||
matches: boolean;
|
|
||||||
media: string;
|
|
||||||
|
|
||||||
constructor(type: string, eventInitDict: { matches: boolean; media: string }) {
|
|
||||||
super(type);
|
|
||||||
this.matches = eventInitDict.matches;
|
|
||||||
this.media = eventInitDict.media;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// NOW IT'S SAFE TO IMPORT
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
import {
|
|
||||||
afterEach,
|
|
||||||
beforeEach,
|
|
||||||
describe,
|
|
||||||
expect,
|
|
||||||
it,
|
|
||||||
vi,
|
|
||||||
} from 'vitest';
|
|
||||||
import { ThemeManager } from './ThemeManager.svelte';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test Suite for ThemeManager
|
|
||||||
*
|
|
||||||
* Tests theme management functionality including:
|
|
||||||
* - Initial state from localStorage or system preference
|
|
||||||
* - Theme setting and persistence
|
|
||||||
* - Toggle functionality
|
|
||||||
* - System preference detection and following
|
|
||||||
* - DOM manipulation for theme application
|
|
||||||
* - MediaQueryList listener management
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Storage key used by ThemeManager
|
|
||||||
const STORAGE_KEY = 'glyphdiff:theme';
|
|
||||||
|
|
||||||
// Helper type for MediaQueryList event handler
|
|
||||||
type MediaQueryListCallback = (this: MediaQueryList, ev: MediaQueryListEvent) => void;
|
|
||||||
|
|
||||||
// Helper to flush Svelte effects (they run in microtasks)
|
|
||||||
async function flushEffects() {
|
|
||||||
await Promise.resolve();
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('ThemeManager', () => {
|
|
||||||
let classListMock: DOMTokenList;
|
|
||||||
let darkClassAdded = false;
|
|
||||||
let mediaQueryListeners: Map<string, Set<MediaQueryListCallback>> = new Map();
|
|
||||||
let matchMediaSpy: ReturnType<typeof vi.fn>;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
// Reset tracking variables
|
|
||||||
darkClassAdded = false;
|
|
||||||
mediaQueryListeners.clear();
|
|
||||||
// Clear localStorage before each test
|
|
||||||
localStorage.clear();
|
|
||||||
|
|
||||||
// Mock documentElement.classList
|
|
||||||
classListMock = {
|
|
||||||
contains: (className: string) => className === 'dark' ? darkClassAdded : false,
|
|
||||||
add: vi.fn((..._classNames: string[]) => {
|
|
||||||
darkClassAdded = true;
|
|
||||||
}),
|
|
||||||
remove: vi.fn((..._classNames: string[]) => {
|
|
||||||
darkClassAdded = false;
|
|
||||||
}),
|
|
||||||
toggle: vi.fn((className: string, force?: boolean) => {
|
|
||||||
if (className === 'dark') {
|
|
||||||
if (force !== undefined) {
|
|
||||||
darkClassAdded = force;
|
|
||||||
} else {
|
|
||||||
darkClassAdded = !darkClassAdded;
|
|
||||||
}
|
|
||||||
return darkClassAdded;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}),
|
|
||||||
supports: vi.fn(() => true),
|
|
||||||
entries: vi.fn(() => []),
|
|
||||||
forEach: vi.fn(),
|
|
||||||
keys: vi.fn(() => []),
|
|
||||||
values: vi.fn(() => []),
|
|
||||||
length: 0,
|
|
||||||
item: vi.fn(() => null),
|
|
||||||
replace: vi.fn(() => false),
|
|
||||||
} as unknown as DOMTokenList;
|
|
||||||
|
|
||||||
// Mock document.documentElement
|
|
||||||
if (typeof document !== 'undefined' && document.documentElement) {
|
|
||||||
Object.defineProperty(document.documentElement, 'classList', {
|
|
||||||
configurable: true,
|
|
||||||
get: () => classListMock,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mock window.matchMedia with spy to track listeners
|
|
||||||
matchMediaSpy = vi.fn((query: string) => {
|
|
||||||
// Default to light theme (matches = false)
|
|
||||||
const mediaQueryList = {
|
|
||||||
matches: false,
|
|
||||||
media: query,
|
|
||||||
onchange: null,
|
|
||||||
addListener: vi.fn(), // Deprecated
|
|
||||||
removeListener: vi.fn(), // Deprecated
|
|
||||||
addEventListener: vi.fn((_type: string, listener: MediaQueryListCallback) => {
|
|
||||||
if (!mediaQueryListeners.has(query)) {
|
|
||||||
mediaQueryListeners.set(query, new Set());
|
|
||||||
}
|
|
||||||
mediaQueryListeners.get(query)!.add(listener);
|
|
||||||
}),
|
|
||||||
removeEventListener: vi.fn((_type: string, listener: MediaQueryListCallback) => {
|
|
||||||
if (mediaQueryListeners.has(query)) {
|
|
||||||
mediaQueryListeners.get(query)!.delete(listener);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
dispatchEvent: vi.fn(),
|
|
||||||
};
|
|
||||||
return mediaQueryList;
|
|
||||||
});
|
|
||||||
|
|
||||||
Object.defineProperty(window, 'matchMedia', {
|
|
||||||
writable: true,
|
|
||||||
value: matchMediaSpy,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper to trigger a MediaQueryList change event
|
|
||||||
*/
|
|
||||||
function triggerSystemThemeChange(isDark: boolean) {
|
|
||||||
const query = '(prefers-color-scheme: dark)';
|
|
||||||
const listeners = mediaQueryListeners.get(query);
|
|
||||||
if (listeners) {
|
|
||||||
const event = new MockMediaQueryListEvent('change', {
|
|
||||||
matches: isDark,
|
|
||||||
media: query,
|
|
||||||
});
|
|
||||||
listeners.forEach(listener => listener.call({ matches: isDark, media: query } as MediaQueryList, event));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('Constructor - Initial State', () => {
|
|
||||||
it('should initialize with light theme when localStorage is empty and system prefers light', () => {
|
|
||||||
const manager = new ThemeManager();
|
|
||||||
|
|
||||||
expect(manager.value).toBe('light');
|
|
||||||
expect(manager.isDark).toBe(false);
|
|
||||||
expect(manager.source).toBe('system');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should initialize with system dark theme when localStorage is empty', () => {
|
|
||||||
// Mock system prefers dark theme
|
|
||||||
matchMediaSpy.mockImplementation((query: string) => ({
|
|
||||||
matches: query === '(prefers-color-scheme: dark)',
|
|
||||||
media: query,
|
|
||||||
onchange: null,
|
|
||||||
addListener: vi.fn(),
|
|
||||||
removeListener: vi.fn(),
|
|
||||||
addEventListener: vi.fn(),
|
|
||||||
removeEventListener: vi.fn(),
|
|
||||||
dispatchEvent: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const manager = new ThemeManager();
|
|
||||||
|
|
||||||
expect(manager.value).toBe('dark');
|
|
||||||
expect(manager.isDark).toBe(true);
|
|
||||||
expect(manager.source).toBe('system');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should initialize with stored light theme from localStorage', () => {
|
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify('light'));
|
|
||||||
|
|
||||||
const manager = new ThemeManager();
|
|
||||||
|
|
||||||
expect(manager.value).toBe('light');
|
|
||||||
expect(manager.isDark).toBe(false);
|
|
||||||
expect(manager.source).toBe('user');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should initialize with stored dark theme from localStorage', () => {
|
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify('dark'));
|
|
||||||
|
|
||||||
const manager = new ThemeManager();
|
|
||||||
|
|
||||||
expect(manager.value).toBe('dark');
|
|
||||||
expect(manager.isDark).toBe(true);
|
|
||||||
expect(manager.source).toBe('user');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should ignore invalid values in localStorage and use system theme', () => {
|
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify('invalid'));
|
|
||||||
|
|
||||||
const manager = new ThemeManager();
|
|
||||||
|
|
||||||
expect(manager.value).toBe('light');
|
|
||||||
expect(manager.source).toBe('system');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle null in localStorage as system theme', () => {
|
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(null));
|
|
||||||
|
|
||||||
const manager = new ThemeManager();
|
|
||||||
|
|
||||||
expect(manager.value).toBe('light');
|
|
||||||
expect(manager.source).toBe('system');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should be in user-controlled mode when localStorage has a valid theme', () => {
|
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify('dark'));
|
|
||||||
|
|
||||||
const manager = new ThemeManager();
|
|
||||||
|
|
||||||
expect(manager.isUserControlled).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not be in user-controlled mode when following system', () => {
|
|
||||||
const manager = new ThemeManager();
|
|
||||||
|
|
||||||
expect(manager.isUserControlled).toBe(false);
|
|
||||||
expect(manager.source).toBe('system');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('init() - Initialization', () => {
|
|
||||||
it('should apply initial theme to DOM on init', () => {
|
|
||||||
const manager = new ThemeManager();
|
|
||||||
manager.init();
|
|
||||||
|
|
||||||
expect(classListMock.toggle).toHaveBeenCalledWith('dark', false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should apply dark theme to DOM when initialized with dark theme', () => {
|
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify('dark'));
|
|
||||||
const manager = new ThemeManager();
|
|
||||||
manager.init();
|
|
||||||
|
|
||||||
expect(classListMock.toggle).toHaveBeenCalledWith('dark', true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should set up MediaQueryList listener on init', () => {
|
|
||||||
const manager = new ThemeManager();
|
|
||||||
manager.init();
|
|
||||||
|
|
||||||
expect(matchMediaSpy).toHaveBeenCalledWith('(prefers-color-scheme: dark)');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not fail if called multiple times', () => {
|
|
||||||
const manager = new ThemeManager();
|
|
||||||
expect(() => {
|
|
||||||
manager.init();
|
|
||||||
manager.init();
|
|
||||||
}).not.toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('destroy() - Cleanup', () => {
|
|
||||||
it('should remove MediaQueryList listener on destroy', () => {
|
|
||||||
const manager = new ThemeManager();
|
|
||||||
manager.init();
|
|
||||||
manager.destroy();
|
|
||||||
|
|
||||||
const listeners = mediaQueryListeners.get('(prefers-color-scheme: dark)');
|
|
||||||
expect(listeners?.size ?? 0).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not fail if destroy is called before init', () => {
|
|
||||||
const manager = new ThemeManager();
|
|
||||||
expect(() => {
|
|
||||||
manager.destroy();
|
|
||||||
}).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not fail if destroy is called multiple times', () => {
|
|
||||||
const manager = new ThemeManager();
|
|
||||||
manager.init();
|
|
||||||
expect(() => {
|
|
||||||
manager.destroy();
|
|
||||||
manager.destroy();
|
|
||||||
}).not.toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('setTheme() - Set Explicit Theme', () => {
|
|
||||||
it('should set theme to light and update source to user', () => {
|
|
||||||
const manager = new ThemeManager();
|
|
||||||
manager.setTheme('light');
|
|
||||||
|
|
||||||
expect(manager.value).toBe('light');
|
|
||||||
expect(manager.isDark).toBe(false);
|
|
||||||
expect(manager.source).toBe('user');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should set theme to dark and update source to user', () => {
|
|
||||||
const manager = new ThemeManager();
|
|
||||||
manager.setTheme('dark');
|
|
||||||
|
|
||||||
expect(manager.value).toBe('dark');
|
|
||||||
expect(manager.isDark).toBe(true);
|
|
||||||
expect(manager.source).toBe('user');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should save theme to localStorage when set', async () => {
|
|
||||||
const manager = new ThemeManager();
|
|
||||||
manager.setTheme('dark');
|
|
||||||
|
|
||||||
await flushEffects();
|
|
||||||
|
|
||||||
expect(localStorage.getItem(STORAGE_KEY)).toBe(JSON.stringify('dark'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should apply theme to DOM when set', () => {
|
|
||||||
const manager = new ThemeManager();
|
|
||||||
manager.setTheme('dark');
|
|
||||||
|
|
||||||
expect(classListMock.toggle).toHaveBeenCalledWith('dark', true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should overwrite existing localStorage value', async () => {
|
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify('light'));
|
|
||||||
const manager = new ThemeManager();
|
|
||||||
manager.setTheme('dark');
|
|
||||||
|
|
||||||
await flushEffects();
|
|
||||||
|
|
||||||
expect(localStorage.getItem(STORAGE_KEY)).toBe(JSON.stringify('dark'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle switching from light to dark', () => {
|
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify('light'));
|
|
||||||
const manager = new ThemeManager();
|
|
||||||
manager.init();
|
|
||||||
manager.setTheme('dark');
|
|
||||||
|
|
||||||
expect(manager.value).toBe('dark');
|
|
||||||
expect(manager.source).toBe('user');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle switching from dark to light', () => {
|
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify('dark'));
|
|
||||||
const manager = new ThemeManager();
|
|
||||||
manager.init();
|
|
||||||
manager.setTheme('light');
|
|
||||||
|
|
||||||
expect(manager.value).toBe('light');
|
|
||||||
expect(manager.source).toBe('user');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('toggle() - Toggle Between Themes', () => {
|
|
||||||
it('should toggle from light to dark', () => {
|
|
||||||
const manager = new ThemeManager();
|
|
||||||
manager.toggle();
|
|
||||||
|
|
||||||
expect(manager.value).toBe('dark');
|
|
||||||
expect(manager.isDark).toBe(true);
|
|
||||||
expect(manager.source).toBe('user');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should toggle from dark to light', () => {
|
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify('dark'));
|
|
||||||
const manager = new ThemeManager();
|
|
||||||
manager.toggle();
|
|
||||||
|
|
||||||
expect(manager.value).toBe('light');
|
|
||||||
expect(manager.isDark).toBe(false);
|
|
||||||
expect(manager.source).toBe('user');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should save toggled theme to localStorage', async () => {
|
|
||||||
const manager = new ThemeManager();
|
|
||||||
manager.toggle();
|
|
||||||
|
|
||||||
await flushEffects();
|
|
||||||
|
|
||||||
expect(localStorage.getItem(STORAGE_KEY)).toBe(JSON.stringify('dark'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should apply toggled theme to DOM', () => {
|
|
||||||
const manager = new ThemeManager();
|
|
||||||
manager.toggle();
|
|
||||||
|
|
||||||
expect(classListMock.toggle).toHaveBeenCalledWith('dark', true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle multiple rapid toggles', () => {
|
|
||||||
const manager = new ThemeManager();
|
|
||||||
manager.toggle();
|
|
||||||
expect(manager.value).toBe('dark');
|
|
||||||
|
|
||||||
manager.toggle();
|
|
||||||
expect(manager.value).toBe('light');
|
|
||||||
|
|
||||||
manager.toggle();
|
|
||||||
expect(manager.value).toBe('dark');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('resetToSystem() - Reset to System Preference', () => {
|
|
||||||
it('should clear localStorage when resetting to system', () => {
|
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify('dark'));
|
|
||||||
const manager = new ThemeManager();
|
|
||||||
manager.resetToSystem();
|
|
||||||
|
|
||||||
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should set source to system after reset', () => {
|
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify('dark'));
|
|
||||||
const manager = new ThemeManager();
|
|
||||||
manager.resetToSystem();
|
|
||||||
|
|
||||||
expect(manager.source).toBe('system');
|
|
||||||
expect(manager.isUserControlled).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should detect and apply light system theme', () => {
|
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify('dark'));
|
|
||||||
const manager = new ThemeManager();
|
|
||||||
manager.resetToSystem();
|
|
||||||
|
|
||||||
expect(manager.value).toBe('light');
|
|
||||||
expect(classListMock.toggle).toHaveBeenCalledWith('dark', false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should detect and apply dark system theme', () => {
|
|
||||||
// Override matchMedia to return dark preference
|
|
||||||
matchMediaSpy.mockImplementation((query: string) => ({
|
|
||||||
matches: query === '(prefers-color-scheme: dark)',
|
|
||||||
media: query,
|
|
||||||
onchange: null,
|
|
||||||
addListener: vi.fn(),
|
|
||||||
removeListener: vi.fn(),
|
|
||||||
addEventListener: vi.fn(),
|
|
||||||
removeEventListener: vi.fn(),
|
|
||||||
dispatchEvent: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const manager = new ThemeManager();
|
|
||||||
manager.resetToSystem();
|
|
||||||
|
|
||||||
expect(manager.value).toBe('dark');
|
|
||||||
expect(classListMock.toggle).toHaveBeenCalledWith('dark', true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should apply system theme to DOM on reset', () => {
|
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify('dark'));
|
|
||||||
const manager = new ThemeManager();
|
|
||||||
manager.resetToSystem();
|
|
||||||
|
|
||||||
expect(classListMock.toggle).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('System Theme Change Handling', () => {
|
|
||||||
it('should update theme when system changes to dark while following system', () => {
|
|
||||||
const manager = new ThemeManager();
|
|
||||||
manager.init();
|
|
||||||
|
|
||||||
triggerSystemThemeChange(true);
|
|
||||||
|
|
||||||
expect(manager.value).toBe('dark');
|
|
||||||
expect(manager.isDark).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should update theme when system changes to light while following system', () => {
|
|
||||||
// Start with dark system theme
|
|
||||||
// Keep the listener tracking while overriding matches behavior
|
|
||||||
matchMediaSpy.mockImplementation((query: string) => ({
|
|
||||||
matches: query === '(prefers-color-scheme: dark)',
|
|
||||||
media: query,
|
|
||||||
onchange: null,
|
|
||||||
addListener: vi.fn(),
|
|
||||||
removeListener: vi.fn(),
|
|
||||||
addEventListener: vi.fn((_type: string, listener: MediaQueryListCallback) => {
|
|
||||||
if (!mediaQueryListeners.has(query)) {
|
|
||||||
mediaQueryListeners.set(query, new Set());
|
|
||||||
}
|
|
||||||
mediaQueryListeners.get(query)!.add(listener);
|
|
||||||
}),
|
|
||||||
removeEventListener: vi.fn((_type: string, listener: MediaQueryListCallback) => {
|
|
||||||
if (mediaQueryListeners.has(query)) {
|
|
||||||
mediaQueryListeners.get(query)!.delete(listener);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
dispatchEvent: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const manager = new ThemeManager();
|
|
||||||
manager.init();
|
|
||||||
expect(manager.value).toBe('dark');
|
|
||||||
|
|
||||||
// Now change to light
|
|
||||||
triggerSystemThemeChange(false);
|
|
||||||
|
|
||||||
expect(manager.value).toBe('light');
|
|
||||||
expect(manager.isDark).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should update DOM when system theme changes while following system', () => {
|
|
||||||
const manager = new ThemeManager();
|
|
||||||
manager.init();
|
|
||||||
|
|
||||||
triggerSystemThemeChange(true);
|
|
||||||
|
|
||||||
expect(classListMock.toggle).toHaveBeenCalledWith('dark', true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should NOT update theme when system changes if user has set theme', () => {
|
|
||||||
const manager = new ThemeManager();
|
|
||||||
manager.setTheme('light'); // User explicitly sets light
|
|
||||||
manager.init();
|
|
||||||
|
|
||||||
// Simulate system changing to dark
|
|
||||||
triggerSystemThemeChange(true);
|
|
||||||
|
|
||||||
// Theme should remain light because user set it
|
|
||||||
expect(manager.value).toBe('light');
|
|
||||||
expect(manager.source).toBe('user');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should respond to system changes after resetToSystem', () => {
|
|
||||||
// Start with user-controlled dark theme
|
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify('dark'));
|
|
||||||
const manager = new ThemeManager();
|
|
||||||
manager.init();
|
|
||||||
|
|
||||||
expect(manager.value).toBe('dark');
|
|
||||||
expect(manager.source).toBe('user');
|
|
||||||
|
|
||||||
// Reset to system (which is light)
|
|
||||||
manager.resetToSystem();
|
|
||||||
expect(manager.value).toBe('light');
|
|
||||||
expect(manager.source).toBe('system');
|
|
||||||
|
|
||||||
// Now system changes to dark
|
|
||||||
triggerSystemThemeChange(true);
|
|
||||||
|
|
||||||
// Should update because we're back to following system
|
|
||||||
expect(manager.value).toBe('dark');
|
|
||||||
expect(manager.source).toBe('system');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should stop responding to system changes after setTheme is called', () => {
|
|
||||||
const manager = new ThemeManager();
|
|
||||||
manager.init();
|
|
||||||
|
|
||||||
// System changes to dark
|
|
||||||
triggerSystemThemeChange(true);
|
|
||||||
expect(manager.value).toBe('dark');
|
|
||||||
expect(manager.source).toBe('system');
|
|
||||||
|
|
||||||
// User explicitly sets light
|
|
||||||
manager.setTheme('light');
|
|
||||||
expect(manager.value).toBe('light');
|
|
||||||
expect(manager.source).toBe('user');
|
|
||||||
|
|
||||||
// System changes again
|
|
||||||
triggerSystemThemeChange(false);
|
|
||||||
|
|
||||||
// Should stay light because user set it
|
|
||||||
expect(manager.value).toBe('light');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not trigger updates after destroy is called', () => {
|
|
||||||
const manager = new ThemeManager();
|
|
||||||
manager.init();
|
|
||||||
manager.destroy();
|
|
||||||
|
|
||||||
// This should not cause any updates since listener was removed
|
|
||||||
expect(() => {
|
|
||||||
triggerSystemThemeChange(true);
|
|
||||||
}).not.toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('DOM Interaction', () => {
|
|
||||||
it('should add dark class when applying dark theme', () => {
|
|
||||||
const manager = new ThemeManager();
|
|
||||||
manager.init();
|
|
||||||
|
|
||||||
manager.setTheme('dark');
|
|
||||||
|
|
||||||
// Check toggle was called with force=true for dark
|
|
||||||
expect(classListMock.toggle).toHaveBeenCalledWith('dark', true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should remove dark class when applying light theme', () => {
|
|
||||||
// Start with dark
|
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify('dark'));
|
|
||||||
const manager = new ThemeManager();
|
|
||||||
manager.init();
|
|
||||||
|
|
||||||
// Switch to light
|
|
||||||
manager.setTheme('light');
|
|
||||||
|
|
||||||
expect(classListMock.toggle).toHaveBeenCalledWith('dark', false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not add dark class when system prefers light', () => {
|
|
||||||
const manager = new ThemeManager();
|
|
||||||
manager.init();
|
|
||||||
|
|
||||||
expect(classListMock.toggle).toHaveBeenCalledWith('dark', false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Getter Properties', () => {
|
|
||||||
it('value getter should return current theme', () => {
|
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify('dark'));
|
|
||||||
const manager = new ThemeManager();
|
|
||||||
|
|
||||||
expect(manager.value).toBe('dark');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('source getter should return "user" when theme is user-controlled', () => {
|
|
||||||
const manager = new ThemeManager();
|
|
||||||
manager.setTheme('dark');
|
|
||||||
|
|
||||||
expect(manager.source).toBe('user');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('source getter should return "system" when following system', () => {
|
|
||||||
const manager = new ThemeManager();
|
|
||||||
|
|
||||||
expect(manager.source).toBe('system');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('isDark getter should return true for dark theme', () => {
|
|
||||||
const manager = new ThemeManager();
|
|
||||||
manager.setTheme('dark');
|
|
||||||
|
|
||||||
expect(manager.isDark).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('isDark getter should return false for light theme', () => {
|
|
||||||
const manager = new ThemeManager();
|
|
||||||
|
|
||||||
expect(manager.isDark).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('isUserControlled getter should return true when source is user', () => {
|
|
||||||
const manager = new ThemeManager();
|
|
||||||
manager.setTheme('light');
|
|
||||||
|
|
||||||
expect(manager.isUserControlled).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('isUserControlled getter should return false when source is system', () => {
|
|
||||||
const manager = new ThemeManager();
|
|
||||||
|
|
||||||
expect(manager.isUserControlled).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Edge Cases', () => {
|
|
||||||
it('should handle rapid setTheme calls', async () => {
|
|
||||||
const manager = new ThemeManager();
|
|
||||||
manager.setTheme('dark');
|
|
||||||
manager.setTheme('light');
|
|
||||||
manager.setTheme('dark');
|
|
||||||
manager.setTheme('light');
|
|
||||||
|
|
||||||
await flushEffects();
|
|
||||||
|
|
||||||
expect(manager.value).toBe('light');
|
|
||||||
expect(localStorage.getItem(STORAGE_KEY)).toBe(JSON.stringify('light'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle toggle immediately followed by setTheme', async () => {
|
|
||||||
const manager = new ThemeManager();
|
|
||||||
manager.toggle();
|
|
||||||
manager.setTheme('light');
|
|
||||||
|
|
||||||
await flushEffects();
|
|
||||||
|
|
||||||
expect(manager.value).toBe('light');
|
|
||||||
expect(localStorage.getItem(STORAGE_KEY)).toBe(JSON.stringify('light'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle setTheme immediately followed by resetToSystem', () => {
|
|
||||||
const manager = new ThemeManager();
|
|
||||||
manager.setTheme('dark');
|
|
||||||
manager.resetToSystem();
|
|
||||||
|
|
||||||
expect(manager.value).toBe('light');
|
|
||||||
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle resetToSystem when already following system', () => {
|
|
||||||
const manager = new ThemeManager();
|
|
||||||
manager.resetToSystem();
|
|
||||||
|
|
||||||
expect(manager.value).toBe('light');
|
|
||||||
expect(manager.source).toBe('system');
|
|
||||||
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Type Safety', () => {
|
|
||||||
it('should accept "light" as valid theme', () => {
|
|
||||||
const manager = new ThemeManager();
|
|
||||||
expect(() => manager.setTheme('light')).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should accept "dark" as valid theme', () => {
|
|
||||||
const manager = new ThemeManager();
|
|
||||||
expect(() => manager.setTheme('dark')).not.toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
<script module>
|
|
||||||
import { defineMeta } from '@storybook/addon-svelte-csf';
|
|
||||||
import ThemeSwitch from './ThemeSwitch.svelte';
|
|
||||||
|
|
||||||
const { Story } = defineMeta({
|
|
||||||
title: 'Features/ThemeSwitch',
|
|
||||||
component: ThemeSwitch,
|
|
||||||
tags: ['autodocs'],
|
|
||||||
parameters: {
|
|
||||||
docs: {
|
|
||||||
description: {
|
|
||||||
component:
|
|
||||||
'Theme toggle button that switches between light and dark modes. Uses ThemeManager to persist user preference and sync with system preference. Displays sun/moon icon based on current theme.',
|
|
||||||
},
|
|
||||||
story: { inline: false },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
argTypes: {
|
|
||||||
// ThemeSwitch has no explicit props - it uses themeManager internally
|
|
||||||
},
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import { themeManager } from '$features/ChangeAppTheme';
|
|
||||||
|
|
||||||
// Current theme state for display
|
|
||||||
const currentTheme = $derived(themeManager.value);
|
|
||||||
const themeSource = $derived(themeManager.source);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Story name="Default">
|
|
||||||
<div class="flex items-center justify-center p-8 gap-4">
|
|
||||||
<ThemeSwitch />
|
|
||||||
<div class="text-sm text-muted-foreground">
|
|
||||||
Theme: <span class="font-semibold">{currentTheme}</span>
|
|
||||||
{#if themeSource === 'user'}
|
|
||||||
<span class="text-xs ml-2">(user preference)</span>
|
|
||||||
{:else}
|
|
||||||
<span class="text-xs ml-2">(system preference)</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Story>
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
<!--
|
|
||||||
Component: ThemeSwitch
|
|
||||||
Toggles the theme between light and dark mode.
|
|
||||||
-->
|
|
||||||
<script lang="ts">
|
|
||||||
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 { themeManager } from '../../model';
|
|
||||||
|
|
||||||
const responsive = getContext<ResponsiveManager>('responsive');
|
|
||||||
|
|
||||||
const theme = $derived(themeManager.value);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<IconButton onclick={() => themeManager.toggle()} size={responsive.isMobile ? 'sm' : 'md'} title="Toggle theme">
|
|
||||||
{#snippet icon()}
|
|
||||||
{#if theme === 'light'}
|
|
||||||
<MoonIcon class={responsive.isMobile ? 'size-4' : 'size-5'} />
|
|
||||||
{:else}
|
|
||||||
<SunIcon class={responsive.isMobile ? 'size-4' : 'size-5'} />
|
|
||||||
{/if}
|
|
||||||
{/snippet}
|
|
||||||
</IconButton>
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { default as ThemeSwitch } from './ThemeSwitch/ThemeSwitch.svelte';
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { FontSampler } from './ui';
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
<script module>
|
|
||||||
import Providers from '$shared/lib/storybook/Providers.svelte';
|
|
||||||
import { defineMeta } from '@storybook/addon-svelte-csf';
|
|
||||||
import FontSampler from './FontSampler.svelte';
|
|
||||||
|
|
||||||
const { Story } = defineMeta({
|
|
||||||
title: 'Features/FontSampler',
|
|
||||||
component: FontSampler,
|
|
||||||
tags: ['autodocs'],
|
|
||||||
parameters: {
|
|
||||||
docs: {
|
|
||||||
description: {
|
|
||||||
component:
|
|
||||||
'Displays a sample text with a given font in a contenteditable element. Visual design matches FontCard: sharp corners, brand hover accent, header stats showing font properties (size, weight, line height, letter spacing). Staggered entrance animation based on index.',
|
|
||||||
},
|
|
||||||
story: { inline: false },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
argTypes: {
|
|
||||||
font: {
|
|
||||||
control: 'object',
|
|
||||||
description: 'Font information object',
|
|
||||||
},
|
|
||||||
text: {
|
|
||||||
control: 'text',
|
|
||||||
description: 'Editable sample text (two-way bindable)',
|
|
||||||
},
|
|
||||||
index: {
|
|
||||||
control: { type: 'number', min: 0 },
|
|
||||||
description: 'Position index — drives the staggered entrance delay',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import type { UnifiedFont } from '$entities/Font';
|
|
||||||
import { controlManager } from '$features/SetupFont';
|
|
||||||
|
|
||||||
// Mock fonts for testing
|
|
||||||
const mockArial: UnifiedFont = {
|
|
||||||
id: 'arial',
|
|
||||||
name: 'Arial',
|
|
||||||
provider: 'google',
|
|
||||||
category: 'sans-serif',
|
|
||||||
subsets: ['latin'],
|
|
||||||
variants: ['400', '700'],
|
|
||||||
styles: {
|
|
||||||
regular: '',
|
|
||||||
bold: '',
|
|
||||||
},
|
|
||||||
metadata: {
|
|
||||||
cachedAt: Date.now(),
|
|
||||||
version: '1.0',
|
|
||||||
popularity: 1,
|
|
||||||
},
|
|
||||||
features: {
|
|
||||||
isVariable: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockGeorgia: UnifiedFont = {
|
|
||||||
id: 'georgia',
|
|
||||||
name: 'Georgia',
|
|
||||||
provider: 'google',
|
|
||||||
category: 'serif',
|
|
||||||
subsets: ['latin'],
|
|
||||||
variants: ['400', '700'],
|
|
||||||
styles: {
|
|
||||||
regular: '',
|
|
||||||
bold: '',
|
|
||||||
},
|
|
||||||
metadata: {
|
|
||||||
cachedAt: Date.now(),
|
|
||||||
version: '1.0',
|
|
||||||
popularity: 2,
|
|
||||||
},
|
|
||||||
features: {
|
|
||||||
isVariable: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Story
|
|
||||||
name="Default"
|
|
||||||
args={{
|
|
||||||
font: mockArial,
|
|
||||||
text: 'The quick brown fox jumps over the lazy dog',
|
|
||||||
index: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{#snippet template(args)}
|
|
||||||
<Providers>
|
|
||||||
<div class="max-w-2xl mx-auto">
|
|
||||||
<FontSampler {...args} />
|
|
||||||
</div>
|
|
||||||
</Providers>
|
|
||||||
{/snippet}
|
|
||||||
</Story>
|
|
||||||
<Story
|
|
||||||
name="Long Text"
|
|
||||||
args={{
|
|
||||||
font: mockGeorgia,
|
|
||||||
text:
|
|
||||||
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.',
|
|
||||||
index: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{#snippet template(args)}
|
|
||||||
<Providers>
|
|
||||||
<div class="max-w-2xl mx-auto">
|
|
||||||
<FontSampler {...args} />
|
|
||||||
</div>
|
|
||||||
</Providers>
|
|
||||||
{/snippet}
|
|
||||||
</Story>
|
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
<!--
|
|
||||||
Component: FontSampler
|
|
||||||
Displays a sample text with a given font in a contenteditable element.
|
|
||||||
Visual design matches FontCard: sharp corners, red hover accent, header stats.
|
|
||||||
-->
|
|
||||||
<script lang="ts">
|
|
||||||
import {
|
|
||||||
FontApplicator,
|
|
||||||
type UnifiedFont,
|
|
||||||
} from '$entities/Font';
|
|
||||||
import { controlManager } from '$features/SetupFont';
|
|
||||||
import {
|
|
||||||
Badge,
|
|
||||||
ContentEditable,
|
|
||||||
Divider,
|
|
||||||
Footnote,
|
|
||||||
Stat,
|
|
||||||
StatGroup,
|
|
||||||
} from '$shared/ui';
|
|
||||||
import { fly } from 'svelte/transition';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
/**
|
|
||||||
* Font info
|
|
||||||
*/
|
|
||||||
font: UnifiedFont;
|
|
||||||
/**
|
|
||||||
* Sample text
|
|
||||||
*/
|
|
||||||
text: string;
|
|
||||||
/**
|
|
||||||
* Position index
|
|
||||||
* @default 0
|
|
||||||
*/
|
|
||||||
index?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { font, text = $bindable(), index = 0 }: Props = $props();
|
|
||||||
|
|
||||||
const fontWeight = $derived(controlManager.weight);
|
|
||||||
const fontSize = $derived(controlManager.renderedSize);
|
|
||||||
const lineHeight = $derived(controlManager.height);
|
|
||||||
const letterSpacing = $derived(controlManager.spacing);
|
|
||||||
|
|
||||||
// Adjust the property name to match your UnifiedFont type
|
|
||||||
const fontType = $derived((font as any).type ?? (font as any).category ?? '');
|
|
||||||
|
|
||||||
// Extract provider badge with fallback
|
|
||||||
const providerBadge = $derived(
|
|
||||||
font.providerBadge
|
|
||||||
?? (font.provider === 'google' ? 'Google Fonts' : 'Fontshare'),
|
|
||||||
);
|
|
||||||
|
|
||||||
const stats = $derived([
|
|
||||||
{ label: 'SZ', value: `${fontSize}PX` },
|
|
||||||
{ label: 'WGT', value: `${fontWeight}` },
|
|
||||||
{ label: 'LH', value: lineHeight?.toFixed(2) },
|
|
||||||
{ label: 'LTR', value: `${letterSpacing}` },
|
|
||||||
]);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
in:fly={{ y: 20, duration: 400, delay: index * 50 }}
|
|
||||||
class="
|
|
||||||
group relative
|
|
||||||
w-full h-full
|
|
||||||
bg-paper dark:bg-dark-card
|
|
||||||
border border-black/5 dark:border-white/10
|
|
||||||
hover:border-brand dark:hover:border-brand
|
|
||||||
hover:shadow-brand/10
|
|
||||||
hover:shadow-[5px_5px_0px_0px]
|
|
||||||
transition-all duration-200
|
|
||||||
overflow-hidden
|
|
||||||
flex flex-col
|
|
||||||
min-h-60
|
|
||||||
rounded-none
|
|
||||||
"
|
|
||||||
style:font-weight={fontWeight}
|
|
||||||
>
|
|
||||||
<!-- ── Header bar ─────────────────────────────────────────────────── -->
|
|
||||||
<div
|
|
||||||
class="
|
|
||||||
flex items-center justify-between
|
|
||||||
px-4 sm:px-5 md:px-6 py-3 sm:py-4
|
|
||||||
border-b border-black/5 dark:border-white/10
|
|
||||||
bg-paper dark:bg-dark-card
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<!-- Left: index · name · type badge · provider badge -->
|
|
||||||
<div class="flex items-center gap-2 sm:gap-4 min-w-0 shrink-0">
|
|
||||||
<span class="font-mono text-[0.625rem] tracking-widest text-neutral-400 uppercase leading-none shrink-0">
|
|
||||||
{String(index + 1).padStart(2, '0')}
|
|
||||||
</span>
|
|
||||||
<Divider orientation="vertical" class="h-3 shrink-0" />
|
|
||||||
|
|
||||||
<span
|
|
||||||
class="font-primary font-bold text-sm text-swiss-black dark:text-neutral-200 leading-none tracking-tight uppercase truncate"
|
|
||||||
>
|
|
||||||
{font.name}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{#if fontType}
|
|
||||||
<Badge size="xs" variant="default" class="text-nowrap font-mono">
|
|
||||||
{fontType}
|
|
||||||
</Badge>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Provider badge -->
|
|
||||||
{#if providerBadge}
|
|
||||||
<Badge size="xs" variant="default" class="text-nowrap font-mono" data-provider={font.provider}>
|
|
||||||
{providerBadge}
|
|
||||||
</Badge>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Right: stats, hidden on mobile, fade in on group hover -->
|
|
||||||
<div
|
|
||||||
class="
|
|
||||||
flex-1 min-w-0
|
|
||||||
hidden md:block @container
|
|
||||||
opacity-50 group-hover:opacity-100
|
|
||||||
transition-opacity duration-200 ml-4
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<!-- Switches: narrow → 2×2, wide enough → 1 row -->
|
|
||||||
<div
|
|
||||||
class="
|
|
||||||
max-w-64 ml-auto
|
|
||||||
grid grid-cols-2 gap-x-3 gap-y-2
|
|
||||||
@[160px]:grid-cols-4 @[160px]:gap-y-0
|
|
||||||
items-center
|
|
||||||
"
|
|
||||||
>
|
|
||||||
{#each stats as stat}
|
|
||||||
<Stat label={stat.label} value={stat.value} />
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ── Main content area ──────────────────────────────────────────── -->
|
|
||||||
<div class="flex-1 p-4 sm:p-5 md:p-8 flex items-center overflow-hidden bg-paper dark:bg-dark-card relative z-10">
|
|
||||||
<FontApplicator {font} weight={fontWeight}>
|
|
||||||
<ContentEditable
|
|
||||||
bind:text
|
|
||||||
{fontSize}
|
|
||||||
{lineHeight}
|
|
||||||
{letterSpacing}
|
|
||||||
/>
|
|
||||||
</FontApplicator>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ── Mobile stats footer (md:hidden — header stats take over above) -->
|
|
||||||
<div class="md:hidden px-4 sm:px-5 py-1.5 sm:py-2 border-t border-black/5 dark:border-white/10 flex gap-2 sm:gap-4 bg-paper dark:bg-dark-card mt-auto">
|
|
||||||
{#each stats as stat, i}
|
|
||||||
<Footnote class="text-[0.4375rem] sm:text-[0.5rem] tracking-wider {i === 0 ? 'ml-auto' : ''}">
|
|
||||||
{stat.label}:{stat.value}
|
|
||||||
</Footnote>
|
|
||||||
{#if i < stats.length - 1}
|
|
||||||
<div class="w-px h-2 sm:h-2.5 self-center bg-black/10 dark:bg-white/10 hidden sm:block"></div>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ── Red hover line ─────────────────────────────────────────────── -->
|
|
||||||
<div
|
|
||||||
class="
|
|
||||||
absolute bottom-0 left-0 right-0
|
|
||||||
w-full h-0.5 bg-brand
|
|
||||||
scale-x-0 group-hover:scale-x-100
|
|
||||||
transition-transform cubic-bezier(0.25, 0.1, 0.25, 1) origin-left duration-400
|
|
||||||
z-10
|
|
||||||
"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import FontSampler from './FontSampler/FontSampler.svelte';
|
|
||||||
|
|
||||||
export { FontSampler };
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
/**
|
|
||||||
* Proxy API filters
|
|
||||||
*
|
|
||||||
* Fetches filter metadata from GlyphDiff proxy API.
|
|
||||||
* Provides type-safe response handling.
|
|
||||||
*
|
|
||||||
* @see https://api.glyphdiff.com/api/v1/filters
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { api } from '$shared/api/api';
|
|
||||||
|
|
||||||
const PROXY_API_URL = 'https://api.glyphdiff.com/api/v1/filters' as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Filter metadata type from backend
|
|
||||||
*/
|
|
||||||
export interface FilterMetadata {
|
|
||||||
/** Filter ID (e.g., "providers", "categories", "subsets") */
|
|
||||||
id: string;
|
|
||||||
|
|
||||||
/** Display name (e.g., "Font Providers", "Categories", "Character Subsets") */
|
|
||||||
name: string;
|
|
||||||
|
|
||||||
/** Filter description */
|
|
||||||
description: string;
|
|
||||||
|
|
||||||
/** Filter type */
|
|
||||||
type: 'enum' | 'string' | 'array';
|
|
||||||
|
|
||||||
/** Available filter options */
|
|
||||||
options: FilterOption[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Filter option type
|
|
||||||
*/
|
|
||||||
export interface FilterOption {
|
|
||||||
/** Option ID (e.g., "google", "serif", "latin") */
|
|
||||||
id: string;
|
|
||||||
|
|
||||||
/** Display name (e.g., "Google Fonts", "Serif", "Latin") */
|
|
||||||
name: string;
|
|
||||||
|
|
||||||
/** Option value (e.g., "google", "serif", "latin") */
|
|
||||||
value: string;
|
|
||||||
|
|
||||||
/** Number of fonts with this value */
|
|
||||||
count: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Proxy filters API response
|
|
||||||
*/
|
|
||||||
export interface ProxyFiltersResponse {
|
|
||||||
/** Array of filter metadata */
|
|
||||||
filters: FilterMetadata[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch filters from proxy API
|
|
||||||
*
|
|
||||||
* @returns Promise resolving to array of filter metadata
|
|
||||||
* @throws ApiError when request fails
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```ts
|
|
||||||
* // Fetch all filters
|
|
||||||
* const filters = await fetchProxyFilters();
|
|
||||||
*
|
|
||||||
* console.log(filters); // [
|
|
||||||
* // { id: "providers", name: "Font Providers", options: [...] },
|
|
||||||
* // { id: "categories", name: "Categories", options: [...] },
|
|
||||||
* // { id: "subsets", name: "Character Subsets", options: [...] }
|
|
||||||
* // ]
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export async function fetchProxyFilters(): Promise<FilterMetadata[]> {
|
|
||||||
const response = await api.get<FilterMetadata[]>(PROXY_API_URL);
|
|
||||||
|
|
||||||
if (!response.data || !Array.isArray(response.data)) {
|
|
||||||
throw new Error('Proxy API returned invalid response');
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user