Compare commits

15 Commits

Author SHA1 Message Date
ilia b4152983d1 Merge pull request 'chore(SetupFont): rename controlManager to typographySettingsStore for better semantic' (#37) from feature/minor-improvements into main
Workflow / build (push) Failing after 6m18s
Workflow / publish (push) Has been skipped
Reviewed-on: #37
2026-04-23 10:34:03 +00:00
Ilia Mashkov 2d8c469275 chore(Footer): move components to separate directories
Workflow / build (pull_request) Failing after 8m41s
Workflow / publish (pull_request) Has been skipped
2026-04-23 13:19:35 +03:00
Ilia Mashkov c79eb3f815 feat(FooterLink): move FooterLink to the Footer widget layer, delete the one in shared/ui 2026-04-23 13:19:35 +03:00
Ilia Mashkov 7a510e7acf feat(Footer): tweak the footer position 2026-04-23 13:19:35 +03:00
Ilia Mashkov 32b864f7d2 feat(Link): create reusable Link ui component 2026-04-23 13:19:35 +03:00
Ilia Mashkov 1df03637a9 feat(ComparisonView): replace window resize listener with ResiseObserver on the container to catch the container size change on sidebar open/close 2026-04-23 12:45:13 +03:00
Ilia Mashkov cb7218cf3d feat: brand colored text selection 2026-04-23 10:08:44 +03:00
Ilia Mashkov a68f754b1c feat: replace clsx with cn util 2026-04-23 09:48:32 +03:00
Ilia Mashkov 1332b45bc1 feat(SliderArea): tweak the styles 2026-04-23 09:42:59 +03:00
Ilia Mashkov 0d4d91ab8f feat(FooterLink): tweak the styles 2026-04-23 09:42:33 +03:00
Ilia Mashkov 734ca2ce1b feat(Footer): change the footer styles and layout to avoid overlapping with the TypographyMenu 2026-04-23 09:41:31 +03:00
Ilia Mashkov 16c2fda843 feat(shared): add cn utility for tailwind-aware class merging 2026-04-23 09:38:30 +03:00
Ilia Mashkov aea9fde9ff fix: workflow 2026-04-22 16:11:05 +03:00
Ilia Mashkov 7e847640ad test: add timeout to fail the test instead of OOM 2026-04-22 15:24:28 +03:00
Ilia Mashkov f97afc2425 fix(createVirtualizer): add window check to resolve the ReferenceError 2026-04-22 13:37:23 +03:00
273 changed files with 4525 additions and 8378 deletions
-9
View File
@@ -1,9 +0,0 @@
node_modules
.yarn/cache
.yarn/unplugged
.yarn/install-state.gz
dist
.git
.gitea
.svelte-kit
storybook-static
+2 -32
View File
@@ -50,34 +50,8 @@ jobs:
timeout-minutes: 5
run: yarn test:component --reporter=verbose --logHeapUsage
e2e:
needs: build
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.59.0-jammy
steps:
- uses: actions/checkout@v4
- name: Enable Corepack
run: |
corepack enable
corepack prepare yarn@stable --activate
- name: Persistent Yarn Cache
uses: actions/cache@v4
with:
path: .yarn/cache
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: ${{ runner.os }}-yarn-
- name: Install dependencies
run: yarn install --immutable
- name: Build Svelte SPA
run: yarn build
- name: E2E Tests
timeout-minutes: 15
run: yarn test:e2e
publish:
# Runs if lint, unit-, component-, e2e-tests pass
needs: [build, e2e]
needs: build # Only runs if tests/lint pass
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' # Only deploy from main branch
steps:
@@ -89,9 +63,5 @@ jobs:
- name: Build and Push Docker Image
run: |
docker build \
-t git.allmy.work/${{ gitea.repository }}:latest \
-t git.allmy.work/${{ gitea.repository }}:${{ gitea.sha }} \
.
docker build -t git.allmy.work/${{ gitea.repository }}:latest .
docker push git.allmy.work/${{ gitea.repository }}:latest
docker push git.allmy.work/${{ gitea.repository }}:${{ gitea.sha }}
-6
View File
@@ -10,9 +10,6 @@ node_modules
/build
/dist
# IDE settings
.vscode
# Git worktrees (isolated development branches)
.worktrees
@@ -50,6 +47,3 @@ storybook-static
# Tests
coverage/
.aider*
playwright-report/
blob-report/
.playwright/
-195
View File
@@ -1,195 +0,0 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["import"],
"categories": {
"correctness": "error",
"suspicious": "warn",
"perf": "warn",
// style/restriction off: opt-in, contradictory grab-bags. Wanted rules enabled individually below.
"style": "off",
"restriction": "off"
},
"env": {
"browser": true,
"es2021": true
},
"ignorePatterns": [
"node_modules",
"dist",
"build",
".svelte-kit",
".vercel",
"*.config.js",
"*.config.ts"
],
"rules": {
"no-console": "warn",
"no-debugger": "error",
"no-alert": "warn",
// no-cycle resolves $-aliases via tsconfig auto-discovery (no resolver config in oxlint)
"import/no-cycle": "error",
"import/no-duplicates": "warn",
"import/no-unassigned-import": "off", // CSS/side-effect imports are intentional
"no-sequences": "error",
"no-underscore-dangle": "off",
"no-shadow": "warn",
"no-implicit-coercion": "warn",
"no-await-in-loop": "warn",
"no-return-assign": "warn",
"no-new": "warn",
"no-unneeded-ternary": "warn"
},
// FSD boundaries. oxlint has no zone rule, so layer/segment direction is enforced
// with no-restricted-imports patterns scoped per glob. Layer order (high->low):
// app(exempt top shell) > routes > widgets > features > entities > shared.
// A layer bans imports from itself (cross-slice via alias) and every layer above.
// Overrides are LAST-WINS, not merged: a file matching two overrides keeps only the
// last rule config. So the domain override (below) is a self-contained superset, and
// the test/story override (last) fully disables boundary checks for those files.
"overrides": [
// shared = lowest layer: imports nothing above it
{
"files": ["src/shared/**"],
"rules": {
"no-restricted-imports": ["error", {
"patterns": [
{
"group": [
"$app",
"$app/*",
"$routes",
"$routes/*",
"$widgets",
"$widgets/*",
"$features",
"$features/*",
"$entities",
"$entities/*"
],
"message": "FSD layer violation: `shared` is the lowest layer and may not import from any layer above it."
}
]
}]
}
},
// entities: import shared only; no other entity via alias; interior ui<-only-ui
{
"files": ["src/entities/**"],
"rules": {
"no-restricted-imports": ["error", {
"patterns": [
{
"group": ["$app", "$app/*", "$routes", "$routes/*", "$widgets", "$widgets/*", "$features", "$features/*"],
"message": "FSD layer violation: `entities` may only import from `shared`."
},
{
"group": ["$entities", "$entities/*"],
"message": "FSD cross-slice violation: do not import another entity via its alias. Use relative imports inside your own slice; invert the dependency through a higher layer for cross-slice needs."
},
{
"group": ["../ui", "../ui/*", "../../ui/*"],
"message": "FSD segment violation: only `ui` may import `ui`. Interior direction is ui -> model -> domain."
}
]
}]
}
},
// features: import entities/shared only; no other feature via alias
{
"files": ["src/features/**"],
"rules": {
"no-restricted-imports": ["error", {
"patterns": [
{
"group": ["$app", "$app/*", "$routes", "$routes/*", "$widgets", "$widgets/*"],
"message": "FSD layer violation: `features` may only import from `entities` and `shared`."
},
{
"group": ["$features", "$features/*"],
"message": "FSD cross-slice violation: do not import another feature via its alias. Invert the dependency through a higher layer (widget/route)."
},
{
"group": ["../ui", "../ui/*", "../../ui/*"],
"message": "FSD segment violation: only `ui` may import `ui`. Interior direction is ui -> model -> domain."
}
]
}]
}
},
// widgets: import features/entities/shared only; no other widget via alias
{
"files": ["src/widgets/**"],
"rules": {
"no-restricted-imports": ["error", {
"patterns": [
{
"group": ["$app", "$app/*", "$routes", "$routes/*"],
"message": "FSD layer violation: `widgets` may only import from `features`, `entities`, and `shared`."
},
{
"group": ["$widgets", "$widgets/*"],
"message": "FSD cross-slice violation: do not import another widget via its alias. Invert the dependency through the route layer."
},
{
"group": ["../ui", "../ui/*", "../../ui/*"],
"message": "FSD segment violation: only `ui` may import `ui`. Interior direction is ui -> model -> domain."
}
]
}]
}
},
// routes: top of the FSD list, imports any layer below; only app is above it
{
"files": ["src/routes/**"],
"rules": {
"no-restricted-imports": ["error", {
"patterns": [
{ "group": ["$app", "$app/*"], "message": "FSD layer violation: `routes` may not import from `app`." }
]
}]
}
},
// domain (FSD+): pure logic. Imports NO layer (not even shared) and no sibling
// model/ui segment. Superset: wins over the layer override above for these files.
{
"files": ["src/**/domain/**"],
"rules": {
"no-restricted-imports": ["error", {
"patterns": [
{
"group": [
"$app",
"$app/*",
"$routes",
"$routes/*",
"$widgets",
"$widgets/*",
"$features",
"$features/*",
"$entities",
"$entities/*",
"$shared",
"$shared/*"
],
"message": "FSD+ domain isolation: `domain` is pure business logic and may not import any layer (including `shared`). Allowed: relative imports within `domain` and framework-agnostic npm packages."
},
{
"group": ["../model", "../model/*", "../../model/*", "../ui", "../ui/*", "../../ui/*"],
"message": "FSD+ domain isolation: `domain` may not import sibling `model` or `ui` segments. Dependency flows ui -> model -> domain, never back."
}
]
}]
}
},
// tests/stories/fixtures legitimately cross-import (e.g. $entities/Font/testing).
// Must be LAST so last-wins disables boundary checks for them.
{
"files": ["**/*.test.ts", "**/*.spec.ts", "**/*.stories.svelte", "src/**/testing/**"],
"rules": {
"no-restricted-imports": "off"
}
}
]
}
+1 -24
View File
@@ -1,28 +1,5 @@
:3000 {
root * /usr/share/caddy
# Compress text responses only. woff2/png and other binaries are already
# compressed, so they're excluded — re-compressing them burns CPU for ~0%.
encode {
zstd
gzip
match {
header Content-Type text/*
header Content-Type application/javascript*
header Content-Type application/json*
header Content-Type image/svg+xml*
}
}
# Vite emits all build output under /assets/ with content-hashed filenames,
# so those bytes never change for a given URL — cache them indefinitely.
@assets path /assets/*
header @assets Cache-Control "public, max-age=31536000, immutable"
# The HTML shell is the un-hashed entry point; it must revalidate so a new
# deploy is served immediately rather than from a stale cache.
header /index.html Cache-Control "no-cache"
try_files {path} /index.html
file_server
try_files {path} /index.html
}
+8 -3
View File
@@ -1,22 +1,27 @@
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
# Enable Corepack so we can use Yarn v4 (pinned to match lockfile)
RUN corepack enable && corepack prepare yarn@4.11.0 --activate
# 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 && ls -la dist
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"]
+592
View File
@@ -0,0 +1,592 @@
# Git Workflow and Branching Strategy
This document outlines the git workflow, branching strategy, commit conventions, and code review guidelines for the glyphdiff.com project.
## Table of Contents
1. [Branching Strategy](#branching-strategy)
2. [Branch Naming Conventions](#branch-naming-conventions)
3. [Commit Message Conventions](#commit-message-conventions)
4. [Code Splitting and Merge Request Guidelines](#code-splitting-and-merge-request-guidelines)
5. [Branch Protection Rules](#branch-protection-rules)
6. [Git Hooks Configuration](#git-hooks-configuration)
---
## Branching Strategy
We use a Gitflow-inspired branching strategy adapted for our development workflow. This strategy provides a clear structure for feature development, bug fixes, and releases.
### Branch Types
#### 1. `main` Branch
- **Purpose**: Production-ready code only
- **Protection**: Highest level of protection
- **Rules**:
- Only merge `release/*` or `hotfix/*` branches into `main`
- No direct commits allowed
- Must pass all tests and code reviews
- Tags are created from this branch for releases (e.g., `v1.0.0`)
#### 2. `develop` Branch
- **Purpose**: Integration branch for features
- **Protection**: High level of protection
- **Rules**:
- Merge `feature/*` and `fix/*` branches into `develop`
- No direct commits allowed
- Must pass all tests before merging
- Serves as the base for `release/*` branches
#### 3. `feature/*` Branches
- **Purpose**: Develop new features
- **Naming**: `feature/feature-name` (e.g., `feature/font-catalog`, `feature/comparison-grid`)
- **Base**: Always branch from `develop`
- **Merge**: Merge back into `develop` via Merge Request (MR)
- **Rules**:
- One feature per branch
- Keep branches focused and small
- Delete after merging
#### 4. `fix/*` Branches
- **Purpose**: Fix bugs discovered during development
- **Naming**: `fix/issue-description` (e.g., `fix/font-loading-error`, `fix/responsive-layout`)
- **Base**: Branch from `develop`
- **Merge**: Merge back into `develop` via MR
- **Rules**:
- One fix per branch
- Include tests that verify the fix
- Delete after merging
#### 5. `hotfix/*` Branches
- **Purpose**: Critical fixes for production issues
- **Naming**: `hotfix/critical-fix` (e.g., `hotfix/security-patch`, `hotfix-production-crash`)
- **Base**: Branch from `main`
- **Merge**: Merge into both `main` and `develop`
- **Rules**:
- Use only for production emergencies
- Must be thoroughly tested
- Create a release tag after merging to `main`
#### 6. `release/*` Branches
- **Purpose**: Prepare for a new release
- **Naming**: `release/vX.Y.Z` (e.g., `release/v1.0.0`, `release/v1.1.0`)
- **Base**: Branch from `develop`
- **Merge**: Merge into both `main` and `develop`
- **Rules**:
- Finalize release notes
- Update version numbers
- Perform final testing
- Create release tag after merging to `main`
### Branch Workflow Diagram
```
main (production)
│ hotfix/*, release/*
develop (integration)
│ feature/*, fix/*
feature branches
```
---
## Branch Naming Conventions
### Feature Branches
- Format: `feature/feature-name`
- Examples:
- `feature/font-catalog`
- `feature/comparison-grid`
- `feature/dark-mode`
- `feature/google-fonts-integration`
### Fix Branches
- Format: `fix/issue-description`
- Examples:
- `fix/font-loading-error`
- `fix/responsive-layout`
- `fix/state-persistence`
- `fix-accessibility-contrast`
### Hotfix Branches
- Format: `hotfix/critical-fix`
- Examples:
- `hotfix/security-patch`
- `hotfix-production-crash`
- `hotfix-api-rate-limit`
### Release Branches
- Format: `release/vX.Y.Z`
- Examples:
- `release/v1.0.0`
- `release/v1.1.0`
- `release/v2.0.0`
### Naming Guidelines
- Use lowercase letters
- Use hyphens to separate words
- Be descriptive but concise
- Avoid special characters (except hyphens)
- Keep names under 50 characters
---
## Commit Message Conventions
We follow the [Conventional Commits](https://www.conventionalcommits.org/) specification. This format enables automated changelog generation and better commit history readability.
### Format
```
<type>(<scope>): <subject>
<body>
<footer>
```
### Commit Types
| Type | Description | Examples |
|------|-------------|----------|
| `feat` | New feature | `feat(fonts): add Google Fonts integration` |
| `fix` | Bug fix | `fix(comparison): resolve font loading race condition` |
| `docs` | Documentation changes | `docs(readme): update installation instructions` |
| `style` | Code style changes (formatting, etc.) | `style(components): format with Prettier` |
| `refactor` | Code refactoring | `refactor(stores): simplify state management` |
| `test` | Adding or updating tests | `test(fonts): add unit tests for font mapper` |
| `chore` | Maintenance tasks | `chore(deps): update Tailwind CSS to v4.0` |
| `perf` | Performance improvements | `perf(catalog): implement lazy loading for fonts` |
### Scope
The scope provides context about which part of the codebase is affected. Common scopes for this project:
- `fonts` - Font-related functionality
- `comparison` - Font comparison features
- `catalog` - Font catalog pages
- `stores` - State management stores
- `components` - UI components
- `routes` - SvelteKit routes
- `services` - External API services
- `utils` - Utility functions
- `types` - TypeScript type definitions
- `ui` - UI-related changes (theme, layout, etc.)
- `config` - Configuration files
### Subject
- Use imperative mood ("add" not "added", "fix" not "fixed")
- Keep it short (50 characters or less)
- Don't end with a period
- Be specific and descriptive
### Body
- Use imperative mood
- Explain **what** and **why**, not **how**
- Wrap at 72 characters
- Include references to issues (e.g., `Closes #123`)
### Footer
- Reference breaking changes with `BREAKING CHANGE:`
- Reference issues with `Closes #123` or `Fixes #456`
- Include co-authors if needed
### Examples
#### Feature Commit
```
feat(fonts): add Google Fonts API integration
Implement Google Fonts API service to fetch and display available fonts.
This includes the fetchGoogleFonts function and font mapper utilities.
Closes #12
```
#### Bug Fix Commit
```
fix(comparison): resolve font loading race condition
The comparison grid was attempting to render fonts before they were fully
loaded. Added loading state checks to prevent this issue.
Fixes #45
```
#### Refactor Commit
```
refactor(stores): simplify state management with Svelte 5 runes
Migrated from Svelte stores to Svelte 5's $state runes for better
performance and simpler code. This change affects all stores in the
project.
BREAKING CHANGE: Store API has changed from subscribe() to direct
property access. Update all store consumers accordingly.
```
#### Documentation Commit
```
docs(git-workflow): add commit message conventions
Document the conventional commits format with examples and guidelines
for the team.
```
#### Chore Commit
```
chore(deps): update Tailwind CSS to v4.0.0
Update Tailwind CSS to the latest version and adjust configuration
files accordingly.
```
---
## Code Splitting and Merge Request Guidelines
### Merge Request Size Guidelines
- **Maximum MR size**: < 500 lines changed (additions + deletions)
- **Ideal MR size**: 100-300 lines changed
- **Files per MR**: < 10 files
### When to Split a Feature into Multiple MRs
Split a feature into multiple MRs when:
1. **The feature is large** (> 500 lines or > 10 files)
2. **Multiple concerns are involved** (e.g., UI + API + state management)
3. **Independent parts can be tested separately**
4. **The feature has logical phases** (e.g., setup → implementation → polish)
### Example: Splitting a Feature
**Feature**: Font Catalog with Filtering
**MR 1**: `feature/font-catalog-setup`
- Create basic catalog page structure
- Set up routing
- Add placeholder components
- ~150 lines
**MR 2**: `feature/font-catalog-data`
- Implement Google Fonts API integration
- Create font data fetching logic
- Add font mapper utilities
- ~200 lines
**MR 3**: `feature/font-catalog-ui`
- Build FontCard component
- Implement grid layout
- Add loading states
- ~250 lines
**MR 4**: `feature/font-catalog-filtering`
- Implement filter store
- Build FilterBar component
- Connect filters to catalog
- ~180 lines
### Merge Request Description Template
Every MR must include a comprehensive description:
```markdown
## Description
Brief description of what this MR changes and why.
## Changes Made
- [ ] Change 1
- [ ] Change 2
- [ ] Change 3
## Type of Change
- [ ] Bug fix
- [ ] New feature
- [ ] Breaking change
- [ ] Documentation update
- [ ] Refactoring
- [ ] Performance improvement
## Testing
- [ ] Unit tests pass
- [ ] Manual testing completed
- [ ] Tested on Chrome
- [ ] Tested on Firefox
- [ ] Tested on Safari
- [ ] Tested on mobile (responsive)
## Screenshots (if applicable)
Add screenshots or GIFs showing the changes.
## Checklist
- [ ] Code follows project style guidelines
- [ ] Self-review completed
- [ ] Comments added for complex logic
- [ ] Documentation updated
- [ ] No new warnings generated
- [ ] Tests added/updated
- [ ] All tests passing
## Related Issues
Closes #123
Related to #456
```
### Code Review Checklist
Reviewers should check:
#### Functionality
- [ ] Does the code work as intended?
- [ ] Are edge cases handled?
- [ ] Is error handling appropriate?
#### Code Quality
- [ ] Is the code readable and maintainable?
- [ ] Are variable/function names descriptive?
- [ ] Is there unnecessary complexity?
- [ ] Are there code duplications?
#### Best Practices
- [ ] Does it follow project conventions?
- [ ] Are TypeScript types properly defined?
- [ ] Are Svelte best practices followed?
- [ ] Is Tailwind CSS used appropriately?
#### Testing
- [ ] Are tests included?
- [ ] Do tests cover edge cases?
- [ ] Are tests meaningful and not redundant?
#### Documentation
- [ ] Is the code self-documenting?
- [ ] Are complex functions commented?
- [ ] Is the MR description clear?
#### Performance
- [ ] Are there performance concerns?
- [ ] Is lazy loading used where appropriate?
- [ ] Are unnecessary re-renders avoided?
### Merge Request Approval Process
1. **Author**: Creates MR with complete description
2. **Reviewer**: Reviews code using the checklist above
3. **Discussion**: Address any concerns or suggestions
4. **Approval**: At least one approval required
4. **Merge**: Squash and merge into target branch
5. **Cleanup**: Delete source branch after merge
---
## Branch Protection Rules
### `main` Branch Protection
- **Require pull request reviews**: Yes
- Required approvers: 1
- Dismiss stale reviews: Yes
- **Require status checks**: Yes
- Required checks: All tests, linting
- Require branches to be up to date: Yes
- **Restrict who can push**: Only maintainers
- **Require linear history**: Yes (squash and merge)
- **Block force pushes**: Yes
### `develop` Branch Protection
- **Require pull request reviews**: Yes
- Required approvers: 1
- Dismiss stale reviews: Yes
- **Require status checks**: Yes
- Required checks: All tests, linting
- Require branches to be up to date: Yes
- **Restrict who can push**: Only developers and maintainers
- **Require linear history**: Yes (squash and merge)
- **Block force pushes**: Yes
### Implementation Notes
These rules should be configured in your Git hosting platform (GitHub, GitLab, or Bitbucket). The exact configuration steps vary by platform:
- **GitHub**: Settings → Branches → Add rule
- **GitLab**: Settings → Repository → Protected branches
- **Bitbucket**: Repository settings → Branch restrictions
---
## Git Hooks Configuration
Git hooks are automated scripts that run at specific points in the git workflow. They help maintain code quality and consistency.
### Recommended Hooks
#### 1. Pre-commit Hook
**Purpose**: Run linter and formatter before committing
**Tools**: ESLint, Prettier
**Implementation**:
```bash
#!/bin/sh
# .git/hooks/pre-commit
# Run Prettier
npm run format:check
# Run ESLint
npm run lint
# Exit with error if any check fails
if [ $? -ne 0 ]; then
echo "❌ Pre-commit checks failed. Please fix the issues before committing."
exit 1
fi
echo "✅ Pre-commit checks passed."
```
**Setup**:
```bash
# Install husky (recommended)
npm install --save-dev husky
# Initialize husky
npx husky install
# Add pre-commit hook
npx husky add .husky/pre-commit "npm run lint && npm run format:check"
```
#### 2. Commit-msg Hook
**Purpose**: Validate commit message format
**Tools**: commitlint
**Implementation**:
```bash
#!/bin/sh
# .git/hooks/commit-msg
# Validate commit message with commitlint
npx --no -- commitlint --edit $1
```
**Setup**:
```bash
# Install commitlint
npm install --save-dev @commitlint/cli @commitlint/config-conventional
# Create commitlint config
echo "module.exports = { extends: ['@commitlint/config-conventional'] };" > commitlint.config.js
# Add commit-msg hook
npx husky add .husky/commit-msg "npx --no -- commitlint --edit \$1"
```
#### 3. Pre-push Hook
**Purpose**: Run tests before pushing
**Tools**: Vitest, SvelteKit test runner
**Implementation**:
```bash
#!/bin/sh
# .git/hooks/pre-push
# Run tests
npm run test
# Exit with error if tests fail
if [ $? -ne 0 ]; then
echo "❌ Tests failed. Please fix the failing tests before pushing."
exit 1
fi
echo "✅ All tests passed."
```
**Setup**:
```bash
# Add pre-push hook
npx husky add .husky/pre-push "npm run test"
```
### Alternative: Using Husky
[Husky](https://typicode.github.io/husky/) is a popular tool for managing git hooks. It's easier to maintain and works across different operating systems.
**Installation**:
```bash
npm install --save-dev husky
npx husky install
npm pkg set scripts.prepare="husky install"
```
**Adding hooks**:
```bash
# Pre-commit hook
npx husky add .husky/pre-commit "npm run lint && npm run format:check"
# Commit-msg hook
npx husky add .husky/commit-msg "npx --no -- commitlint --edit \$1"
# Pre-push hook
npx husky add .husky/pre-push "npm run test"
```
### Hook Scripts for This Project
Once the project is set up with SvelteKit, add these scripts to `package.json`:
```json
{
"scripts": {
"format": "prettier --write .",
"format:check": "prettier --check .",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"test": "vitest run",
"test:watch": "vitest",
"prepare": "husky install"
}
}
```
### Benefits of Git Hooks
1. **Consistency**: Enforce code style and formatting
2. **Quality**: Catch bugs before they're committed
3. **Efficiency**: Fail fast, fix early
4. **Automation**: Reduce manual checks
5. **Team alignment**: Ensure everyone follows the same standards
---
## Summary
This git workflow provides a structured approach to development for the glyphdiff.com project:
- **Clear branching strategy** with defined purposes for each branch type
- **Conventional commits** for readable and automated changelogs
- **Code splitting guidelines** to keep MRs focused and reviewable
- **Comprehensive review process** to maintain code quality
- **Git hooks** to automate quality checks
Following this workflow will help the team:
- Develop features in parallel without conflicts
- Maintain a clean git history
- Catch issues early in the development process
- Ensure code quality and consistency
- Streamline the release process
For questions or suggestions about this workflow, please discuss with the team or create an issue in the project repository.
+5 -4
View File
@@ -13,7 +13,7 @@
"https://plugins.dprint.dev/typescript-0.93.0.wasm",
"https://plugins.dprint.dev/json-0.19.3.wasm",
"https://plugins.dprint.dev/markdown-0.17.8.wasm",
"https://plugins.dprint.dev/g-plane/markup_fmt-v0.27.0.wasm"
"https://plugins.dprint.dev/g-plane/markup_fmt-v0.25.3.wasm"
],
"typescript": {
"lineWidth": 120,
@@ -57,8 +57,9 @@
"quotes": "double",
"scriptIndent": false,
"styleIndent": false,
"formatComments": true,
"svelteAttrShorthand": true,
"svelteDirectiveShorthand": true
"vBindStyle": "short",
"vOnStyle": "short",
"formatComments": true
}
}
-39
View File
@@ -1,39 +0,0 @@
import {
expect,
test,
} from './fixtures';
test.describe('compare flow', () => {
test('selects fontA and fontB onto opposite sides', async ({ comparison }) => {
await comparison.pickPair('Inter', 'Roboto');
// Each side's header region exposes the font name independently.
await expect(comparison.primaryFont).toContainText('Inter');
await expect(comparison.secondaryFont).toContainText('Roboto');
// Slider is rendered and interactive once both fonts are picked.
await expect(comparison.slider).toBeVisible();
});
test('reflects active side via aria-pressed', async ({ comparison }) => {
await comparison.selectSide('B');
expect(await comparison.activeSide()).toBe('B');
await expect(comparison.secondarySideButton).toHaveAttribute('aria-pressed', 'true');
await expect(comparison.primarySideButton).toHaveAttribute('aria-pressed', 'false');
});
test('persists selection through the comparisonStore localStorage', async ({ comparison }) => {
await comparison.pickPair('Inter', 'Roboto');
// Wait for the store debounce to flush to localStorage.
await expect.poll(async () => {
const storage = await comparison.readStorage();
return storage['glyphdiff:comparison'];
}).toMatch(/inter/i);
const storage = await comparison.readStorage();
const state = JSON.parse(storage['glyphdiff:comparison']!);
expect(state.fontAId).toBe('inter');
expect(state.fontBId).toBe('roboto');
});
});
-35
View File
@@ -1,35 +0,0 @@
import { test as base } from '@playwright/test';
import { ComparisonPage } from './pages/comparison-page';
import { TypographyMenu } from './pages/typography-menu';
type Fixtures = {
/**
* Opened ComparisonPage with the root view loaded.
*/
comparison: ComparisonPage;
/**
* Typography menu helper bound to the same page.
*/
typography: TypographyMenu;
};
/**
* Custom test that auto-opens the comparison view before each spec.
* Playwright gives each test a fresh BrowserContext by default, so
* localStorage is empty unless a test seeds it.
*/
export const test = base.extend<Fixtures>({
comparison: async ({ page }, use) => {
const view = new ComparisonPage(page);
await view.open();
await use(view);
},
// Depends on `comparison` so the root page is opened before the menu is
// consulted — TypographyMenu has no markup of its own to load.
typography: async ({ comparison, page }, use) => {
void comparison;
await use(new TypographyMenu(page));
},
});
export { expect } from '@playwright/test';
-22
View File
@@ -1,22 +0,0 @@
import {
expect,
test,
} from './fixtures';
test.describe('font loading', () => {
test('selected fonts land in the FontFaceSet with status="loaded"', async ({ comparison }) => {
await comparison.pickPair('Inter', 'Roboto');
await expect.poll(() => comparison.fontLoaded('Inter')).toBe(true);
await expect.poll(() => comparison.fontLoaded('Roboto')).toBe(true);
});
test('an unrelated font remains absent from the FontFaceSet', async ({ comparison }) => {
await comparison.pickPair('Inter', 'Roboto');
// "Audiowide" is unlikely to be on the system AND was not selected, so
// no FontFace should ever have been registered for it. This guards
// against the loader over-fetching neighbouring fonts.
await expect.poll(() => comparison.fontLoaded('Audiowide')).toBe(false);
});
});
-16
View File
@@ -1,16 +0,0 @@
import type { Page } from '@playwright/test';
/**
* Shared base for all page objects. Subclasses extend this and expose
* domain-specific locators + actions — never raw selectors leaking into tests.
*/
export abstract class BasePage {
protected constructor(protected readonly page: Page) {}
/**
* Navigate to a path relative to baseURL.
*/
async goto(path = '/') {
await this.page.goto(path);
}
}
-144
View File
@@ -1,144 +0,0 @@
import type {
Locator,
Page,
} from '@playwright/test';
import { BasePage } from './base-page';
/**
* Page object for the root comparison view. Encapsulates locators for the
* primary controls so tests don't hardcode aria-labels or DOM structure.
*
* Selection flow: clicking a font row assigns it to whichever side
* (`A` = "Left Font" / Primary, `B` = "Right Font" / Secondary) is currently
* active in the Sidebar — there's no per-row A/B toggle.
*/
export class ComparisonPage extends BasePage {
readonly searchInput: Locator;
readonly previewInput: Locator;
readonly slider: Locator;
readonly primarySideButton: Locator;
readonly secondarySideButton: Locator;
readonly primaryFont: Locator;
readonly secondaryFont: Locator;
readonly fontList: Locator;
constructor(page: Page) {
super(page);
this.searchInput = page.getByRole('textbox', { name: 'Search typefaces' });
this.previewInput = page.getByRole('textbox', { name: 'Preview text' });
this.slider = page.getByRole('slider', { name: 'Font comparison slider' });
// ARIA-controls couples the side toggle to the font display it targets — copy-independent.
this.primarySideButton = page.locator('[aria-controls="primary-font"]');
this.secondarySideButton = page.locator('[aria-controls="secondary-font"]');
this.primaryFont = page.locator('#primary-font');
this.secondaryFont = page.locator('#secondary-font');
this.fontList = page.locator('[data-font-list]');
}
/**
* Open the root page and wait for the main controls to be interactable.
* Uses lg+ viewport for the preview input to be visible.
*/
async open() {
await this.goto('/');
await this.searchInput.waitFor({ state: 'visible' });
}
async searchFor(query: string) {
await this.searchInput.fill(query);
}
async setPreviewText(text: string) {
await this.previewInput.fill(text);
}
/**
* Switch which side the next font click will assign to.
*/
async selectSide(side: 'A' | 'B') {
const button = side === 'A' ? this.primarySideButton : this.secondarySideButton;
await button.click();
}
/**
* Read which side is currently active from `aria-pressed`.
* Falls back to A when neither button reports pressed (initial state in some flows).
*/
async activeSide(): Promise<'A' | 'B' | null> {
const [primaryPressed, secondaryPressed] = await Promise.all([
this.primarySideButton.getAttribute('aria-pressed'),
this.secondarySideButton.getAttribute('aria-pressed'),
]);
if (primaryPressed === 'true') {
return 'A';
}
if (secondaryPressed === 'true') {
return 'B';
}
return null;
}
/**
* Search for a font and click the matching list row. The row's accessible
* name is the font name itself (rendered by FontApplicator).
*/
async pickFont(name: string) {
await this.searchFor(name);
const row = this.fontList.getByRole('button', { name, exact: true });
await row.click();
}
/**
* Assign fontA to side A and fontB to side B in one call.
*/
async pickPair(fontA: string, fontB: string) {
await this.selectSide('A');
await this.pickFont(fontA);
await this.selectSide('B');
await this.pickFont(fontB);
}
/**
* Read aria-valuenow off the comparison slider.
*/
async sliderValue(): Promise<number> {
const value = await this.slider.getAttribute('aria-valuenow');
return Number(value);
}
/**
* Snapshot the glyphdiff:* localStorage entries.
*/
async readStorage(): Promise<Record<string, string | null>> {
return await this.page.evaluate(() => {
const out: Record<string, string | null> = {};
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i)!;
if (key.startsWith('glyphdiff:')) {
out[key] = localStorage.getItem(key);
}
}
return out;
});
}
/**
* Whether the document.fonts FontFaceSet contains a fully-loaded face for
* the named family. Counts only faces registered via the FontFace API —
* system-installed fallbacks (which `document.fonts.check` honours) are
* excluded, so a `false` here is meaningful in negative assertions.
*/
async fontLoaded(name: string): Promise<boolean> {
return await this.page.evaluate(target => {
for (const face of document.fonts) {
// FontFace.family is wrapped in quotes only if the literal was;
// strip any surrounding quotes before comparing.
const family = face.family.replace(/^["']|["']$/g, '');
if (family === target && face.status === 'loaded') {
return true;
}
}
return false;
}, name);
}
}
-73
View File
@@ -1,73 +0,0 @@
import type {
Locator,
Page,
} from '@playwright/test';
/**
* Typography settings menu — desktop layout exposes inline ComboControls with
* increase/decrease buttons. The current value is encoded in the trigger
* button's aria-label as `${controlLabel}: ${value}` (e.g. "Size: 24").
*/
export type TypographyControl = 'size' | 'weight' | 'leading' | 'tracking';
const LABELS: Record<TypographyControl, { increase: string; decrease: string; trigger: string }> = {
size: {
increase: 'Increase Font Size',
decrease: 'Decrease Font Size',
trigger: 'Size',
},
weight: {
increase: 'Increase Font Weight',
decrease: 'Decrease Font Weight',
trigger: 'Weight',
},
leading: {
increase: 'Increase Line Height',
decrease: 'Decrease Line Height',
trigger: 'Leading',
},
tracking: {
increase: 'Increase Letter Spacing',
decrease: 'Decrease Letter Spacing',
trigger: 'Tracking',
},
};
export class TypographyMenu {
constructor(private readonly page: Page) {}
increase(control: TypographyControl): Locator {
return this.page.getByRole('button', { name: LABELS[control].increase });
}
decrease(control: TypographyControl): Locator {
return this.page.getByRole('button', { name: LABELS[control].decrease });
}
/**
* Trigger button whose aria-label encodes the current value, e.g. "Size: 24".
*/
trigger(control: TypographyControl): Locator {
return this.page.getByRole('button', { name: new RegExp(`^${LABELS[control].trigger}:\\s`) });
}
/**
* Parse the numeric value out of the trigger button's aria-label.
* Returns null if the label can't be read yet.
*/
async readValue(control: TypographyControl): Promise<number | null> {
const label = await this.trigger(control).getAttribute('aria-label');
if (!label) {
return null;
}
const match = label.match(/:\s*(-?\d+(?:\.\d+)?)/);
return match ? Number(match[1]) : null;
}
async bump(control: TypographyControl, direction: 'up' | 'down', times = 1) {
const button = direction === 'up' ? this.increase(control) : this.decrease(control);
for (let i = 0; i < times; i++) {
await button.click();
}
}
}
-41
View File
@@ -1,41 +0,0 @@
import {
expect,
test,
} from './fixtures';
test.describe('persistence', () => {
test('restores selected fonts after reload', async ({ comparison, page }) => {
await comparison.pickPair('Inter', 'Roboto');
// Confirm the store has flushed before reloading — otherwise we race
// the debounce and may reload with empty storage.
await expect.poll(async () => {
const storage = await comparison.readStorage();
return storage['glyphdiff:comparison'];
}).toMatch(/roboto/i);
await page.reload();
await comparison.searchInput.waitFor({ state: 'visible' });
await expect(comparison.primaryFont).toContainText('Inter');
await expect(comparison.secondaryFont).toContainText('Roboto');
});
test('restores typography settings after reload', async ({ comparison, typography, page }) => {
const baseline = await typography.readValue('size');
await typography.bump('size', 'up', 2);
const bumped = await typography.readValue('size');
expect(bumped).not.toBe(baseline);
await expect.poll(async () => {
const storage = await comparison.readStorage();
return storage['glyphdiff:comparison:typography'];
}).not.toBeNull();
await page.reload();
await comparison.searchInput.waitFor({ state: 'visible' });
expect(await typography.readValue('size')).toBe(bumped);
});
});
-32
View File
@@ -1,32 +0,0 @@
import { windowSizeForLine } from '../src/entities/Font/domain/windowSizeForLine/windowSizeForLine';
import {
expect,
test,
} from './fixtures';
test.describe('preview text', () => {
test('drives the slider character rendering', async ({ comparison }) => {
/**
* Must stay a single unwrapped line of ASCII: the assertion feeds
* `text.length` (UTF-16 code units) to `windowSizeForLine`, but the
* renderer feeds it the line's grapheme count. They match only for
* plain ASCII — emoji/combining marks (length > graphemes) or wrapping
* (one input string splitting into several lines) silently desync them.
*/
const text = 'Sphinx';
await comparison.pickPair('Inter', 'Roboto');
await comparison.setPreviewText(text);
// Window chars render as `.char-wrap` cells for crossfade. The window
// size is a pure function of the line's grapheme count — assert against
// the rule, not a hardcoded constant, so tuning the policy can't silently
// break this. "Sphinx" is one unwrapped line of 6 graphemes.
await expect(comparison.slider.locator('.char-wrap')).toHaveCount(windowSizeForLine(text.length));
});
test('preserves the typed value in the input', async ({ comparison }) => {
const text = 'Sphinx of black quartz';
await comparison.setPreviewText(text);
await expect(comparison.previewInput).toHaveValue(text);
});
});
-46
View File
@@ -1,46 +0,0 @@
import {
expect,
test,
} from './fixtures';
/**
* Slider position is spring-animated; aria-valuenow reflects the current
* value, not the target. All assertions use `toHaveAttribute` so Playwright
* polls until the spring settles.
*/
test.describe('comparison slider', () => {
test.beforeEach(async ({ comparison }) => {
await comparison.pickPair('Inter', 'Roboto');
await comparison.slider.focus();
});
test('keyboard navigation snaps to End and Home', async ({ comparison }) => {
await comparison.slider.press('End');
await expect(comparison.slider).toHaveAttribute('aria-valuenow', '100');
await comparison.slider.press('Home');
await expect(comparison.slider).toHaveAttribute('aria-valuenow', '0');
});
test('arrow keys nudge by one, Shift+Arrow by ten', async ({ comparison }) => {
await comparison.slider.press('Home');
await expect(comparison.slider).toHaveAttribute('aria-valuenow', '0');
await comparison.slider.press('ArrowRight');
await expect(comparison.slider).toHaveAttribute('aria-valuenow', '1');
await comparison.slider.press('Shift+ArrowRight');
await expect(comparison.slider).toHaveAttribute('aria-valuenow', '11');
});
test('PageUp / PageDown move by ten', async ({ comparison }) => {
await comparison.slider.press('Home');
await expect(comparison.slider).toHaveAttribute('aria-valuenow', '0');
await comparison.slider.press('PageUp');
await expect(comparison.slider).toHaveAttribute('aria-valuenow', '10');
await comparison.slider.press('PageDown');
await expect(comparison.slider).toHaveAttribute('aria-valuenow', '0');
});
});
-23
View File
@@ -1,23 +0,0 @@
import {
expect,
test,
} from '@playwright/test';
import { ComparisonPage } from './pages/comparison-page';
test.describe('smoke', () => {
test('loads the comparison view with its primary controls', async ({ page }) => {
const view = new ComparisonPage(page);
await view.open();
await expect(view.searchInput).toBeVisible();
await expect(view.previewInput).toBeVisible();
});
test('accepts a search query', async ({ page }) => {
const view = new ComparisonPage(page);
await view.open();
await view.searchFor('Inter');
await expect(view.searchInput).toHaveValue('Inter');
});
});
-44
View File
@@ -1,44 +0,0 @@
import {
expect,
test,
} from './fixtures';
import type { TypographyControl } from './pages/typography-menu';
/**
* Each control's trigger button advertises its current value via aria-label
* ("Size: 24"). We bump in one direction, then back, and assert the value
* tracks symmetrically.
*/
const controls: TypographyControl[] = ['size', 'weight', 'leading', 'tracking'];
test.describe('typography settings', () => {
for (const control of controls) {
test(`${control}: increase then decrease returns to baseline`, async ({ typography }) => {
const baseline = await typography.readValue(control);
expect(baseline).not.toBeNull();
await typography.bump(control, 'up');
const bumped = await typography.readValue(control);
expect(bumped).not.toBe(baseline);
expect(bumped! > baseline!).toBe(true);
await typography.bump(control, 'down');
const restored = await typography.readValue(control);
expect(restored).toBe(baseline);
});
}
test('font size step is reflected in the persisted typography state', async ({ comparison, typography }) => {
await typography.bump('size', 'up');
const expected = await typography.readValue('size');
await expect.poll(async () => {
const storage = await comparison.readStorage();
const raw = storage['glyphdiff:comparison:typography'];
if (!raw) {
return null;
}
return JSON.parse(raw).fontSize ?? null;
}).toBe(expected);
});
});
+27
View File
@@ -0,0 +1,27 @@
{
"categories": {
"correctness": "error",
"suspicious": "warn",
"perf": "warn",
"style": "warn",
"restriction": "error"
},
"env": {
"browser": true,
"es2021": true
},
"ignore": [
"node_modules",
"dist",
"build",
".svelte-kit",
".vercel",
"*.config.js",
"*.config.ts"
],
"rules": {
"no-console": "off",
"no-debugger": "error",
"no-alert": "warn"
}
}
+33 -37
View File
@@ -4,10 +4,6 @@
"version": "0.0.1",
"packageManager": "yarn@4.11.0",
"type": "module",
"sideEffects": [
"*.css",
"**/router.ts"
],
"scripts": {
"dev": "vite",
"build": "vite build",
@@ -31,45 +27,45 @@
"build-storybook": "storybook build"
},
"devDependencies": {
"@chromatic-com/storybook": "5.1.2",
"@internationalized/date": "3.12.1",
"@lucide/svelte": "^1.14.0",
"@playwright/test": "1.59.1",
"@storybook/addon-a11y": "10.3.6",
"@storybook/addon-docs": "10.3.6",
"@storybook/addon-svelte-csf": "5.1.2",
"@storybook/addon-vitest": "10.3.6",
"@storybook/svelte-vite": "10.3.6",
"@sveltejs/vite-plugin-svelte": "7.1.0",
"@tailwindcss/vite": "4.2.4",
"@chromatic-com/storybook": "^4.1.3",
"@internationalized/date": "^3.10.0",
"@lucide/svelte": "^0.561.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",
"@tailwindcss/vite": "^4.1.18",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/svelte": "^5.3.1",
"@tsconfig/svelte": "5.0.8",
"@types/jsdom": "28.0.1",
"@vitest/browser-playwright": "4.1.5",
"@vitest/coverage-v8": "4.1.5",
"@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",
"dprint": "0.54.0",
"jsdom": "29.1.1",
"lefthook": "2.1.6",
"oxlint": "1.62.0",
"playwright": "1.59.1",
"storybook": "10.3.6",
"svelte": "5.55.5",
"svelte-check": "4.4.8",
"svelte-language-server": "0.18.0",
"tailwind-merge": "3.5.0",
"dprint": "^0.50.2",
"jsdom": "^27.4.0",
"lefthook": "^2.0.13",
"oxlint": "^1.35.0",
"playwright": "^1.57.0",
"storybook": "^10.1.11",
"svelte": "^5.45.6",
"svelte-check": "^4.3.4",
"svelte-language-server": "^0.17.23",
"tailwind-merge": "^3.4.0",
"tailwind-variants": "^3.2.2",
"tailwindcss": "4.2.4",
"tailwindcss": "^4.1.18",
"tw-animate-css": "^1.4.0",
"typescript": "6.0.3",
"vite": "8.0.10",
"vitest": "4.1.5",
"vitest-browser-svelte": "2.1.1"
"typescript": "^5.9.3",
"vite": "^7.2.6",
"vitest": "^4.0.16",
"vitest-browser-svelte": "^2.0.1"
},
"dependencies": {
"@chenglou/pretext": "0.0.6",
"@tanstack/svelte-query": "6.1.28",
"sv-router": "^0.16.3"
"@chenglou/pretext": "^0.0.5",
"@tanstack/svelte-query": "^6.0.14"
}
}
+3 -47
View File
@@ -1,54 +1,10 @@
import {
defineConfig,
devices,
} from '@playwright/test';
/**
* E2E config. Tests run against the production build via `vite preview` on port 4173.
* Locally: all three browser engines run in parallel.
* CI: chromium only, workers=1 — the runner has 6GB RAM and `yarn build` already
* spikes 12GB, so we keep the E2E peak bounded.
*/
const isCI = !!process.env.CI;
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: 'e2e',
testMatch: /.*\.test\.ts$/,
fullyParallel: true,
forbidOnly: isCI,
retries: isCI ? 2 : 0,
workers: isCI ? 1 : undefined,
reporter: isCI
? [['html', { open: 'never' }], ['github']]
: [['html', { open: 'on-failure' }], ['list']],
use: {
baseURL: 'http://localhost:4173',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: isCI
? [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }]
: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
},
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
],
webServer: {
command: 'yarn build && yarn preview',
port: 4173,
reuseExistingServer: !isCI,
timeout: 120_000,
reuseExistingServer: true,
},
testDir: 'e2e',
});
+7 -13
View File
@@ -6,27 +6,21 @@
/**
* App Component
*
* Application entry point component. Wraps the active route within the shared
* 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
* - Router renders the matched route component
* - Page renders the current route content
*/
import '$routes/router';
import { Router } from 'sv-router';
import {
AppBindingsProvider,
QueryProvider,
} from './providers';
import Page from '$routes/Page.svelte';
import { QueryProvider } from './providers';
import Layout from './ui/Layout.svelte';
</script>
<QueryProvider>
<AppBindingsProvider>
<Layout>
<Router />
</Layout>
</AppBindingsProvider>
<Layout>
<Page />
</Layout>
</QueryProvider>
Binary file not shown.
Binary file not shown.
Binary file not shown.
-24
View File
@@ -1,24 +0,0 @@
<!--
Component: AppBindings
Provider that starts app-wide store bindings (filters → sort → font catalog)
for its subtree. Mount-scoped so the bindings' lifetime tracks the app tree.
-->
<script lang="ts">
import { startFilterBindings } from '$features/FilterAndSortFonts';
import { onMount } from 'svelte';
import type { Snippet } from 'svelte';
interface Props {
/**
* Content snippet
*/
children?: Snippet;
}
let { children }: Props = $props();
// startFilterBindings returns its $effect.root cleanup; onMount runs it on unmount.
onMount(() => startFilterBindings());
</script>
{@render children?.()}
+1 -4
View File
@@ -6,7 +6,7 @@
descendants of this provider.
-->
<script lang="ts">
import { getQueryClient } from '$shared/api/queryClient';
import { queryClient } from '$shared/api/queryClient';
import { QueryClientProvider } from '@tanstack/svelte-query';
import type { Snippet } from 'svelte';
@@ -18,9 +18,6 @@ interface Props {
}
let { children }: Props = $props();
// First call to the lazy singleton — constructs the shared client for the app.
const queryClient = getQueryClient();
</script>
<QueryClientProvider client={queryClient}>
-1
View File
@@ -1,2 +1 @@
export { default as AppBindingsProvider } from './AppBindings.svelte';
export { default as QueryProvider } from './QueryProvider.svelte';
+33 -165
View File
@@ -1,6 +1,5 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "./fonts.css";
@variant dark (&:where(.dark, .dark *));
@@ -15,13 +14,6 @@
--swiss-black: #1a1a1a;
--swiss-white: #ffffff;
/* Semantic mode-switching colors. These are redefined inside `.dark`
so utilities that reference them auto-adapt without a `dark:` variant. */
--color-border-subtle: var(--neutral-300);
--color-text-subtle: var(--neutral-500);
--color-skeleton: var(--neutral-200);
--color-grid-line: rgb(0 0 0 / 0.03);
/* Neutral Grays */
--neutral-50: #fafafa;
--neutral-100: #f5f5f5;
@@ -88,6 +80,16 @@
--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-xs: 0.75rem;
--text-sm: 0.875rem;
@@ -112,12 +114,6 @@
--color-surface: var(--dark-bg);
--color-paper: var(--dark-card);
/* Dark-mode overrides for the semantic mode-switching colors. */
--color-border-subtle: rgb(255 255 255 / 0.1);
--color-text-subtle: var(--neutral-400);
--color-skeleton: var(--neutral-800);
--color-grid-line: rgb(255 255 255 / 0.05);
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.145 0 0);
@@ -216,47 +212,6 @@
--text-2xs: 0.625rem;
/* Monospace label tracking — used in Loader and Footnote */
--tracking-wider-mono: 0.2em;
/* Shadow tokens */
/* Default resting shadow equivalent to Tailwind's shadow-sm. Used on
buttons, sliders, popover triggers in non-floating state. */
--shadow-rest: 0 1px 2px 0 rgb(0 0 0 / 0.05);
/* Swiss "hard offset" stamp rests at 2px/2px, lifts to 3px/3px on
hover, presses back to 1px/1px on active. Primary button motif. */
--shadow-stamp-rest: 0.125rem 0.125rem 0 0 rgb(0 0 0 / 0.1);
--shadow-stamp-hover: 0.1875rem 0.1875rem 0 0 rgb(0 0 0 / 0.15);
--shadow-stamp-pressed: 0.0625rem 0.0625rem 0 0 rgb(0 0 0 / 0.1);
/* Card-tier hard-offset stamp wider, brand-tinted. Used on
interactive cards (FontSampler hover). */
--shadow-stamp-card: 5px 5px 0 0 var(--color-brand);
/* Floating popovers (typography menu, combo control list). */
--shadow-popover: 0 20px 40px -10px rgb(0 0 0 / 0.15);
/* Drop-shadow under semi-translucent floating panels like the
comparison slider's character row. */
--shadow-floating-panel: 0 25px 50px -12px rgb(0 0 0 / 0.05);
--shadow-floating-panel-dark: 0 25px 50px -12px rgb(0 0 0 / 0.2);
/* Drawer / overlay shadow — full-strength shadow-2xl. */
--shadow-overlay: 0 25px 50px -12px rgb(0 0 0 / 0.25);
/* Motion tokens */
--duration-fast: 150ms;
--duration-normal: 200ms;
--duration-slow: 300ms;
--duration-slower: 500ms;
/* Tailwind's default ease-in-out — symmetric, good for layout shifts. */
--ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
/* Decelerating curve — matches Tailwind's ease-out. Dominant in this codebase. */
--ease-out-soft: cubic-bezier(0, 0, 0.2, 1);
/* Spring overshoot — used in character pop animation. */
--ease-spring-overshoot: cubic-bezier(0.34, 1.56, 0.64, 1);
}
@layer base {
@@ -271,7 +226,7 @@
body {
@apply bg-background text-foreground;
font-family: var(--font-secondary);
font-family: "Karla", system-ui, -apple-system, "Segoe UI", Inter, Roboto, Arial, sans-serif;
font-optical-sizing: auto;
}
@@ -322,111 +277,22 @@
}
}
/* Design-system utilities.
Defined via `@utility` (Tailwind v4) so they integrate with the variant
system (`hover:`, `dark:`, breakpoints) and don't rely on `@apply`
chains. Colors reference the mode-switching semantic vars defined in
`:root`/`.dark` above, so most utilities need no `dark:` variant in
their definition or at call sites. */
@utility border-subtle {
border-color: var(--color-border-subtle);
}
/* Same color as border-subtle, applied via background-color for 1px
dividers, inline separator strips, and other hairlines that aren't
element borders. */
@utility bg-subtle {
background-color: var(--color-border-subtle);
}
/* Muted text color paired with `border-subtle` naming. The previous
name `text-secondary` collided with Tailwind v4 auto-generating a
utility from `--color-secondary` (the shadcn near-white surface token
registered in `@theme`), which made every consumer effectively
invisible (near-white text on light backgrounds). */
@utility text-subtle {
color: var(--color-text-subtle);
}
@utility focus-ring {
&:focus-visible {
outline: 2px solid transparent;
outline-offset: 2px;
box-shadow: 0 0 0 2px var(--color-background, white), 0 0 0 4px var(--color-brand);
@layer utilities {
/* 21× border-black/5 dark:border-white/10 → single token */
.border-subtle {
@apply border-black/5 dark:border-white/10;
}
/* Secondary text pair */
.text-secondary {
@apply text-neutral-500 dark:text-neutral-400;
}
/* Standard focus ring */
.focus-ring {
@apply focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2;
}
}
/* Surface utilities */
@utility surface-canvas {
background-color: var(--color-surface);
}
@utility surface-card {
background-color: var(--color-paper);
border: 1px solid var(--color-border-subtle);
}
@utility surface-card-elevated {
background-color: var(--color-paper);
border: 1px solid var(--color-border-subtle);
box-shadow: var(--shadow-rest);
}
@utility surface-popover {
background-color: var(--color-paper);
border: 1px solid var(--color-border-subtle);
box-shadow: var(--shadow-popover);
}
@utility surface-floating {
background-color: color-mix(in srgb, var(--color-surface) 80%, transparent);
backdrop-filter: blur(12px);
border: 1px solid var(--color-border-subtle);
}
/* Shape / layout */
@utility flex-center {
display: flex;
align-items: center;
justify-content: center;
}
@utility skeleton-fill {
background-color: color-mix(in srgb, var(--color-skeleton) 70%, transparent);
}
/* Subtle dotted-grid overlay used as a decorative background on the
comparison paper surface. Color and intensity auto-switch via
--color-grid-line. `bg-grid-sm` uses a tighter cell typical mobile
choice; `bg-grid` is the default desktop cell. Pair with absolute /
pointer-events-none on the overlay element. */
@utility bg-grid {
background-image:
linear-gradient(var(--color-grid-line) 1px, transparent 1px),
linear-gradient(90deg, var(--color-grid-line) 1px, transparent 1px);
background-size: 20px 20px;
}
@utility bg-grid-sm {
background-image:
linear-gradient(var(--color-grid-line) 1px, transparent 1px),
linear-gradient(90deg, var(--color-grid-line) 1px, transparent 1px);
background-size: 10px 10px;
}
/* Typography */
@utility text-label-mono {
font-family: var(--font-primary);
font-weight: 700;
letter-spacing: -0.025em;
text-transform: uppercase;
}
/* Honor prefers-reduced-motion: collapse animation and transition timing. */
/* Global utility - useful across your app */
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
@@ -435,12 +301,12 @@
}
}
/* Hint the upcoming height animation on open collapsibles. */
/* Performance optimization for collapsible elements */
[data-state="open"] {
will-change: height;
}
/* Transition siblings of a focus-visible peer. */
/* Smooth focus transitions - good globally */
.peer:focus-visible ~ * {
transition: all 150ms cubic-bezier(0.4, 0, 0.2, 1);
}
@@ -467,9 +333,11 @@
animation: nudge 10s ease-in-out infinite;
}
/* Scrollbar styling */
/* ============================================
SCROLLBAR STYLES
============================================ */
/* Standard API: color + width (Chrome 121+, Firefox 64+). */
/* ---- Modern API: color + width (Chrome 121+, FF 64+) ---- */
@supports (scrollbar-width: auto) {
* {
scrollbar-width: thin;
@@ -481,8 +349,8 @@
}
}
/* WebKit fallback: applies on top of the standard API in Chrome, standalone in
older Safari. Covers what scrollbar-width can't hiding buttons, exact sizing. */
/* ---- 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;
@@ -490,7 +358,7 @@
}
::-webkit-scrollbar-button {
display: none; /* hide scrollbar buttons */
display: none; /* kills arrows */
}
::-webkit-scrollbar-track {
-78
View File
@@ -1,78 +0,0 @@
/*
Self-hosted interface fonts (latin subset only).
Vendored from @fontsource see docs/interface-font-selfhost-benchmark.md.
Variable faces (Inter, Space Grotesk) keep their wght axis; Inter also keeps opsz.
url()s are resolved + content-hashed by Vite at build immutable long-cache.
*/
/* Inter — variable wght + opsz, the body/secondary UI font (--font-secondary) */
@font-face {
font-family: 'Inter';
font-style: normal;
font-display: swap;
font-weight: 100 900;
src: url('../assets/fonts/inter-latin-opsz-normal.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Inter';
font-style: italic;
font-display: swap;
font-weight: 100 900;
src: url('../assets/fonts/inter-latin-opsz-italic.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* Space Grotesk — variable wght, the primary/display UI font (--font-primary) */
@font-face {
font-family: 'Space Grotesk';
font-style: normal;
font-display: swap;
font-weight: 300 700;
src: url('../assets/fonts/space-grotesk-latin-wght-normal.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* Space Mono — static 400/700 × roman/italic (--font-mono) */
@font-face {
font-family: 'Space Mono';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url('../assets/fonts/space-mono-latin-400-normal.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Space Mono';
font-style: italic;
font-display: swap;
font-weight: 400;
src: url('../assets/fonts/space-mono-latin-400-italic.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Space Mono';
font-style: normal;
font-display: swap;
font-weight: 700;
src: url('../assets/fonts/space-mono-latin-700-normal.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Space Mono';
font-style: italic;
font-display: swap;
font-weight: 700;
src: url('../assets/fonts/space-mono-latin-700-italic.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* Syne — static 800, the logo font (--font-logo) */
@font-face {
font-family: 'Syne';
font-style: normal;
font-display: swap;
font-weight: 800;
src: url('../assets/fonts/syne-latin-800-normal.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
-7
View File
@@ -36,13 +36,6 @@ declare module '*.jpg' {
export default content;
}
declare module '*.css';
declare module '*.woff2?url' {
const content: string;
export default content;
}
/// <reference types="vite/client" />
interface ImportMetaEnv {
+26 -21
View File
@@ -3,20 +3,12 @@
Application shell with providers and page wrapper
-->
<script lang="ts">
import { getThemeManager } from '$features/ChangeAppTheme';
import { themeManager } from '$features/ChangeAppTheme';
import G from '$shared/assets/G.svg';
import { ResponsiveProvider } from '$shared/lib';
import { cn } from '$shared/lib';
import { Footer } from '$widgets/Footer';
/*
Preload the two render-critical interface faces (primary + secondary).
`?url` resolves to the content-hashed path Vite emits, so the binary is
fetched immediately rather than waiting for CSS @font-face discovery.
*/
import interWoff2 from '../assets/fonts/inter-latin-opsz-normal.woff2?url';
import spaceGroteskWoff2 from '../assets/fonts/space-grotesk-latin-wght-normal.woff2?url';
import {
type Snippet,
onDestroy,
@@ -32,8 +24,6 @@ interface Props {
let { children }: Props = $props();
let fontsReady = $state(true);
const themeManager = getThemeManager();
const theme = $derived(themeManager.value);
onMount(() => themeManager.init());
@@ -43,21 +33,36 @@ onDestroy(() => themeManager.destroy());
<svelte:head>
<link rel="icon" href={G} type="image/svg+xml" />
<!-- Self-hosted interface fonts (see src/app/styles/fonts/fonts.css). Preload the two critical faces. -->
<link rel="preconnect" href="https://api.fontshare.com" />
<link
rel="preload"
as="font"
type="font/woff2"
href={interWoff2}
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="font"
type="font/woff2"
href={spaceGroteskWoff2}
crossorigin="anonymous"
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>
<meta
name="description"
@@ -69,7 +74,7 @@ onDestroy(() => themeManager.destroy());
<div
id="app-root"
class={cn(
'min-h-dvh w-auto flex flex-col surface-canvas relative',
'min-h-screen w-auto flex flex-col bg-surface dark:bg-dark-bg relative',
theme === 'dark' ? 'dark' : '',
)}
>
@@ -8,7 +8,7 @@
* @example
* ```svelte
* <script lang="ts">
* import { scrollBreadcrumbsStore } from '$features/Breadcrumb';
* import { scrollBreadcrumbsStore } from '$entities/Breadcrumb';
* import { onMount } from 'svelte';
*
* onMount(() => {
@@ -26,8 +26,8 @@
*/
export {
getScrollBreadcrumbsStore,
type NavigationAction,
scrollBreadcrumbsStore,
} from './model';
export {
BreadcrumbHeader,
+2
View File
@@ -0,0 +1,2 @@
export * from './store/scrollBreadcrumbsStore.svelte';
export * from './types/types.ts';
@@ -1,5 +1,3 @@
import { createSingleton } from '$shared/lib/helpers/createSingleton/createSingleton';
/**
* Scroll-based breadcrumb tracking store
*
@@ -17,7 +15,7 @@ import { createSingleton } from '$shared/lib/helpers/createSingleton/createSingl
* @example
* ```svelte
* <script lang="ts">
* import { scrollBreadcrumbsStore } from '$features/Breadcrumb';
* import { scrollBreadcrumbsStore } from '$entities/Breadcrumb';
*
* onMount(() => {
* scrollBreadcrumbsStore.add({
@@ -169,13 +167,6 @@ class ScrollBreadcrumbsStore {
this.#detachScrollListener();
}
/**
* Tears down the observer and scroll listener. Call on store disposal.
*/
destroy(): void {
this.#disconnect();
}
/**
* All tracked items sorted by index
*/
@@ -282,14 +273,6 @@ export function createScrollBreadcrumbsStore(): ScrollBreadcrumbsStore {
}
/**
* App-wide scroll breadcrumbs store, created on first access.
* Singleton scroll breadcrumbs store instance
*/
const scrollBreadcrumbsStore = createSingleton(
() => createScrollBreadcrumbsStore(),
instance => instance.destroy(),
);
export const getScrollBreadcrumbsStore = scrollBreadcrumbsStore.get;
// test-only reset, so specs don't share observer/scroll state
export const __resetScrollBreadcrumbsStore = scrollBreadcrumbsStore.reset;
export const scrollBreadcrumbsStore = createScrollBreadcrumbsStore();
@@ -20,7 +20,6 @@ let mockObserverInstances: MockIntersectionObserver[] = [];
class MockIntersectionObserver implements IntersectionObserver {
root = null;
rootMargin = '';
scrollMargin = '';
thresholds: number[] = [];
readonly callbacks: Array<(entries: IntersectionObserverEntry[], observer: IntersectionObserver) => void> = [];
readonly observedElements = new Set<Element>();
@@ -70,6 +69,7 @@ class MockIntersectionObserver implements IntersectionObserver {
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
@@ -110,7 +110,7 @@ describe('ScrollBreadcrumbsStore', () => {
// Track scroll event listeners
addEventListenerSpy = vi.spyOn(window, 'addEventListener').mockImplementation(
(event: string, listener: EventListenerOrEventListenerObject, _options?: any) => {
(event: string, listener: EventListenerOrEventListenerObject, options?: any) => {
if (event === 'scroll') {
scrollListeners.push(listener as () => void);
}
@@ -118,7 +118,7 @@ describe('ScrollBreadcrumbsStore', () => {
},
);
vi.spyOn(window, 'removeEventListener').mockImplementation(
removeEventListenerSpy = vi.spyOn(window, 'removeEventListener').mockImplementation(
(event: string, listener: EventListenerOrEventListenerObject) => {
if (event === 'scroll') {
const index = scrollListeners.indexOf(listener as () => void);
@@ -14,10 +14,9 @@ import { cubicOut } from 'svelte/easing';
import { slide } from 'svelte/transition';
import {
type BreadcrumbItem,
getScrollBreadcrumbsStore,
scrollBreadcrumbsStore,
} from '../../model';
const scrollBreadcrumbsStore = getScrollBreadcrumbsStore();
const breadcrumbs = $derived(scrollBreadcrumbsStore.scrolledPastItems);
const responsive = getContext<ResponsiveManager>('responsive');
@@ -44,8 +43,8 @@ function createButtonText(item: BreadcrumbItem) {
md:h-16 px-4 md:px-6 lg:px-8
flex items-center justify-between
z-40
surface-floating bg-surface/90 dark:bg-dark-bg/90
border-x-0 border-t-0
bg-surface/90 dark:bg-dark-bg/90 backdrop-blur-md
border-b border-subtle
"
>
<div class="max-w-8xl px-4 sm:px-6 h-full w-full flex items-center justify-between gap-2 sm:gap-4">
@@ -1,24 +1,18 @@
<script>
import { onMount } from 'svelte';
import { getScrollBreadcrumbsStore } from '../../model';
import { scrollBreadcrumbsStore } from '../../model';
import BreadcrumbHeader from './BreadcrumbHeader.svelte';
const scrollBreadcrumbsStore = getScrollBreadcrumbsStore();
const sections = [
{ index: 100, title: 'Introduction' },
{ index: 101, title: 'Typography' },
{ index: 102, title: 'Spacing' },
];
/** @type {HTMLDivElement | undefined} */
let container = $state();
/** @type {HTMLDivElement} */
let container;
onMount(() => {
if (!container) {
return;
}
for (const section of sections) {
const el = /** @type {HTMLElement} */ (container.querySelector(`[data-story-index="${section.index}"]`));
scrollBreadcrumbsStore.add({ index: section.index, title: section.title, element: el }, 96);
@@ -6,11 +6,9 @@
import { type Snippet } from 'svelte';
import {
type NavigationAction,
getScrollBreadcrumbsStore,
scrollBreadcrumbsStore,
} from '../../model';
const scrollBreadcrumbsStore = getScrollBreadcrumbsStore();
interface Props {
/**
* Navigation index
-1
View File
@@ -9,7 +9,6 @@ export {
fetchFontsByIds,
fetchProxyFontById,
fetchProxyFonts,
seedFontCache,
} from './proxy/proxyFonts';
export type {
ProxyFontsParams,
+5 -12
View File
@@ -19,11 +19,8 @@ vi.mock('$shared/api/api', () => ({
}));
import { api } from '$shared/api/api';
import { getQueryClient } from '$shared/api/queryClient';
const queryClient = getQueryClient();
import { queryClient } from '$shared/api/queryClient';
import { fontKeys } from '$shared/api/queryKeys';
import { FontResponseError } from '../../lib/errors/errors';
import {
fetchFontsByIds,
fetchProxyFontById,
@@ -89,20 +86,16 @@ describe('proxyFonts', () => {
expect(calledUrl).toContain('offset=0');
});
test('should throw FontResponseError on invalid response (missing fonts array)', async () => {
test('should throw on invalid response (missing fonts array)', async () => {
mockApiGet({ total: 0 });
await expect(fetchProxyFonts()).rejects.toSatisfy(
e => e instanceof FontResponseError && e.field === 'response.fonts',
);
await expect(fetchProxyFonts()).rejects.toThrow('Proxy API returned invalid response');
});
test('should throw FontResponseError on null response data', async () => {
test('should throw on null response data', async () => {
vi.mocked(api.get).mockResolvedValueOnce({ data: null, status: 200 });
await expect(fetchProxyFonts()).rejects.toSatisfy(
e => e instanceof FontResponseError && e.field === 'response',
);
await expect(fetchProxyFonts()).rejects.toThrow('Proxy API returned invalid response');
});
});
+8 -19
View File
@@ -11,11 +11,10 @@
*/
import { api } from '$shared/api/api';
import { getQueryClient } from '$shared/api/queryClient';
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 { FontResponseError } from '../../lib/errors/errors';
import type { UnifiedFont } from '../../model/types';
/**
@@ -26,16 +25,14 @@ import type { UnifiedFont } from '../../model/types';
*/
export function seedFontCache(fonts: UnifiedFont[]): void {
fonts.forEach(font => {
getQueryClient().setQueryData(fontKeys.detail(font.id), font);
queryClient.setQueryData(fontKeys.detail(font.id), font);
});
}
import { API_ENDPOINTS } from '$shared/api/endpoints';
/**
* Proxy API endpoint for font resources.
* Proxy API base URL
*/
const PROXY_API_URL = API_ENDPOINTS.fonts;
const PROXY_API_URL = 'https://api.glyphdiff.com/api/v1/fonts' as const;
/**
* Proxy API parameters
@@ -97,16 +94,11 @@ export interface ProxyFontsParams extends QueryParams {
/**
* Proxy API response
*
* Includes pagination metadata alongside font data.
*
* Contract: `fonts` is always an array never `null` or omitted, even when
* `total === 0`. Returning `null` on the wire is a backend regression and
* surfaces as FontResponseError (non-retryable) on the client.
* Includes pagination metadata alongside font data
*/
export interface ProxyFontsResponse {
/**
* List of font objects returned by the proxy.
* Always an array; empty when no matches.
* List of font objects returned by the proxy
*/
fonts: UnifiedFont[];
@@ -162,11 +154,8 @@ export async function fetchProxyFonts(
const response = await api.get<ProxyFontsResponse>(url);
if (!response.data) {
throw new FontResponseError('response', response.data);
}
if (!Array.isArray(response.data.fonts)) {
throw new FontResponseError('response.fonts', response.data.fonts);
if (!response.data || !Array.isArray(response.data.fonts)) {
throw new Error('Proxy API returned invalid response');
}
return response.data;
@@ -1,95 +0,0 @@
// @vitest-environment jsdom
import { installCanvasMock } from '$shared/lib/helpers/__mocks__/canvas';
import { clearCache } from '@chenglou/pretext';
import {
beforeEach,
describe,
expect,
it,
} from 'vitest';
import { DualFontLayout } from './DualFontLayout';
// FontA: 10px per character. FontB: 15px per character.
// The mock dispatches on whether the font string contains 'FontA' or 'FontB'.
const FONT_A_WIDTH = 10;
const FONT_B_WIDTH = 15;
function fontWidthFactory(font: string, text: string): number {
const perChar = font.includes('FontA') ? FONT_A_WIDTH : FONT_B_WIDTH;
return text.length * perChar;
}
describe('DualFontLayout', () => {
let layout: DualFontLayout;
beforeEach(() => {
installCanvasMock(fontWidthFactory);
clearCache();
layout = new DualFontLayout();
});
it('returns empty result for empty string', () => {
const result = layout.layout('', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
expect(result.lines).toHaveLength(0);
expect(result.totalHeight).toBe(0);
});
it('uses worst-case width across both fonts to determine line breaks', () => {
// 'AB CD' — two 2-char words separated by a space.
// FontA: 'AB'=20px, 'CD'=20px. Both fit in 25px? No: 'AB CD' = 50px total.
// FontB: 'AB'=30px, 'CD'=30px. Width 35px forces wrap after 'AB '.
// Unified must use FontB widths — so it must wrap at the same place FontB wraps.
const result = layout.layout('AB CD', '400 16px "FontA"', '400 16px "FontB"', 35, 20);
expect(result.lines.length).toBeGreaterThan(1);
// First line text must not include both words.
expect(result.lines[0].text).not.toContain('CD');
});
it('provides xA and xB offsets for both fonts on a single line', () => {
// 'ABC' fits in 500px for both fonts.
// FontA: A@0(w=10), B@10(w=10), C@20(w=10)
// FontB: A@0(w=15), B@15(w=15), C@30(w=15)
const result = layout.layout('ABC', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
const chars = result.lines[0].chars;
expect(chars).toHaveLength(3);
expect(chars[0].xA).toBe(0);
expect(chars[0].widthA).toBe(FONT_A_WIDTH);
expect(chars[0].xB).toBe(0);
expect(chars[0].widthB).toBe(FONT_B_WIDTH);
expect(chars[1].xA).toBe(FONT_A_WIDTH); // 10
expect(chars[1].widthA).toBe(FONT_A_WIDTH);
expect(chars[1].xB).toBe(FONT_B_WIDTH); // 15
expect(chars[1].widthB).toBe(FONT_B_WIDTH);
expect(chars[2].xA).toBe(FONT_A_WIDTH * 2); // 20
expect(chars[2].xB).toBe(FONT_B_WIDTH * 2); // 30
});
it('returns cached result when called again with same arguments', () => {
const r1 = layout.layout('ABC', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
const r2 = layout.layout('ABC', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
expect(r2).toBe(r1); // strict reference equality — same object
});
it('re-computes when text changes', () => {
const r1 = layout.layout('ABC', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
const r2 = layout.layout('DEF', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
expect(r2).not.toBe(r1);
expect(r2.lines[0].text).not.toBe(r1.lines[0].text);
});
it('re-computes when width changes', () => {
const r1 = layout.layout('Hello World', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
const r2 = layout.layout('Hello World', '400 16px "FontA"', '400 16px "FontB"', 60, 20);
expect(r2).not.toBe(r1);
});
it('re-computes when fontA changes', () => {
const r1 = layout.layout('ABC', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
const r2 = layout.layout('ABC', '400 24px "FontA"', '400 16px "FontB"', 500, 20);
expect(r2).not.toBe(r1);
});
});
@@ -1,278 +0,0 @@
import {
type PreparedTextWithSegments,
layoutWithLines,
prepareWithSegments,
} from '@chenglou/pretext';
/**
* Default render size in px when callers omit the `size` arg on `layout()`.
*/
const DEFAULT_RENDER_SIZE_PX = 16;
/**
* Per-grapheme data computed during dual-font layout. Internal to the engine;
* consumed by computeLineRenderModel to derive the per-frame render model.
*/
export interface ComparisonChar {
/**
* Grapheme cluster (may be >1 code unit for emoji, combining marks).
*/
char: string;
/**
* X offset from line start in fontA, pixels.
*/
xA: number;
/**
* Advance width of this grapheme in fontA, pixels.
*/
widthA: number;
/**
* X offset from line start in fontB, pixels.
*/
xB: number;
/**
* Advance width of this grapheme in fontB, pixels.
*/
widthB: number;
}
/**
* A single laid-out line. `chars` carries the per-grapheme data needed by
* computeLineRenderModel. Consumers should not iterate it directly.
*/
export interface ComparisonLine {
/**
* Full text of this line as returned by pretext.
*/
text: string;
/**
* Rendered width in pixels maximum across fontA and fontB.
*/
width: number;
/**
* Per-grapheme metadata for both fonts.
*/
chars: ComparisonChar[];
}
/**
* Aggregated output of a dual-font layout pass.
*/
export interface ComparisonResult {
/**
* Per-line grapheme data. Empty when input text is empty.
*/
lines: ComparisonLine[];
/**
* Total height in pixels.
*/
totalHeight: number;
}
/**
* Dual-font text layout engine backed by `@chenglou/pretext`.
*
* Computes identical line breaks for two fonts simultaneously by constructing a
* "unified" prepared-text object whose per-glyph widths are the worst-case maximum
* of font A and font B. This guarantees that both fonts wrap at exactly the same
* positions, making side-by-side or slider comparison visually coherent.
*
* Relies on pretext's published structural fields on `PreparedTextWithSegments`
* (`widths`, `breakableFitAdvances`, `lineEndFitAdvances`, `lineEndPaintAdvances`)
* which are exposed via the `PreparedCore` intersection in `@chenglou/pretext@0.0.6`.
*
* **Two-level caching strategy**
* 1. Font-change cache (`#preparedA`, `#preparedB`, `#unifiedPrepared`): rebuilt only
* when `text`, `fontA`, or `fontB` changes. `prepareWithSegments` is expensive
* (canvas measurement), so this avoids re-measuring during slider interaction.
* 2. Layout cache (`#lastResult`): rebuilt when `width` or `lineHeight` changes but
* the fonts have not changed. Line-breaking is cheap relative to measurement, but
* still worth skipping on every render tick.
*
* Per-frame slider state derivation lives in `computeLineRenderModel`, not on the
* class. This class is pure layout + caching; it holds no reactive state.
*/
export class DualFontLayout {
#segmenter: Intl.Segmenter;
// Cached prepared data
#preparedA: PreparedTextWithSegments | null = null;
#preparedB: PreparedTextWithSegments | null = null;
#unifiedPrepared: PreparedTextWithSegments | null = null;
#lastText = '';
#lastFontA = '';
#lastFontB = '';
#lastSpacing = 0;
#lastSize = 0;
// Cached layout results
#lastWidth = -1;
#lastLineHeight = -1;
#lastResult: ComparisonResult | null = null;
constructor(locale?: string) {
this.#segmenter = new Intl.Segmenter(locale, { granularity: 'grapheme' });
}
/**
* Lay out `text` using both fonts within `width` pixels.
*
* Line breaks are determined by the worst-case (maximum) glyph widths across
* both fonts, so both fonts always wrap at identical positions.
*
* @param text Raw text to lay out.
* @param fontA CSS font string for the first font: `"weight sizepx \"family\""`.
* @param fontB CSS font string for the second font: `"weight sizepx \"family\""`.
* @param width Available line width in pixels.
* @param lineHeight Line height in pixels (passed directly to pretext).
* @param spacing Letter spacing in em (from typography settings).
* @param size Current font size in pixels (used to convert spacing em to px).
* @returns Per-line grapheme data for both fonts. Empty `lines` when `text` is empty.
*/
layout(
text: string,
fontA: string,
fontB: string,
width: number,
lineHeight: number,
spacing: number = 0,
size: number = DEFAULT_RENDER_SIZE_PX,
): ComparisonResult {
if (!text) {
return { lines: [], totalHeight: 0 };
}
const spacingPx = spacing * size;
const isFontChange = text !== this.#lastText
|| fontA !== this.#lastFontA
|| fontB !== this.#lastFontB
|| spacing !== this.#lastSpacing
|| size !== this.#lastSize;
const isLayoutChange = width !== this.#lastWidth || lineHeight !== this.#lastLineHeight;
if (!isFontChange && !isLayoutChange && this.#lastResult) {
return this.#lastResult;
}
// 1. Prepare (or use cache)
if (isFontChange) {
this.#preparedA = prepareWithSegments(text, fontA);
this.#preparedB = prepareWithSegments(text, fontB);
this.#unifiedPrepared = this.#createUnifiedPrepared(this.#preparedA, this.#preparedB, spacingPx);
this.#lastText = text;
this.#lastFontA = fontA;
this.#lastFontB = fontB;
this.#lastSpacing = spacing;
this.#lastSize = size;
}
if (!this.#unifiedPrepared || !this.#preparedA || !this.#preparedB) {
return { lines: [], totalHeight: 0 };
}
const { lines, height } = layoutWithLines(this.#unifiedPrepared, width, lineHeight);
// 3. Map results back to both fonts
const preparedA = this.#preparedA;
const preparedB = this.#preparedB;
const resultLines: ComparisonLine[] = lines.map(line => {
const chars: ComparisonChar[] = [];
let currentXA = 0;
let currentXB = 0;
const start = line.start;
const end = line.end;
for (let sIdx = start.segmentIndex; sIdx <= end.segmentIndex; sIdx++) {
const segmentText = preparedA.segments[sIdx];
if (segmentText === undefined) {
continue;
}
const graphemes = Array.from(this.#segmenter.segment(segmentText), s => s.segment);
const advA = preparedA.breakableFitAdvances[sIdx];
const advB = preparedB.breakableFitAdvances[sIdx];
const gStart = sIdx === start.segmentIndex ? start.graphemeIndex : 0;
const gEnd = sIdx === end.segmentIndex ? end.graphemeIndex : graphemes.length;
for (let gIdx = gStart; gIdx < gEnd; gIdx++) {
const char = graphemes[gIdx];
let wA = advA != null ? advA[gIdx]! : preparedA.widths[sIdx]!;
let wB = advB != null ? advB[gIdx]! : preparedB.widths[sIdx]!;
// Apply letter spacing (tracking) to the width of each character
wA += spacingPx;
wB += spacingPx;
chars.push({
char,
xA: currentXA,
widthA: wA,
xB: currentXB,
widthB: wB,
});
currentXA += wA;
currentXB += wB;
}
}
return {
text: line.text,
width: line.width,
chars,
};
});
this.#lastWidth = width;
this.#lastLineHeight = lineHeight;
this.#lastResult = {
lines: resultLines,
totalHeight: height,
};
return this.#lastResult;
}
/**
* Merge two prepared texts into a worst-case unified version so both fonts
* wrap at identical positions. Per-segment widths are the elementwise max
* across both fonts, with `spacingPx` added to model letter-spacing.
*/
#createUnifiedPrepared(
a: PreparedTextWithSegments,
b: PreparedTextWithSegments,
spacingPx: number = 0,
): PreparedTextWithSegments {
const unified: PreparedTextWithSegments = { ...a };
unified.widths = a.widths.map((w, i) => Math.max(w, b.widths[i]) + spacingPx);
unified.lineEndFitAdvances = a.lineEndFitAdvances.map((w, i) =>
Math.max(w, b.lineEndFitAdvances[i]) + spacingPx
);
unified.lineEndPaintAdvances = a.lineEndPaintAdvances.map((w, i) =>
Math.max(w, b.lineEndPaintAdvances[i]) + spacingPx
);
unified.breakableFitAdvances = a.breakableFitAdvances.map((advA, i) => {
const advB = b.breakableFitAdvances[i];
if (!advA && !advB) {
return null;
}
if (!advA) {
return advB!.map(w => w + spacingPx);
}
if (!advB) {
return advA.map(w => w + spacingPx);
}
return advA.map((w, j) => Math.max(w, advB[j]) + spacingPx);
});
return unified;
}
}
@@ -1,220 +0,0 @@
import {
describe,
expect,
it,
} from 'vitest';
import type { ComparisonLine } from '../DualFontLayout/DualFontLayout';
import {
type LineRenderModel,
computeLineRenderModel,
findSplitIndex,
} from './computeLineRenderModel';
/**
* Build a ComparisonLine fixture with given per-char widths. xA/xB are
* cumulative prefix sums of widthA/widthB respectively.
*/
function makeLine(
chars: { char: string; widthA: number; widthB: number }[],
): ComparisonLine {
let xA = 0;
let xB = 0;
const out: ComparisonLine = {
text: chars.map(c => c.char).join(''),
width: chars.reduce((s, c) => s + Math.max(c.widthA, c.widthB), 0),
chars: chars.map(c => {
const entry = {
char: c.char,
xA,
xB,
widthA: c.widthA,
widthB: c.widthB,
};
xA += c.widthA;
xB += c.widthB;
return entry;
}),
};
return out;
}
/**
* Test helper: compute split + render model in one step, matching the
* SliderArea call site shape.
*/
function compute(
line: ComparisonLine,
sliderPos: number,
containerWidth: number,
windowSize: number,
): LineRenderModel {
const split = findSplitIndex(line, sliderPos, containerWidth);
return computeLineRenderModel(line, split, windowSize);
}
describe('computeLineRenderModel', () => {
it('returns empty model for an empty line', () => {
const line = makeLine([]);
const model = compute(line, 50, 500, 5);
expect(model.leftText).toBe('');
expect(model.windowChars).toEqual([]);
expect(model.rightText).toBe('');
});
it('places entire line in rightText when slider is at 0', () => {
const line = makeLine([
{ char: 'A', widthA: 10, widthB: 10 },
{ char: 'B', widthA: 10, widthB: 10 },
{ char: 'C', widthA: 10, widthB: 10 },
]);
const model = compute(line, 0, 500, 0);
expect(model.leftText).toBe('');
expect(model.windowChars).toEqual([]);
expect(model.rightText).toBe('ABC');
});
it('places entire line in leftText when slider is at 100', () => {
const line = makeLine([
{ char: 'A', widthA: 10, widthB: 10 },
{ char: 'B', widthA: 10, widthB: 10 },
{ char: 'C', widthA: 10, widthB: 10 },
]);
const model = compute(line, 100, 500, 0);
expect(model.leftText).toBe('ABC');
expect(model.windowChars).toEqual([]);
expect(model.rightText).toBe('');
});
it('splits line correctly with slider mid-line (window=0)', () => {
// Equal widths → line is centered. Container=300, total=30 → xOffset=135.
// Char thresholds (per the threshold formula in the design):
// threshold[i] = xOffset + prefA[i] + widthA[i]/2
// i=0: 135 + 0 + 5 = 140 → 140/300 = 46.67%
// i=1: 135 + 10 + 5 = 150 → 150/300 = 50.00%
// i=2: 135 + 20 + 5 = 160 → 160/300 = 53.33%
const line = makeLine([
{ char: 'A', widthA: 10, widthB: 10 },
{ char: 'B', widthA: 10, widthB: 10 },
{ char: 'C', widthA: 10, widthB: 10 },
]);
// Slider just past B's threshold (50%) but not C's (53.33%).
const model = compute(line, 51, 300, 0);
expect(model.leftText).toBe('AB');
expect(model.rightText).toBe('C');
});
it('centers window of size 3 on the split index', () => {
const line = makeLine([
{ char: 'A', widthA: 10, widthB: 10 },
{ char: 'B', widthA: 10, widthB: 10 },
{ char: 'C', widthA: 10, widthB: 10 },
{ char: 'D', widthA: 10, widthB: 10 },
{ char: 'E', widthA: 10, widthB: 10 },
]);
// Slider past A and B (~thresholds 43.33%, 46.67%); not past C (50%).
// split = 2 → halfWindow = 1 → windowStart = 1, windowEnd = 4
const model = compute(line, 48, 300, 3);
expect(model.leftText).toBe('A');
expect(model.windowChars.map(w => w.char)).toEqual(['B', 'C', 'D']);
expect(model.rightText).toBe('E');
});
it('clamps window at line start when slider is near 0', () => {
const line = makeLine([
{ char: 'A', widthA: 10, widthB: 10 },
{ char: 'B', widthA: 10, widthB: 10 },
{ char: 'C', widthA: 10, widthB: 10 },
{ char: 'D', widthA: 10, widthB: 10 },
{ char: 'E', widthA: 10, widthB: 10 },
]);
const model = compute(line, 0, 300, 3);
expect(model.leftText).toBe('');
expect(model.windowChars.map(w => w.char)).toEqual(['A', 'B', 'C']);
expect(model.rightText).toBe('DE');
});
it('clamps window at line end when slider is near 100', () => {
const line = makeLine([
{ char: 'A', widthA: 10, widthB: 10 },
{ char: 'B', widthA: 10, widthB: 10 },
{ char: 'C', widthA: 10, widthB: 10 },
{ char: 'D', widthA: 10, widthB: 10 },
{ char: 'E', widthA: 10, widthB: 10 },
]);
const model = compute(line, 100, 300, 3);
expect(model.leftText).toBe('AB');
expect(model.windowChars.map(w => w.char)).toEqual(['C', 'D', 'E']);
expect(model.rightText).toBe('');
});
it('treats whole line as window when line is shorter than windowSize', () => {
const line = makeLine([
{ char: 'A', widthA: 10, widthB: 10 },
{ char: 'B', widthA: 10, widthB: 10 },
]);
const model = compute(line, 50, 300, 5);
expect(model.leftText).toBe('');
expect(model.windowChars.map(w => w.char)).toEqual(['A', 'B']);
expect(model.rightText).toBe('');
});
it('produces stable keys across slider movement within the same line', () => {
const line = makeLine([
{ char: 'A', widthA: 10, widthB: 10 },
{ char: 'B', widthA: 10, widthB: 10 },
{ char: 'C', widthA: 10, widthB: 10 },
{ char: 'D', widthA: 10, widthB: 10 },
{ char: 'E', widthA: 10, widthB: 10 },
]);
const a = compute(line, 40, 300, 3);
const b = compute(line, 60, 300, 3);
// Chars that appear in both windows must carry identical keys.
for (const charA of a.windowChars) {
const charB = b.windowChars.find(w => w.char === charA.char);
if (charB !== undefined) {
expect(charB.key).toBe(charA.key);
}
}
});
it('marks isPast=true for chars before the split and false for chars after', () => {
const line = makeLine([
{ char: 'A', widthA: 10, widthB: 10 },
{ char: 'B', widthA: 10, widthB: 10 },
{ char: 'C', widthA: 10, widthB: 10 },
{ char: 'D', widthA: 10, widthB: 10 },
{ char: 'E', widthA: 10, widthB: 10 },
]);
// split = 2 → A,B past; C,D,E not
const model = compute(line, 48, 300, 5);
const expected = new Map([['A', true], ['B', true], ['C', false], ['D', false], ['E', false]]);
for (const wc of model.windowChars) {
expect(wc.isPast).toBe(expected.get(wc.char));
}
});
});
describe('findSplitIndex', () => {
it('returns 0 for empty line', () => {
const line = makeLine([]);
expect(findSplitIndex(line, 50, 500)).toBe(0);
});
it('returns 0 when slider is before all char thresholds', () => {
const line = makeLine([
{ char: 'A', widthA: 10, widthB: 10 },
{ char: 'B', widthA: 10, widthB: 10 },
{ char: 'C', widthA: 10, widthB: 10 },
]);
expect(findSplitIndex(line, 0, 300)).toBe(0);
});
it('returns chars.length when slider is past all char thresholds', () => {
const line = makeLine([
{ char: 'A', widthA: 10, widthB: 10 },
{ char: 'B', widthA: 10, widthB: 10 },
{ char: 'C', widthA: 10, widthB: 10 },
]);
expect(findSplitIndex(line, 100, 300)).toBe(3);
});
});
@@ -1,133 +0,0 @@
import type { ComparisonLine } from '../DualFontLayout/DualFontLayout';
/**
* Per-line render slice consumed by Line.svelte. The window is centered on the
* slider's split index and clamps at line boundaries.
*/
export interface LineRenderModel {
/**
* Chars before the window joined into a single string, rendered as one fontA text run.
*/
leftText: string;
/**
* Window chars each rendered as its own Character element with crossfade slots.
*/
windowChars: Array<{
/**
* Stable key for Svelte keyed each survives slider movement within the same line.
*/
key: string;
/**
* Grapheme cluster to render.
*/
char: string;
/**
* True once the slider has crossed this char's threshold.
*/
isPast: boolean;
}>;
/**
* Chars after the window joined into a single string, rendered as one fontB text run.
*/
rightText: string;
}
/**
* Returns the count of chars whose flip threshold the slider has crossed.
*
* Exposed as a separate step so consumers can pass the resulting primitive
* `split` across component boundaries: when split is unchanged tick-to-tick,
* downstream `$derived` reads of `computeLineRenderModel(line, split, ...)`
* short-circuit on value equality and skip re-rendering.
*
* For each candidate split `i`, the line's hypothetical width at that moment is
* `prefA[i] + widthA[i] + sufB[i+1]` (past chars in fontA, char `i` flipping, future
* chars in fontB). The threshold is the x of char `i`'s center in the centered line.
* Thresholds are monotonically non-decreasing in `i`, so the scan short-circuits on
* the first miss.
*/
export function findSplitIndex(
line: ComparisonLine,
sliderPos: number,
containerWidth: number,
): number {
const chars = line.chars;
const n = chars.length;
if (n === 0) {
return 0;
}
const sliderX = (sliderPos / 100) * containerWidth;
const prefA = new Float64Array(n + 1);
const sufB = new Float64Array(n + 1);
for (let i = 0, j = n - 1; i < n; i++, j--) {
prefA[i + 1] = prefA[i] + chars[i].widthA;
sufB[j] = sufB[j + 1] + chars[j].widthB;
}
let split = 0;
for (let i = 0; i < n; i++) {
const totalWidth = prefA[i] + chars[i].widthA + sufB[i + 1];
const xOffset = (containerWidth - totalWidth) / 2;
const threshold = xOffset + prefA[i] + chars[i].widthA / 2;
if (sliderX > threshold) {
split = i + 1;
} else {
break;
}
}
return split;
}
/**
* Slices a laid-out line into three regions around a precomputed split index:
* a fontA bulk run, an N-char crossfade window, and a fontB bulk run.
*
* Pure and allocation-bounded: two strings plus a `windowSize`-length array per call.
* Takes `split` as a primitive so callers can feed it into a `$derived` and
* skip re-evaluation on ticks where the split index is unchanged.
*
* @param line Line from `DualFontLayout.layout()`. Empty `chars` yields an empty model.
* @param split Count of chars the slider has passed, in `[0, line.chars.length]`.
* @param windowSize Number of chars in the crossfade window. Clamped to `[0, line.chars.length]`.
* At line edges the window is shifted (not shrunk) to keep its size.
*/
export function computeLineRenderModel(
line: ComparisonLine,
split: number,
windowSize: number,
): LineRenderModel {
const chars = line.chars;
const n = chars.length;
if (n === 0) {
return { leftText: '', windowChars: [], rightText: '' };
}
const halfWindow = Math.floor(Math.max(0, windowSize) / 2);
let windowStart = clamp(split - halfWindow, 0, n);
let windowEnd = clamp(windowStart + Math.max(0, windowSize), 0, n);
windowStart = Math.max(0, windowEnd - Math.max(0, windowSize));
const leftText = chars.slice(0, windowStart).map(c => c.char).join('');
const rightText = chars.slice(windowEnd).map(c => c.char).join('');
const windowChars = chars.slice(windowStart, windowEnd).map((c, idx) => ({
key: `${windowStart + idx}-${c.char}`,
char: c.char,
isPast: (windowStart + idx) < split,
}));
return { leftText, windowChars, rightText };
}
/**
* Clamps `value` into the inclusive range `[lo, hi]`. Assumes `lo <= hi`.
*/
function clamp(value: number, lo: number, hi: number): number {
if (value < lo) {
return lo;
}
if (value > hi) {
return hi;
}
return value;
}
-11
View File
@@ -1,11 +0,0 @@
export {
type ComparisonLine,
type ComparisonResult,
DualFontLayout,
} from './DualFontLayout/DualFontLayout';
export {
computeLineRenderModel,
findSplitIndex,
type LineRenderModel,
} from './computeLineRenderModel/computeLineRenderModel';
export { windowSizeForLine } from './windowSizeForLine/windowSizeForLine';
@@ -1,38 +0,0 @@
import {
describe,
expect,
it,
} from 'vitest';
import { windowSizeForLine } from './windowSizeForLine';
describe('windowSizeForLine', () => {
it('returns 0 for an empty or non-positive line', () => {
expect(windowSizeForLine(0)).toBe(0);
expect(windowSizeForLine(-3)).toBe(0);
});
it('floors non-empty short lines at the minimum window of 1', () => {
expect(windowSizeForLine(1)).toBe(1);
expect(windowSizeForLine(2)).toBe(1);
expect(windowSizeForLine(3)).toBe(1);
});
it('scales with round(n / 3) in the mid range', () => {
expect(windowSizeForLine(6)).toBe(2);
expect(windowSizeForLine(12)).toBe(4);
});
it('caps at the maximum window of 5', () => {
expect(windowSizeForLine(15)).toBe(5);
expect(windowSizeForLine(16)).toBe(5);
expect(windowSizeForLine(100)).toBe(5);
});
it('rounds to nearest at fractional boundaries', () => {
// round(4/3)=1, round(5/3)=2, round(13/3)=4, round(14/3)=5
expect(windowSizeForLine(4)).toBe(1);
expect(windowSizeForLine(5)).toBe(2);
expect(windowSizeForLine(13)).toBe(4);
expect(windowSizeForLine(14)).toBe(5);
});
});
@@ -1,39 +0,0 @@
/**
* Crossfade-window sizing policy for the dual-font slider.
*
* The slider renders a band of per-char `Character` cells that opacity-crossfade
* between the two fonts; everything outside the band is committed native bulk
* text. A fixed band looked wrong on short lines a 6-grapheme line left almost
* no bulk, so nearly the whole line shimmered as per-char DOM. The band size
* therefore scales with the line's grapheme count and caps so long lines don't
* pay for an oversized per-char DOM band.
*/
/**
* Fraction of a line's graphemes that sit in the crossfade band.
*/
const WINDOW_RATIO = 1 / 3;
/**
* Smallest band for a non-empty line guarantees at least one crossfading char.
*
* Accepted tradeoff: short lines now get a band of 12, so a fast slider drag
* can unmount a char before its ~100ms opacity crossfade finishes, a slight pop.
* Worth it for the "bulk committed, small band shimmering" look on short lines;
* raising this trades that pop back for less committed bulk.
*/
const WINDOW_MIN = 1;
/**
* Largest band regardless of line length bounds per-char DOM cost.
*/
const WINDOW_MAX = 5;
/**
* Crossfade window size, in graphemes, for a line of `n` graphemes.
* `clamp(round(n / 3), 1, 5)`; an empty/non-positive line gets no window.
*/
export function windowSizeForLine(n: number): number {
if (n <= 0) {
return 0;
}
return Math.min(WINDOW_MAX, Math.max(WINDOW_MIN, Math.round(n * WINDOW_RATIO)));
}
+4 -93
View File
@@ -1,93 +1,4 @@
export {
computeLineRenderModel,
DualFontLayout,
findSplitIndex,
windowSizeForLine,
} from './domain';
export type {
ComparisonLine,
ComparisonResult,
LineRenderModel,
} from './domain';
export {
createFontRowSizeResolver,
FontNetworkError,
FontResponseError,
getFontUrl,
} from './lib';
export type { FontRowSizeResolverOptions } from './lib';
export {
FontApplicator,
FontSampler,
FontVirtualList,
} from './ui';
// Pure model surface (types + constants).
export {
DEFAULT_FONT_SIZE,
DEFAULT_FONT_WEIGHT,
DEFAULT_LETTER_SPACING,
DEFAULT_LINE_HEIGHT,
FONT_SIZE_STEP,
FONT_WEIGHT_STEP,
LETTER_SPACING_STEP,
LINE_HEIGHT_STEP,
MAX_FONT_SIZE,
MAX_FONT_WEIGHT,
MAX_LETTER_SPACING,
MAX_LINE_HEIGHT,
MIN_FONT_SIZE,
MIN_FONT_WEIGHT,
MIN_LETTER_SPACING,
MIN_LINE_HEIGHT,
VIRTUAL_INDEX_NOT_LOADED,
} from './model/const/const';
export type {
FilterGroup,
FilterType,
FontCategory,
FontCollectionFilters,
FontCollectionSort,
FontCollectionState,
FontFeatures,
FontFilters,
FontLoadRequestConfig,
FontLoadStatus,
FontMetadata,
FontProvider,
FontStyleUrls,
FontSubset,
FontVariant,
FontWeight,
FontWeightItalic,
UnifiedFont,
UnifiedFontVariant,
} from './model/types';
/*
* Stores are exposed as lazy accessors / classes (not eager singletons): the
* entity's public API is complete, so consumers go through this barrel instead
* of deep-importing `./model` (FSD public-API boundary). Construction happens on
* first call, so this is inert at import. The slice root already transitively
* loads `@tanstack/query-core` via `./ui` (FontVirtualList), so surfacing the
* stores here adds no new eager cost.
*/
export {
FontLifecycleManager,
FontsByIdsStore,
getFontCatalog,
getFontLifecycleManager,
} from './model';
export type { FontCatalogStore } from './model';
/*
* `./api` (proxy clients: `fetchProxyFonts`, `seedFontCache`, ) is intentionally
* NOT re-exported here those are not part of the entity's consumed surface and
* importing them eagerly constructs the TanStack `queryClient`. Import via the
* segment: `import { fetchProxyFonts } from '$entities/Font/api'`.
*/
// `./testing` is intentionally not re-exported: fixtures must not leak into the
// production public API. Import them via `$entities/Font/testing`.
export * from './api';
export * from './lib';
export * from './model';
export * from './ui';
@@ -1,111 +0,0 @@
import {
describe,
expect,
it,
} from 'vitest';
import type { UnifiedFont } from '../../model/types';
import { createFontLoadRequestContfig } from './createFontLoadRequestContfig';
/**
* Minimal UnifiedFont mock override only the fields a case exercises.
*/
function createMockFont(overrides: Partial<UnifiedFont> = {}): UnifiedFont {
const baseFont: UnifiedFont = {
id: 'test-font',
name: 'Test Font',
provider: 'google',
category: 'sans-serif',
subsets: ['latin'],
variants: [],
styles: {},
metadata: {
cachedAt: Date.now(),
},
features: {
isVariable: false,
tags: [],
},
};
return { ...baseFont, ...overrides };
}
describe('createFontLoadRequestContfig', () => {
it('builds a single-element config when a URL resolves', () => {
const font = createMockFont({
id: 'roboto',
name: 'Roboto',
styles: { variants: { '400': 'https://example.com/roboto-400.woff2' } },
});
const result = createFontLoadRequestContfig(font, 400);
expect(result).toEqual([
{
id: 'roboto',
name: 'Roboto',
weight: 400,
url: 'https://example.com/roboto-400.woff2',
isVariable: false,
},
]);
});
it('returns an empty array when no URL resolves (flatMap drops the font)', () => {
const font = createMockFont({ styles: {} });
expect(createFontLoadRequestContfig(font, 400)).toEqual([]);
});
it('forwards isVariable from font features', () => {
const font = createMockFont({
features: { isVariable: true, tags: [] },
styles: { variants: { '700': 'https://example.com/inter-vf.woff2' } },
});
const [config] = createFontLoadRequestContfig(font, 700);
expect(config.isVariable).toBe(true);
});
it('sets isVariable to undefined when features is absent', () => {
// features is non-optional on UnifiedFont, but upstream data can be partial —
// the optional chain must not throw, and isVariable stays undefined.
const font = createMockFont({
styles: { variants: { '400': 'https://example.com/font.woff2' } },
});
// @ts-expect-error — deliberately drop the guaranteed field to exercise the optional chain
font.features = undefined;
const [config] = createFontLoadRequestContfig(font, 400);
expect(config.isVariable).toBeUndefined();
});
it('uses the resolved fallback URL, not just exact matches', () => {
// getFontUrl falls back to styles.regular when the exact weight is missing;
// the config must carry whatever URL actually resolved.
const font = createMockFont({
styles: { regular: 'https://example.com/font-regular.woff2' },
});
const [config] = createFontLoadRequestContfig(font, 900);
expect(config.url).toBe('https://example.com/font-regular.woff2');
expect(config.weight).toBe(900);
});
it('carries the requested weight even when the URL is a shared fallback', () => {
const font = createMockFont({
styles: { variants: { '400': 'https://example.com/shared.woff2' } },
});
expect(createFontLoadRequestContfig(font, 700)[0].weight).toBe(700);
});
it('propagates the invalid-weight error from getFontUrl', () => {
const font = createMockFont();
expect(() => createFontLoadRequestContfig(font, 450)).toThrow('Invalid weight: 450');
});
});
@@ -1,33 +0,0 @@
import type {
FontLoadRequestConfig,
UnifiedFont,
} from '../../model';
import { getFontUrl } from '../getFontUrl/getFontUrl';
/**
* Build the font-lifecycle load request for a single font at a given weight.
*
* Returns a 0-or-1 element array rather than `FontLoadRequestConfig | undefined`
* so call sites can `flatMap` over a font list resolve the URL and drop fonts
* that have none in a single pass, with no separate filter step. An empty array
* means the font has no loadable asset for this weight (or its fallbacks) and is
* silently skipped.
*
* `isVariable` is forwarded from the font's features so the lifecycle manager can
* dedupe variable fonts per ID (they load once regardless of weight) while still
* loading static fonts per weight.
*
* @param font - Unified font to load
* @param weight - Numeric weight (100-900)
* @returns Single-element config array, or `[]` when no URL resolves
* @throws Error when weight is outside the valid 100-900 range (propagated from `getFontUrl`)
*/
export function createFontLoadRequestContfig(font: UnifiedFont, weight: number): FontLoadRequestConfig[] {
const url = getFontUrl(font, weight);
if (!url) {
return [];
}
return [{ id: font.id, name: font.name, weight, url, isVariable: font.features?.isVariable }];
}
+1 -5
View File
@@ -1,5 +1,3 @@
import { NonRetryableError } from '$shared/api/nonRetryableError';
/**
* Thrown when the network request to the proxy API fails.
* Wraps the underlying fetch error (timeout, DNS failure, connection refused, etc.).
@@ -14,13 +12,11 @@ export class FontNetworkError extends Error {
/**
* Thrown when the proxy API returns a response with an unexpected shape.
* Extends NonRetryableError because schema mismatches are not transient
* retrying will produce the same failure and only delay surfacing the bug.
*
* @property field - The name of the field that failed validation (e.g. `'response'`, `'response.fonts'`).
* @property received - The actual value received at that field, for debugging.
*/
export class FontResponseError extends NonRetryableError {
export class FontResponseError extends Error {
readonly name = 'FontResponseError';
constructor(
+44
View File
@@ -1,5 +1,49 @@
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,
@@ -1,5 +1,9 @@
/**
* Mock font data: factory functions and preset fixtures.
* ============================================================================
* MOCK FONT DATA
* ============================================================================
*
* Factory functions and preset mock data for fonts.
* Used in Storybook stories, tests, and development.
*
* ## Usage
@@ -12,7 +16,7 @@
* GOOGLE_FONTS,
* FONTHARE_FONTS,
* UNIFIED_FONTS,
* } from '$entities/Font/testing';
* } from '$entities/Font/lib/mocks';
*
* // Create a mock Google Font
* const roboto = mockGoogleFont({ family: 'Roboto', category: 'sans-serif' });
@@ -24,7 +28,7 @@
* const font = mockUnifiedFont({ id: 'roboto', name: 'Roboto' });
*
* // Use preset fonts
* import { UNIFIED_FONTS } from '$entities/Font/testing';
* import { UNIFIED_FONTS } from '$entities/Font/lib/mocks';
* ```
*/
@@ -1,5 +1,8 @@
/**
* Mock data helpers (main export).
* ============================================================================
* MOCK DATA HELPERS - MAIN EXPORT
* ============================================================================
*
* Comprehensive mock data for Storybook stories, tests, and development.
*
* ## Quick Start
@@ -10,7 +13,7 @@
* UNIFIED_FONTS,
* MOCK_FILTERS,
* createMockFontStoreState,
* } from '$entities/Font/testing';
* } from '$entities/Font/lib/mocks';
*
* // Use in stories
* const font = mockUnifiedFont({ name: 'My Font', category: 'serif' });
@@ -8,7 +8,7 @@
* import {
* createMockQueryState,
* MOCK_STORES,
* } from '$entities/Font/testing';
* } from '$entities/Font/lib/mocks';
*
* // Create a mock query state
* const loadingState = createMockQueryState({ status: 'pending' });
@@ -21,7 +21,11 @@
*/
import type { UnifiedFont } from '$entities/Font/model/types';
import type { QueryStatus } from '@tanstack/svelte-query';
import type {
QueryKey,
QueryObserverResult,
QueryStatus,
} from '@tanstack/svelte-query';
import {
UNIFIED_FONTS,
generateMockFonts,
@@ -663,10 +667,10 @@ export const MOCK_STORES = {
};
},
/**
* Create a mock FontCatalogStore object
* Matches FontCatalogStore's public API for Storybook use
* Create a mock FontStore object
* Matches FontStore's public API for Storybook use
*/
fontCatalogStore: (config: {
fontStore: (config: {
/**
* Preset font list
*/
@@ -1,20 +1,7 @@
// @vitest-environment jsdom
import { TextLayoutEngine } from '$shared/lib';
import { installCanvasMock } from '$shared/lib/helpers/__mocks__/canvas';
import {
clearCache,
layout,
} from '@chenglou/pretext';
// Wrap pretext's `layout` in a spy-able mock so tests can assert call counts.
// `vi.mock` is hoisted, so the import above receives the mocked module.
vi.mock('@chenglou/pretext', async () => {
const actual = await vi.importActual<typeof import('@chenglou/pretext')>('@chenglou/pretext');
return {
...actual,
layout: vi.fn(actual.layout),
};
});
import { mockUnifiedFont } from '$entities/Font/testing';
import { clearCache } from '@chenglou/pretext';
import {
beforeEach,
describe,
@@ -23,6 +10,7 @@ import {
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.
@@ -124,13 +112,13 @@ describe('createFontRowSizeResolver', () => {
const { resolver } = makeResolver();
statusMap.set('inter@400', 'loaded');
const layoutSpy = vi.mocked(layout);
layoutSpy.mockClear();
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)', () => {
@@ -138,14 +126,14 @@ describe('createFontRowSizeResolver', () => {
const { resolver } = makeResolver({ getContainerWidth: () => width });
statusMap.set('inter@400', 'loaded');
const layoutSpy = vi.mocked(layout);
layoutSpy.mockClear();
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)', () => {
@@ -1,8 +1,5 @@
import {
layout,
prepare,
} from '@chenglou/pretext';
import { generateFontKey } from '../../model/store/fontLifecycleManager/utils/generateFontKey/generateFontKey';
import { TextLayoutEngine } from '$shared/lib';
import { generateFontKey } from '../../model/store/appliedFontsStore/utils/generateFontKey/generateFontKey';
import type {
FontLoadStatus,
UnifiedFont,
@@ -44,7 +41,7 @@ export interface FontRowSizeResolverOptions {
/**
* Returns the font load status for a given font key (`'{id}@{weight}'` or `'{id}@vf'`).
*
* In production: `(key) => fontLifecycleManager.statuses.get(key)`.
* 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
@@ -82,13 +79,14 @@ export interface FontRowSizeResolverOptions {
* no DOM snap occurs.
*
* **Caching:** A `Map` keyed by `fontCssString|text|contentWidth|lineHeightPx`
* prevents redundant `pretext.layout()` calls. The cache is invalidated
* 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>();
@@ -110,7 +108,7 @@ export function createFontRowSizeResolver(options: FontRowSizeResolverOptions):
// generateFontKey: '{id}@{weight}' for static fonts, '{id}@vf' for variable fonts.
const fontKey = generateFontKey({ id: font.id, weight, isVariable: font.features?.isVariable });
// Reading via getStatus() allows the caller to pass fontLifecycleManager.statuses.get(),
// 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') {
@@ -128,11 +126,7 @@ export function createFontRowSizeResolver(options: FontRowSizeResolverOptions):
return cached;
}
// Pretext docs recommend `layout()` (not `layoutWithLines`) for the
// resize hot path — pure arithmetic on cached segment widths, no canvas
// calls, no string allocations.
const prepared = prepare(previewText, fontCssString);
const { height: totalHeight } = layout(prepared, contentWidth, lineHeightPx);
const { totalHeight } = engine.layout(previewText, fontCssString, contentWidth, lineHeightPx);
const result = totalHeight + options.chromeHeight;
cache.set(cacheKey, result);
return result;
+57
View File
@@ -1,3 +1,6 @@
import type { ControlModel } from '$shared/lib';
import type { ControlId } from '../types/typography';
/**
* Font size constants
*/
@@ -30,6 +33,60 @@ export const MIN_LETTER_SPACING = -0.1;
export const MAX_LETTER_SPACING = 0.5;
export const LETTER_SPACING_STEP = 0.01;
export const DEFAULT_TYPOGRAPHY_CONTROLS_DATA: ControlModel<ControlId>[] = [
{
id: 'font_size',
value: DEFAULT_FONT_SIZE,
max: MAX_FONT_SIZE,
min: MIN_FONT_SIZE,
step: FONT_SIZE_STEP,
increaseLabel: 'Increase Font Size',
decreaseLabel: 'Decrease Font Size',
controlLabel: 'Size',
},
{
id: 'font_weight',
value: DEFAULT_FONT_WEIGHT,
max: MAX_FONT_WEIGHT,
min: MIN_FONT_WEIGHT,
step: FONT_WEIGHT_STEP,
increaseLabel: 'Increase Font Weight',
decreaseLabel: 'Decrease Font Weight',
controlLabel: 'Weight',
},
{
id: 'line_height',
value: DEFAULT_LINE_HEIGHT,
max: MAX_LINE_HEIGHT,
min: MIN_LINE_HEIGHT,
step: LINE_HEIGHT_STEP,
increaseLabel: 'Increase Line Height',
decreaseLabel: 'Decrease Line Height',
controlLabel: 'Leading',
},
{
id: 'letter_spacing',
value: DEFAULT_LETTER_SPACING,
max: MAX_LETTER_SPACING,
min: MIN_LETTER_SPACING,
step: LETTER_SPACING_STEP,
increaseLabel: 'Increase Letter Spacing',
decreaseLabel: 'Decrease Letter Spacing',
controlLabel: 'Tracking',
},
];
/**
* Font size multipliers
*/
export const MULTIPLIER_S = 0.5;
export const MULTIPLIER_M = 0.75;
export const MULTIPLIER_L = 1;
/**
* Index value for items not yet loaded in a virtualized list.
* Treated as being at the very bottom of the infinite scroll.
+3 -51
View File
@@ -1,51 +1,3 @@
export {
DEFAULT_FONT_SIZE,
DEFAULT_FONT_WEIGHT,
DEFAULT_LETTER_SPACING,
DEFAULT_LINE_HEIGHT,
FONT_SIZE_STEP,
FONT_WEIGHT_STEP,
LETTER_SPACING_STEP,
LINE_HEIGHT_STEP,
MAX_FONT_SIZE,
MAX_FONT_WEIGHT,
MAX_LETTER_SPACING,
MAX_LINE_HEIGHT,
MIN_FONT_SIZE,
MIN_FONT_WEIGHT,
MIN_LETTER_SPACING,
MIN_LINE_HEIGHT,
VIRTUAL_INDEX_NOT_LOADED,
} from './const/const';
// Stores (lazy accessors + classes)
export {
__resetFontLifecycleManager,
FontLifecycleManager,
FontsByIdsStore,
getFontCatalog,
getFontLifecycleManager,
} from './store';
export type { FontCatalogStore } from './store';
export type {
FilterGroup,
FilterType,
FontCategory,
FontCollectionFilters,
FontCollectionSort,
FontCollectionState,
FontFeatures,
FontFilters,
FontLoadRequestConfig,
FontLoadStatus,
FontMetadata,
FontProvider,
FontStyleUrls,
FontSubset,
FontVariant,
FontWeight,
FontWeightItalic,
UnifiedFont,
UnifiedFontVariant,
} from './types';
export * from './const/const';
export * from './store';
export * from './types';
@@ -1,8 +1,8 @@
/**
* @vitest-environment jsdom
*/
import { AppliedFontsManager } from './appliedFontsStore.svelte';
import { FontFetchError } from './errors';
import { FontLifecycleManager } from './fontLifecycleManager.svelte';
import { FontEvictionPolicy } from './utils/fontEvictionPolicy/FontEvictionPolicy';
class FakeBufferCache {
@@ -32,8 +32,8 @@ const makeConfig = (id: string, overrides: Partial<{ weight: number; isVariable:
...overrides,
});
describe('FontLifecycleManager', () => {
let manager: FontLifecycleManager;
describe('AppliedFontsManager', () => {
let manager: AppliedFontsManager;
let eviction: FontEvictionPolicy;
let mockFontFaceSet: { add: ReturnType<typeof vi.fn>; delete: ReturnType<typeof vi.fn> };
@@ -55,7 +55,7 @@ describe('FontLifecycleManager', () => {
});
vi.stubGlobal('FontFace', MockFontFace);
manager = new FontLifecycleManager({ cache: new FakeBufferCache() as any, eviction });
manager = new AppliedFontsManager({ cache: new FakeBufferCache() as any, eviction });
});
afterEach(() => {
@@ -101,7 +101,7 @@ describe('FontLifecycleManager', () => {
it('skips fonts that have exhausted retries', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const failManager = new FontLifecycleManager({ cache: new FailingBufferCache() as any, eviction });
const failManager = new AppliedFontsManager({ cache: new FailingBufferCache() as any, eviction });
// exhaust all 3 retries
for (let i = 0; i < 3; i++) {
@@ -160,7 +160,7 @@ describe('FontLifecycleManager', () => {
describe('Phase 1 — fetch', () => {
it('sets status to error on fetch failure', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const failManager = new FontLifecycleManager({ cache: new FailingBufferCache() as any, eviction });
const failManager = new AppliedFontsManager({ cache: new FailingBufferCache() as any, eviction });
failManager.touch([makeConfig('broken')]);
await vi.advanceTimersByTimeAsync(50);
@@ -171,7 +171,7 @@ describe('FontLifecycleManager', () => {
it('logs a console error on fetch failure', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const failManager = new FontLifecycleManager({ cache: new FailingBufferCache() as any, eviction });
const failManager = new AppliedFontsManager({ cache: new FailingBufferCache() as any, eviction });
failManager.touch([makeConfig('broken')]);
await vi.advanceTimersByTimeAsync(50);
@@ -189,7 +189,7 @@ describe('FontLifecycleManager', () => {
evict() {},
clear() {},
};
const abortManager = new FontLifecycleManager({ cache: abortingCache as any, eviction });
const abortManager = new AppliedFontsManager({ cache: abortingCache as any, eviction });
abortManager.touch([makeConfig('aborted')]);
await vi.advanceTimersByTimeAsync(50);
@@ -1,4 +1,3 @@
import { createSingleton } from '$shared/lib/helpers/createSingleton/createSingleton';
import { SvelteMap } from 'svelte/reactivity';
import {
type FontLoadRequestConfig,
@@ -18,36 +17,7 @@ import { FontBufferCache } from './utils/fontBufferCache/FontBufferCache';
import { FontEvictionPolicy } from './utils/fontEvictionPolicy/FontEvictionPolicy';
import { FontLoadQueue } from './utils/fontLoadQueue/FontLoadQueue';
/**
* How often the periodic eviction sweep runs.
*/
const PURGE_INTERVAL_MS = 60000;
/**
* Timeout for `requestIdleCallback`. After this elapses, the callback is
* forced to run regardless of whether the browser is idle.
*/
const IDLE_CALLBACK_TIMEOUT_MS = 150;
/**
* setTimeout fallback delay when `requestIdleCallback` is unavailable.
* ~16ms one frame at 60fps.
*/
const SCHEDULE_FALLBACK_MS = 16;
/**
* How often the parse loop yields back to the main thread when the browser
* does not provide `isInputPending` (non-Chromium fallback).
*/
const YIELD_INTERVAL_MS = 8;
/**
* Font weights treated as "critical" in data-saver mode. Other weights are
* skipped to reduce network usage; variable fonts bypass this filter.
*/
const CRITICAL_FONT_WEIGHTS = [400, 700];
interface FontLifecycleManagerDeps {
interface AppliedFontsManagerDeps {
cache?: FontBufferCache;
eviction?: FontEvictionPolicy;
queue?: FontLoadQueue;
@@ -76,7 +46,7 @@ interface FontLifecycleManagerDeps {
*
* **Browser APIs Used:** `scheduler.yield()`, `isInputPending()`, `requestIdleCallback`, Cache API, Network Information API
*/
export class FontLifecycleManager {
export class AppliedFontsManager {
// Injected collaborators - each handles one concern for better testability
readonly #cache: FontBufferCache;
readonly #eviction: FontEvictionPolicy;
@@ -100,20 +70,22 @@ export class FontLifecycleManager {
// 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() }:
FontLifecycleManagerDeps = {},
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(), PURGE_INTERVAL_MS);
this.#intervalId = setInterval(() => this.#purgeUnused(), this.#PURGE_INTERVAL);
}
}
@@ -175,11 +147,11 @@ export class FontLifecycleManager {
if (typeof requestIdleCallback !== 'undefined') {
this.#timeoutId = requestIdleCallback(
() => this.#processQueue(),
{ timeout: IDLE_CALLBACK_TIMEOUT_MS },
{ timeout: 150 },
) as unknown as ReturnType<typeof setTimeout>;
this.#pendingType = 'idle';
} else {
this.#timeoutId = setTimeout(() => this.#processQueue(), SCHEDULE_FALLBACK_MS);
this.#timeoutId = setTimeout(() => this.#processQueue(), 16);
this.#pendingType = 'timeout';
}
}
@@ -211,7 +183,7 @@ export class FontLifecycleManager {
// In data-saver mode, only load variable fonts and common weights (400, 700)
if (this.#shouldDeferNonCritical()) {
entries = entries.filter(([, c]) => c.isVariable || CRITICAL_FONT_WEIGHTS.includes(c.weight));
entries = entries.filter(([, c]) => c.isVariable || [400, 700].includes(c.weight));
}
// Determine optimal concurrent fetches based on network speed (1-4)
@@ -226,6 +198,7 @@ export class FontLifecycleManager {
// 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);
@@ -241,7 +214,7 @@ export class FontLifecycleManager {
// Others: yield every 8ms as fallback
const shouldYield = hasInputPending
? (navigator as any).scheduling.isInputPending({ includeContinuous: true })
: performance.now() - lastYield > YIELD_INTERVAL_MS;
: performance.now() - lastYield > YIELD_INTERVAL;
if (shouldYield) {
await yieldToMainThread();
@@ -421,15 +394,6 @@ export class FontLifecycleManager {
}
/**
* App-wide font lifecycle manager, created on first access. Lazy so its
* AbortController / FontFace bookkeeping isn't set up at module load.
* Singleton instance use throughout the application for unified font loading state.
*/
const fontLifecycleManager = createSingleton(
() => new FontLifecycleManager(),
instance => instance.destroy(),
);
export const getFontLifecycleManager = fontLifecycleManager.get;
// test-only reset, so specs don't share loaded-font/eviction state
export const __resetFontLifecycleManager = fontLifecycleManager.reset;
export const appliedFontsManager = new AppliedFontsManager();
@@ -1,11 +1,6 @@
/**
* Default TTL after which an unpinned font is eligible for eviction.
*/
export const DEFAULT_FONT_TTL_MS = 5 * 60 * 1000;
interface FontEvictionPolicyOptions {
/**
* TTL in milliseconds. Defaults to {@link DEFAULT_FONT_TTL_MS}.
* TTL in milliseconds. Defaults to 5 minutes.
*/
ttl?: number;
}
@@ -22,7 +17,7 @@ export class FontEvictionPolicy {
readonly #TTL: number;
constructor({ ttl = DEFAULT_FONT_TTL_MS }: FontEvictionPolicyOptions = {}) {
constructor({ ttl = 5 * 60 * 1000 }: FontEvictionPolicyOptions = {}) {
this.#TTL = ttl;
}
@@ -1,11 +1,5 @@
import type { FontLoadRequestConfig } from '../../../../types';
/**
* Maximum number of times a single font key will be retried before it is
* considered permanently failed.
*/
export const FONT_LOAD_MAX_RETRIES = 3;
/**
* Manages the font load queue and per-font retry counts.
*
@@ -16,6 +10,8 @@ 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.
@@ -56,7 +52,7 @@ export class FontLoadQueue {
* Returns `true` if the font has reached or exceeded the maximum retry limit.
*/
isMaxRetriesReached(key: string): boolean {
return (this.#retryCounts.get(key) ?? 0) >= FONT_LOAD_MAX_RETRIES;
return (this.#retryCounts.get(key) ?? 0) >= this.#MAX_RETRIES;
}
/**
@@ -71,7 +71,7 @@ describe('loadFont', () => {
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) {
function(this: any, name: string, buffer: BufferSource, options: FontFaceDescriptors) {
this.load = vi.fn().mockRejectedValue(loadError);
},
);
@@ -2,7 +2,9 @@
* 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 => {
@@ -1,14 +1,14 @@
import { fontKeys } from '$shared/api/queryKeys';
import { BaseQueryStore } from '$shared/lib/helpers/BaseQueryStore/BaseQueryStore.svelte';
import { BaseQueryStore } from '$shared/lib/helpers/BaseQueryStore.svelte';
import {
fetchFontsByIds,
seedFontCache,
} from '../../../api/proxy/proxyFonts';
} from '../../api/proxy/proxyFonts';
import {
FontNetworkError,
FontResponseError,
} from '../../../lib/errors/errors';
import type { UnifiedFont } from '../../types';
} from '../../lib/errors/errors';
import type { UnifiedFont } from '../../model/types';
/**
* Internal fetcher that seeds the cache and handles error wrapping.
@@ -35,10 +35,11 @@ async function fetchAndSeed(ids: string[]): Promise<UnifiedFont[]> {
}
/**
* Reactive store for fetching specific fonts by ID via the proxy batch endpoint.
* Wraps TanStack Query and seeds the detail cache for sibling consumers.
* Reactive store for fetching and caching batches of fonts by ID.
* Integrates with TanStack Query via BaseQueryStore and handles
* normalized cache seeding.
*/
export class FontsByIdsStore extends BaseQueryStore<UnifiedFont[]> {
export class BatchFontStore extends BaseQueryStore<UnifiedFont[]> {
constructor(initialIds: string[] = []) {
super({
queryKey: fontKeys.batch(initialIds),
@@ -1,6 +1,4 @@
import { getQueryClient } from '$shared/api/queryClient';
const queryClient = getQueryClient();
import { queryClient } from '$shared/api/queryClient';
import { fontKeys } from '$shared/api/queryKeys';
import {
beforeEach,
@@ -9,14 +7,14 @@ import {
it,
vi,
} from 'vitest';
import * as api from '../../../api/proxy/proxyFonts';
import * as api from '../../api/proxy/proxyFonts';
import {
FontNetworkError,
FontResponseError,
} from '../../../lib/errors/errors';
import { FontsByIdsStore } from './fontsByIdsStore.svelte';
} from '../../lib/errors/errors';
import { BatchFontStore } from './batchFontStore.svelte';
describe('FontsByIdsStore', () => {
describe('BatchFontStore', () => {
beforeEach(() => {
queryClient.clear();
vi.clearAllMocks();
@@ -25,7 +23,7 @@ describe('FontsByIdsStore', () => {
describe('Fetch Behavior', () => {
it('should skip fetch when initialized with empty IDs', async () => {
const spy = vi.spyOn(api, 'fetchFontsByIds');
const store = new FontsByIdsStore([]);
const store = new BatchFontStore([]);
expect(spy).not.toHaveBeenCalled();
expect(store.fonts).toEqual([]);
});
@@ -33,7 +31,7 @@ describe('FontsByIdsStore', () => {
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 FontsByIdsStore(['a']);
const store = new BatchFontStore(['a']);
await vi.waitFor(() => expect(store.fonts).toEqual(fonts), { timeout: 1000 });
expect(queryClient.getQueryData(fontKeys.detail('a'))).toEqual(fonts[0]);
});
@@ -44,7 +42,7 @@ describe('FontsByIdsStore', () => {
vi.spyOn(api, 'fetchFontsByIds').mockImplementation(() =>
new Promise(r => setTimeout(() => r([{ id: 'a' }] as any), 50))
);
const store = new FontsByIdsStore(['a']);
const store = new BatchFontStore(['a']);
expect(store.isLoading).toBe(true);
await vi.waitFor(() => expect(store.isLoading).toBe(false), { timeout: 1000 });
});
@@ -53,7 +51,7 @@ describe('FontsByIdsStore', () => {
describe('Error Handling', () => {
it('should wrap network failures in FontNetworkError', async () => {
vi.spyOn(api, 'fetchFontsByIds').mockRejectedValue(new Error('Network fail'));
const store = new FontsByIdsStore(['a']);
const store = new BatchFontStore(['a']);
await vi.waitFor(() => expect(store.isError).toBe(true), { timeout: 1000 });
expect(store.error).toBeInstanceOf(FontNetworkError);
});
@@ -61,7 +59,7 @@ describe('FontsByIdsStore', () => {
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 FontsByIdsStore(['a']);
const store = new BatchFontStore(['a']);
await vi.waitFor(() => expect(store.isError).toBe(true), { timeout: 1000 });
expect(store.error).toBeInstanceOf(FontResponseError);
});
@@ -69,7 +67,7 @@ describe('FontsByIdsStore', () => {
it('should have null error in success state', async () => {
const fonts = [{ id: 'a' }] as any[];
vi.spyOn(api, 'fetchFontsByIds').mockResolvedValue(fonts);
const store = new FontsByIdsStore(['a']);
const store = new BatchFontStore(['a']);
await vi.waitFor(() => expect(store.fonts).toEqual(fonts), { timeout: 1000 });
expect(store.error).toBeNull();
});
@@ -80,7 +78,7 @@ describe('FontsByIdsStore', () => {
const fonts1 = [{ id: 'a' }] as any[];
const spy = vi.spyOn(api, 'fetchFontsByIds').mockResolvedValueOnce(fonts1);
const store = new FontsByIdsStore(['a']);
const store = new BatchFontStore(['a']);
await vi.waitFor(() => expect(store.fonts).toEqual(fonts1), { timeout: 1000 });
spy.mockClear();
@@ -99,7 +97,7 @@ describe('FontsByIdsStore', () => {
.mockResolvedValueOnce(fonts1)
.mockResolvedValueOnce(fonts2);
const store = new FontsByIdsStore(['a']);
const store = new BatchFontStore(['a']);
await vi.waitFor(() => expect(store.fonts).toEqual(fonts1), { timeout: 1000 });
store.setIds(['b']);
@@ -1,7 +1,4 @@
import {
generateMixedCategoryFonts,
generateMockFonts,
} from '$entities/Font/testing';
import { QueryClient } from '@tanstack/query-core';
import { flushSync } from 'svelte';
import {
afterEach,
@@ -15,33 +12,23 @@ import {
FontNetworkError,
FontResponseError,
} from '../../../lib/errors/errors';
import {
generateMixedCategoryFonts,
generateMockFonts,
} from '../../../lib/mocks/fonts.mock';
import type { UnifiedFont } from '../../types';
import { FontCatalogStore } from './fontCatalogStore.svelte';
import { FontStore } from './fontStore.svelte';
vi.mock('$shared/api/queryClient', async importOriginal => {
/**
* Import QueryClient inside the factory rather than referencing the top-level binding.
* A hoisted vi.mock factory that touches a module-level import can hit that import
* before it is initialized (ReferenceError) when the import sits in a circular/eager
* barrel chain which it now does via $shared/lib BaseQueryStore query-core.
*/
const { QueryClient } = await import('@tanstack/query-core');
const actual = await importOriginal<typeof import('$shared/api/queryClient')>();
const mockClient = new QueryClient({
vi.mock('$shared/api/queryClient', () => ({
queryClient: new QueryClient({
defaultOptions: { queries: { retry: 0, gcTime: 0 } },
});
return {
...actual,
getQueryClient: () => mockClient,
};
});
}),
}));
vi.mock('../../../api', () => ({ fetchProxyFonts: vi.fn() }));
import { getQueryClient } from '$shared/api/queryClient';
import { queryClient } from '$shared/api/queryClient';
import { fetchProxyFonts } from '../../../api';
const queryClient = getQueryClient();
const fetch = fetchProxyFonts as ReturnType<typeof vi.fn>;
type FontPage = { fonts: UnifiedFont[]; total: number; limit: number; offset: number };
@@ -57,7 +44,7 @@ const makeResponse = (
});
function makeStore(params = {}) {
return new FontCatalogStore({ limit: 10, ...params });
return new FontStore({ limit: 10, ...params });
}
async function fetchedStore(params = {}, fonts = generateMockFonts(5), meta: Parameters<typeof makeResponse>[1] = {}) {
@@ -68,7 +55,7 @@ async function fetchedStore(params = {}, fonts = generateMockFonts(5), meta: Par
return store;
}
describe('FontCatalogStore', () => {
describe('FontStore', () => {
afterEach(() => {
queryClient.clear();
vi.resetAllMocks();
@@ -82,7 +69,7 @@ describe('FontCatalogStore', () => {
});
it('defaults limit to 50 when not provided', () => {
const store = new FontCatalogStore();
const store = new FontStore();
expect(store.params.limit).toBe(50);
store.destroy();
});
@@ -93,10 +80,9 @@ describe('FontCatalogStore', () => {
store.destroy();
});
it('starts with isEmpty false — observer is gated until setParams enables it', () => {
// The observer is disabled on construction (no auto-fetch) — see
// `#enabled` in the store. isEmpty must still be false so the UI
// doesn't flash "no results" before bindings configures the query.
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();
@@ -404,11 +390,11 @@ describe('FontCatalogStore', () => {
});
describe('nextPage', () => {
let store: FontCatalogStore;
let store: FontStore;
beforeEach(async () => {
fetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 30, limit: 10, offset: 0 }));
store = new FontCatalogStore({ limit: 10 });
store = new FontStore({ limit: 10 });
await store.refetch();
flushSync();
});
@@ -429,7 +415,7 @@ describe('FontCatalogStore', () => {
// Set up a store where all fonts fit in one page (hasMore = false)
queryClient.clear();
fetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 10, limit: 10, offset: 0 }));
store = new FontCatalogStore({ limit: 10 });
store = new FontStore({ limit: 10 });
await store.refetch();
flushSync();
@@ -468,7 +454,7 @@ describe('FontCatalogStore', () => {
describe('getCachedData / setQueryData', () => {
it('getCachedData returns undefined before any fetch', () => {
queryClient.clear();
const store = new FontCatalogStore({ limit: 10 });
const store = new FontStore({ limit: 10 });
expect(store.getCachedData()).toBeUndefined();
store.destroy();
});
@@ -516,7 +502,7 @@ describe('FontCatalogStore', () => {
});
describe('filter shortcut methods', () => {
let store: FontCatalogStore;
let store: FontStore;
beforeEach(() => {
store = makeStore();
@@ -1,9 +1,4 @@
import {
DEFAULT_QUERY_GC_TIME_MS,
DEFAULT_QUERY_STALE_TIME_MS,
getQueryClient,
} from '$shared/api/queryClient';
import { createSingleton } from '$shared/lib/helpers/createSingleton/createSingleton';
import { queryClient } from '$shared/api/queryClient';
import {
type InfiniteData,
InfiniteQueryObserver,
@@ -30,15 +25,8 @@ type FontStoreParams = Omit<ProxyFontsParams, 'offset'>;
type FontStoreResult = InfiniteQueryObserverResult<InfiniteData<ProxyFontsResponse, PageParam>, Error>;
export class FontCatalogStore {
export class FontStore {
#params = $state<FontStoreParams>({ limit: 50 });
/**
* Gates the initial fetch. The observer starts disabled so the constructor
* cannot race ahead of the bindings module which is the single source of
* truth for query params. The first setParams flips this on, producing a
* single fetch with the correctly merged queryKey.
*/
#enabled = $state(false);
#result = $state<FontStoreResult>({} as FontStoreResult);
#observer: InfiniteQueryObserver<
ProxyFontsResponse,
@@ -47,14 +35,12 @@ export class FontCatalogStore {
readonly unknown[],
PageParam
>;
#qc = getQueryClient();
#qc = queryClient;
#unsubscribe: () => void;
constructor(params: FontStoreParams = {}) {
this.#params = { limit: 50, ...params };
this.#observer = new InfiniteQueryObserver(this.#qc, this.buildOptions());
// Seed result synchronously; subscribe may not fire on disabled observers.
this.#result = this.#observer.getCurrentResult();
this.#unsubscribe = this.#observer.subscribe(r => {
this.#result = r;
});
@@ -98,13 +84,10 @@ export class FontCatalogStore {
return this.#result.error ?? null;
}
/**
* True if no fonts were found for the current filter criteria.
* Always false until the observer has been enabled (via setParams) otherwise
* the UI would briefly render "no results" on mount before bindings configures
* the query.
* True if no fonts were found for the current filter criteria
*/
get isEmpty(): boolean {
return this.#enabled && !this.isLoading && !this.isFetching && this.fonts.length === 0;
return !this.isLoading && !this.isFetching && this.fonts.length === 0;
}
/**
@@ -142,12 +125,10 @@ export class FontCatalogStore {
}
/**
* Merge new parameters into existing state and trigger a refetch.
* The first call also enables the observer (see `#enabled`).
* Merge new parameters into existing state and trigger a refetch
*/
setParams(updates: Partial<FontStoreParams>) {
this.#params = { ...this.#params, ...updates };
this.#enabled = true;
this.#observer.setOptions(this.buildOptions());
}
/**
@@ -446,9 +427,8 @@ export class FontCatalogStore {
const next = lastPage.offset + lastPage.limit;
return next < lastPage.total ? { offset: next } : undefined;
},
enabled: this.#enabled,
staleTime: hasFilters ? 0 : DEFAULT_QUERY_STALE_TIME_MS,
gcTime: DEFAULT_QUERY_GC_TIME_MS,
staleTime: hasFilters ? 0 : 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,
};
}
@@ -457,11 +437,6 @@ export class FontCatalogStore {
try {
response = await fetchProxyFonts(params);
} catch (cause) {
// Preserve non-retryable validation errors so the query client doesn't
// burn the retry budget on a deterministic schema mismatch.
if (cause instanceof FontResponseError) {
throw cause;
}
throw new FontNetworkError(cause);
}
@@ -484,12 +459,8 @@ export class FontCatalogStore {
}
}
const catalog = createSingleton(
() => new FontCatalogStore({ limit: 50 }),
instance => instance.destroy(),
);
export function createFontStore(params: FontStoreParams = {}): FontStore {
return new FontStore(params);
}
export const getFontCatalog = catalog.get;
// test-only reset, so specs don't share a live observer
export const __resetFontCatalog = catalog.reset;
export const fontStore = new FontStore({ limit: 50 });
+11 -12
View File
@@ -1,13 +1,12 @@
// Font lifecycle manager (browser-side load + cache + eviction)
// Applied fonts manager
export * from './appliedFontsStore/appliedFontsStore.svelte';
// Batch font store
export { BatchFontStore } from './batchFontStore.svelte';
// Single FontStore
export {
__resetFontLifecycleManager,
FontLifecycleManager,
getFontLifecycleManager,
} from './fontLifecycleManager/fontLifecycleManager.svelte';
// Paginated catalog
export { getFontCatalog } from './fontCatalogStore/fontCatalogStore.svelte';
export type { FontCatalogStore } from './fontCatalogStore/fontCatalogStore.svelte';
// Batch fetch by IDs (detail-cache seeding)
export { FontsByIdsStore } from './fontsByIdsStore/fontsByIdsStore.svelte';
createFontStore,
FontStore,
fontStore,
} from './fontStore/fontStore.svelte';
+2 -4
View File
@@ -23,7 +23,5 @@ export type {
FontCollectionState,
} from './store';
export type {
FontLoadRequestConfig,
FontLoadStatus,
} from './store/fontLifecycle';
export * from './store/appliedFonts';
export * from './typography';
@@ -0,0 +1 @@
export type ControlId = 'font_size' | 'font_weight' | 'line_height' | 'letter_spacing';
@@ -10,20 +10,20 @@ const { Story } = defineMeta({
docs: {
description: {
component:
'Applies a font to its children based on the supplied load `status`. Renders the skeleton (or system font) until status is `loaded`/`error`, then reveals the font. The status is provided by the composing widget — the component does not read the lifecycle store itself.',
'Loads a font and applies it to children. Shows blur/scale loading state until font is ready, then reveals with a smooth transition.',
},
story: { inline: false },
},
layout: 'centered',
},
argTypes: {
status: { control: 'select', options: ['loading', 'loaded', 'error'] },
weight: { control: 'number' },
},
});
</script>
<script lang="ts">
import { mockUnifiedFont } from '$entities/Font/testing';
import { mockUnifiedFont } from '$entities/Font/lib/mocks';
import type { ComponentProps } from 'svelte';
const fontUnknown = mockUnifiedFont({ id: 'nonexistent-font-xk92z', name: 'Nonexistent Font Xk92z' });
@@ -39,11 +39,11 @@ const fontArialBold = mockUnifiedFont({ id: 'arial-bold', name: 'Arial' });
docs: {
description: {
story:
'Status is `loading`: the font file has not resolved yet, so children render in the skeleton (or system font) fallback rather than the target font.',
'Font that has never been loaded by appliedFontsManager. The component renders in its pending state: blurred, scaled down, and semi-transparent.',
},
},
}}
args={{ font: fontUnknown, status: 'loading' }}
args={{ font: fontUnknown, weight: 400 }}
>
{#snippet template(args: ComponentProps<typeof FontApplicator>)}
<FontApplicator {...args}>
@@ -58,11 +58,11 @@ const fontArialBold = mockUnifiedFont({ id: 'arial-bold', name: 'Arial' });
docs: {
description: {
story:
'Status is `loaded`: the component reveals the font, applying it to its children (Arial here, available in all browsers).',
'Uses Arial, a system font available in all browsers. Because appliedFontsManager has not loaded it via FontFace, the manager status may remain pending — meaning the blur/scale state may still show. In a real app the manager would load the font and transition to the revealed state.',
},
},
}}
args={{ font: fontArial, status: 'loaded' }}
args={{ font: fontArial, weight: 400 }}
>
{#snippet template(args: ComponentProps<typeof FontApplicator>)}
<FontApplicator {...args}>
@@ -72,16 +72,16 @@ const fontArialBold = mockUnifiedFont({ id: 'arial-bold', name: 'Arial' });
</Story>
<Story
name="Error State"
name="Custom Weight"
parameters={{
docs: {
description: {
story:
'Status is `error`: the font failed to load. The component still reveals (it treats `error` like `loaded` for reveal purposes) so children are not stuck behind the skeleton — they fall back to the system font.',
'Demonstrates passing a custom weight (700). The weight is forwarded to appliedFontsManager for font resolution; visually identical to the loaded state story until the manager confirms the font.',
},
},
}}
args={{ font: fontArialBold, status: 'error' }}
args={{ font: fontArialBold, weight: 700 }}
>
{#snippet template(args: ComponentProps<typeof FontApplicator>)}
<FontApplicator {...args}>

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