Compare commits
250 Commits
0ebf75b24e
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c78b8e032e | |||
| 11d5ba0e63 | |||
| 99e9a1fb2c | |||
| 5084df3914 | |||
| a2ec025a65 | |||
| 8dbea97a33 | |||
| 744cdc9d19 | |||
| 600b905e01 | |||
| 4ad0fe4cfa | |||
| eafe89b313 | |||
| 724b00d3d5 | |||
| c09ca93f4e | |||
| 99ab7e9e08 | |||
| ec488cf1ce | |||
| fe07c60dd4 | |||
| 0aae710e35 | |||
| ded9606c30 | |||
| f0736f4d35 | |||
| 5eb458eabb | |||
| a428eac309 | |||
| 09869aed00 | |||
| 028853aff5 | |||
| 1c6427c586 | |||
| 60e115309c | |||
| b390efdabe | |||
| 771bda745c | |||
| c6c8497906 | |||
| f3a10e38df | |||
| 9788f07dec | |||
| deefb51b57 | |||
| 431fb41a7f | |||
| db6384110e | |||
| cbd95350bb | |||
| a8a985ee6a | |||
| be073286dc | |||
| 7798c4bbdf | |||
| 3ae22ad515 | |||
| ffa897ee54 | |||
| 93c52dd132 | |||
| 9e0c8f740b | |||
| b1b5177e02 | |||
| ef9cd33e48 | |||
| f3c76df2c5 | |||
| ae2d0e3c2f | |||
| 3f5151efa0 | |||
| 19d9b07c55 | |||
| 1209358d40 | |||
| d7decd7a00 | |||
| 9d6220d2ec | |||
| 4756682863 | |||
| 7ddf232e3a | |||
| b3bc40b76c | |||
| 839460726e | |||
| 6877807aaf | |||
| 3dca11fea8 | |||
| 0b675635b3 | |||
| 9780ff9358 | |||
| 1ad015aed6 | |||
| 10603d18bf | |||
| 39d1ce4c37 | |||
| fcd61be4fa | |||
| 28a8e49915 | |||
| 43e8507144 | |||
| 67af3d946a | |||
| c6d0270072 | |||
| a677dc6b0b | |||
| f7cd6b5081 | |||
| dda8ef6368 | |||
| d77b51736a | |||
| 1e16330097 | |||
| c41016ac5d | |||
| aa4189f6a8 | |||
| 17c022470e | |||
| a9f3b990ab | |||
| 36673597f7 | |||
| 42bcc915c7 | |||
| c72b51b1c7 | |||
| 6888f67f14 | |||
| a651d3d16f | |||
| 4d8dcf52e0 | |||
| 907145c655 | |||
| e49148008b | |||
| c613d4cf88 | |||
| 7834c7cbf2 | |||
| 4640d6e521 | |||
| 8adf5cd7b3 | |||
| b8edeff86f | |||
| 7d66b0bc92 | |||
| ecdb2f1b7f | |||
| 6a07b89773 | |||
| 02aa27dc48 | |||
| 4652857512 | |||
| d5f0814efc | |||
| 6153769317 | |||
| 3e568685b3 | |||
| 581ffb5887 | |||
| 2ece4c5559 | |||
| 1fa099bef5 | |||
| 50238e12c3 | |||
| f13dfe1caf | |||
| f4edb67acb | |||
| ccf51c645e | |||
| efbc464b14 | |||
| c5092a488b | |||
| ddadac8686 | |||
| f6911fbcca | |||
| eb5a8d1e5b | |||
| e698dc6e07 | |||
| 5d72bb7a4c | |||
| 7f20f36d0a | |||
| c90a258f6c | |||
| dec83c93d0 | |||
| b9560336d5 | |||
| 18f1d109ab | |||
| 24f084ae77 | |||
| b9e21a66d3 | |||
| 7a9422b574 | |||
| f79b24272c | |||
| a9229342e6 | |||
| 05cab5f892 | |||
| 0518c84230 | |||
| 5afb9c5d5d | |||
| 4126275c4d | |||
| ffc28f78f5 | |||
| 80241aa352 | |||
| 37886f3aa7 | |||
| 410a7cd37e | |||
| b5fec3a1ba | |||
| 8eee815e9a | |||
| 5b7ec03973 | |||
| 15bb961ccc | |||
| 4e7f76ecb1 | |||
| 06b6274e66 | |||
| 0c59262a59 | |||
| 2bb43797f0 | |||
| ccef3cf7bb | |||
| e3b489f173 | |||
| f92577608a | |||
| 728380498b | |||
| 07d044f4d6 | |||
| df59dfda02 | |||
| ca382fd43d | |||
| e0d39d861f | |||
| b6494a8cb5 | |||
| cc218934f4 | |||
| 3a327e2d92 | |||
| 30621c33df | |||
| cb8f6ffc97 | |||
| 33d3429060 | |||
| e60309af78 | |||
| 1573950605 | |||
| 773ab55f5c | |||
| 67e02e4e75 | |||
| 5ca7a433ff | |||
| 3b6ea99d09 | |||
| f762a09c23 | |||
| 95ae72719e | |||
| f3c4e72b86 | |||
| f41c4aab9c | |||
| d1eb83fa90 | |||
| c01fc79a3e | |||
| 6bfa7ca777 | |||
| 0d4356b8f1 | |||
| c18574d4c3 | |||
| 1c9a7f9fe1 | |||
| fae6694479 | |||
| a105c94176 | |||
| 77c2b27f8b | |||
| 1ce0d6c66f | |||
| 6c20a68e19 | |||
| 3894912a22 | |||
| e8d3727c6a | |||
| 5fbf090b24 | |||
| a94e1f8b65 | |||
| f8ba2d7eb0 | |||
| 3594033bcb | |||
| 2ae24912f7 | |||
| 877719f106 | |||
| 4eafb96d35 | |||
| 652dfa5c90 | |||
| 54087b7b2a | |||
| cffebf05e3 | |||
| ada484e2e0 | |||
| dbcc1caeb0 | |||
| 2c579a3336 | |||
| fe0d4e7daa | |||
| 108df323f9 | |||
| 2803bcd22c | |||
| 47a8487ce9 | |||
| 1d5af5ea70 | |||
| 2221ecad4c | |||
| cd8599d5b5 | |||
| 6c91d570ec | |||
| 91b80a5ada | |||
| 84ac886c33 | |||
| a60dbcfa51 | |||
| 8fc8a7ee6f | |||
| cbc978df6d | |||
| 6664beec25 | |||
| a801903fd3 | |||
| ecdb1e016d | |||
| 092b58e651 | |||
| d6914f8179 | |||
| b831861662 | |||
| 67fc9dee72 | |||
| a73bd75947 | |||
| 836b83f75d | |||
| 07e4a0b9d9 | |||
| 141126530d | |||
| f9f96e2797 | |||
| 3e11821814 | |||
| ee3f773ca5 | |||
| 2a51f031cc | |||
| b792dde7cb | |||
| 66dcffa448 | |||
| cca00fccaa | |||
| af05443763 | |||
| 99d92d487f | |||
| 4a907619cc | |||
| 6c69d7a5b3 | |||
| 993812de0a | |||
| 67c16530af | |||
| fbbb439023 | |||
| c2046770ef | |||
| adfba38063 | |||
| dfb304d436 | |||
| f55043a1e7 | |||
| 409dd1b229 | |||
| 9fbce095b2 | |||
| 171627e0ea | |||
| d07fb1a3af | |||
| 6f84644ecb | |||
| 5ab5cda611 | |||
| 7975d9aeee | |||
| 2ba5fc0e3e | |||
| 1947d7731e | |||
| 38bfc4ba4b | |||
| 6cf3047b74 | |||
| 81363156d7 | |||
| bb65f1c8d6 | |||
| 5eb9584797 | |||
| bb5c3667b4 | |||
| 3711616a91 | |||
| 6905c54040 | |||
| 1e8e22e2eb | |||
| 8a93c7b545 | |||
| 0004b81e40 | |||
| fb1d2765d0 | |||
| 12e8bc0a89 | |||
| cfaff46d59 |
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules
|
||||||
|
.yarn/cache
|
||||||
|
.yarn/unplugged
|
||||||
|
.yarn/install-state.gz
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
.gitea
|
||||||
|
.svelte-kit
|
||||||
|
storybook-static
|
||||||
@@ -41,10 +41,43 @@ jobs:
|
|||||||
run: yarn lint
|
run: yarn lint
|
||||||
|
|
||||||
- name: Type Check
|
- name: Type Check
|
||||||
run: yarn check:shadcn-excluded
|
run: yarn check
|
||||||
|
|
||||||
|
- name: Run Unit Tests
|
||||||
|
run: yarn test:unit
|
||||||
|
|
||||||
|
- name: Run Component Tests
|
||||||
|
timeout-minutes: 5
|
||||||
|
run: yarn test:component --reporter=verbose --logHeapUsage
|
||||||
|
|
||||||
|
e2e:
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: mcr.microsoft.com/playwright:v1.59.0-jammy
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Enable Corepack
|
||||||
|
run: |
|
||||||
|
corepack enable
|
||||||
|
corepack prepare yarn@stable --activate
|
||||||
|
- name: Persistent Yarn Cache
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: .yarn/cache
|
||||||
|
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
|
||||||
|
restore-keys: ${{ runner.os }}-yarn-
|
||||||
|
- name: Install dependencies
|
||||||
|
run: yarn install --immutable
|
||||||
|
- name: Build Svelte SPA
|
||||||
|
run: yarn build
|
||||||
|
- name: E2E Tests
|
||||||
|
timeout-minutes: 15
|
||||||
|
run: yarn test:e2e
|
||||||
|
|
||||||
publish:
|
publish:
|
||||||
needs: build # Only runs if tests/lint pass
|
# Runs if lint, unit-, component-, e2e-tests pass
|
||||||
|
needs: [build, e2e]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: github.ref == 'refs/heads/main' # Only deploy from main branch
|
if: github.ref == 'refs/heads/main' # Only deploy from main branch
|
||||||
steps:
|
steps:
|
||||||
@@ -56,5 +89,9 @@ jobs:
|
|||||||
|
|
||||||
- name: Build and Push Docker Image
|
- name: Build and Push Docker Image
|
||||||
run: |
|
run: |
|
||||||
docker build -t git.allmy.work/${{ gitea.repository }}:latest .
|
docker build \
|
||||||
|
-t git.allmy.work/${{ gitea.repository }}:latest \
|
||||||
|
-t git.allmy.work/${{ gitea.repository }}:${{ gitea.sha }} \
|
||||||
|
.
|
||||||
docker push git.allmy.work/${{ gitea.repository }}:latest
|
docker push git.allmy.work/${{ gitea.repository }}:latest
|
||||||
|
docker push git.allmy.work/${{ gitea.repository }}:${{ gitea.sha }}
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ node_modules
|
|||||||
/build
|
/build
|
||||||
/dist
|
/dist
|
||||||
|
|
||||||
|
# IDE settings
|
||||||
|
.vscode
|
||||||
|
|
||||||
# Git worktrees (isolated development branches)
|
# Git worktrees (isolated development branches)
|
||||||
.worktrees
|
.worktrees
|
||||||
|
|
||||||
@@ -47,3 +50,6 @@ storybook-static
|
|||||||
# Tests
|
# Tests
|
||||||
coverage/
|
coverage/
|
||||||
.aider*
|
.aider*
|
||||||
|
playwright-report/
|
||||||
|
blob-report/
|
||||||
|
.playwright/
|
||||||
|
|||||||
+195
@@ -0,0 +1,195 @@
|
|||||||
|
{
|
||||||
|
"$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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -4,12 +4,11 @@
|
|||||||
|
|
||||||
This provides:
|
This provides:
|
||||||
- ResponsiveManager context for breakpoint tracking
|
- ResponsiveManager context for breakpoint tracking
|
||||||
- TooltipProvider for shadcn Tooltip components
|
- TooltipProvider for tooltip components
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { createResponsiveManager } from '$shared/lib';
|
import { createResponsiveManager } from '$shared/lib';
|
||||||
import type { ResponsiveManager } from '$shared/lib';
|
import type { ResponsiveManager } from '$shared/lib';
|
||||||
import { Provider as TooltipProvider } from '$shared/shadcn/ui/tooltip';
|
|
||||||
import { setContext } from 'svelte';
|
import { setContext } from 'svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -24,6 +23,4 @@ $effect(() => responsiveManager.init());
|
|||||||
setContext<ResponsiveManager>('responsive', responsiveManager);
|
setContext<ResponsiveManager>('responsive', responsiveManager);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<TooltipProvider delayDuration={200} skipDelayDuration={300}>
|
{@render children()}
|
||||||
{@render children()}
|
|
||||||
</TooltipProvider>
|
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
interface Props {
|
interface Props {
|
||||||
children: import('svelte').Snippet;
|
children: import('svelte').Snippet;
|
||||||
width?: string; // Optional width override
|
/**
|
||||||
|
* Tailwind max-width class applied to the card, or 'none' to remove width constraint.
|
||||||
|
* @default 'max-w-3xl'
|
||||||
|
*/
|
||||||
|
maxWidth?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { children, width = 'max-w-3xl' }: Props = $props();
|
let { children, maxWidth = 'max-w-3xl' }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex min-h-screen w-full items-center justify-center bg-background p-8">
|
<div class="flex min-h-screen w-full items-center justify-center bg-background p-8">
|
||||||
<div class="w-full bg-card shadow-lg ring-1 ring-border rounded-xl p-12 {width}">
|
<div class="w-full bg-card shadow-lg ring-1 ring-border rounded-xl p-12 {maxWidth !== 'none' ? maxWidth : ''}">
|
||||||
<div class="relative flex justify-center items-center text-foreground">
|
<div class="relative flex justify-center items-center text-foreground">
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+2
-63
@@ -5,68 +5,6 @@ import ThemeDecorator from './ThemeDecorator.svelte';
|
|||||||
import '../src/app/styles/app.css';
|
import '../src/app/styles/app.css';
|
||||||
|
|
||||||
const preview: Preview = {
|
const preview: Preview = {
|
||||||
globalTypes: {
|
|
||||||
viewport: {
|
|
||||||
description: 'Viewport size for responsive design',
|
|
||||||
defaultValue: 'widgetWide',
|
|
||||||
toolbar: {
|
|
||||||
icon: 'view',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
value: 'reset',
|
|
||||||
icon: 'refresh',
|
|
||||||
title: 'Reset viewport',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'mobile1',
|
|
||||||
icon: 'mobile',
|
|
||||||
title: 'iPhone 5/SE',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'mobile2',
|
|
||||||
icon: 'mobile',
|
|
||||||
title: 'iPhone 14 Pro Max',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'tablet',
|
|
||||||
icon: 'tablet',
|
|
||||||
title: 'iPad (Portrait)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'desktop',
|
|
||||||
icon: 'desktop',
|
|
||||||
title: 'Desktop (Small)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'widgetMedium',
|
|
||||||
icon: 'view',
|
|
||||||
title: 'Widget Medium',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'widgetWide',
|
|
||||||
icon: 'view',
|
|
||||||
title: 'Widget Wide',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'widgetExtraWide',
|
|
||||||
icon: 'view',
|
|
||||||
title: 'Widget Extra Wide',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'fullWidth',
|
|
||||||
icon: 'view',
|
|
||||||
title: 'Full Width',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'fullScreen',
|
|
||||||
icon: 'expand',
|
|
||||||
title: 'Full Screen',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
dynamicTitle: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
parameters: {
|
parameters: {
|
||||||
layout: 'padded',
|
layout: 'padded',
|
||||||
controls: {
|
controls: {
|
||||||
@@ -195,10 +133,11 @@ const preview: Preview = {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
// Wrap with StoryStage for presentation styling
|
// Wrap with StoryStage for presentation styling
|
||||||
story => ({
|
(story, context) => ({
|
||||||
Component: StoryStage,
|
Component: StoryStage,
|
||||||
props: {
|
props: {
|
||||||
children: story(),
|
children: story(),
|
||||||
|
maxWidth: context.parameters.storyStage?.maxWidth,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,5 +1,28 @@
|
|||||||
:3000 {
|
:3000 {
|
||||||
root * /usr/share/caddy
|
root * /usr/share/caddy
|
||||||
file_server
|
|
||||||
|
# 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
|
try_files {path} /index.html
|
||||||
|
file_server
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-9
@@ -1,27 +1,22 @@
|
|||||||
# Build stage
|
# Build stage
|
||||||
FROM node:20-alpine AS builder
|
FROM node:20-alpine AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
# Enable Corepack so we can use Yarn v4
|
# Enable Corepack so we can use Yarn v4 (pinned to match lockfile)
|
||||||
RUN corepack enable && corepack prepare yarn@stable --activate
|
RUN corepack enable && corepack prepare yarn@4.11.0 --activate
|
||||||
# Force Yarn to use node_modules instead of PnP
|
# Force Yarn to use node_modules instead of PnP
|
||||||
ENV YARN_NODE_LINKER=node-modules
|
ENV YARN_NODE_LINKER=node-modules
|
||||||
COPY package.json yarn.lock ./
|
COPY package.json yarn.lock ./
|
||||||
RUN yarn install --immutable
|
RUN yarn install --immutable
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN yarn build
|
RUN yarn build && ls -la dist
|
||||||
|
|
||||||
# Production stage - Caddy
|
# Production stage - Caddy
|
||||||
FROM caddy:2-alpine
|
FROM caddy:2-alpine
|
||||||
|
|
||||||
WORKDIR /usr/share/caddy
|
WORKDIR /usr/share/caddy
|
||||||
|
|
||||||
# Copy built static files from the builder stage
|
# Copy built static files from the builder stage
|
||||||
COPY --from=builder /app/dist .
|
COPY --from=builder /app/dist .
|
||||||
|
|
||||||
# Copy our local Caddyfile config
|
# Copy our local Caddyfile config
|
||||||
COPY Caddyfile /etc/caddy/Caddyfile
|
COPY Caddyfile /etc/caddy/Caddyfile
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
# Start caddy using the config file
|
# Start caddy using the config file
|
||||||
CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"]
|
CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"]
|
||||||
@@ -8,14 +8,14 @@ A modern font exploration and comparison tool for browsing fonts from Google Fon
|
|||||||
- **Side-by-Side Comparison**: Compare up to 4 fonts simultaneously with customizable text, size, and typography settings
|
- **Side-by-Side Comparison**: Compare up to 4 fonts simultaneously with customizable text, size, and typography settings
|
||||||
- **Advanced Filtering**: Filter by category, provider, character subsets, and weight
|
- **Advanced Filtering**: Filter by category, provider, character subsets, and weight
|
||||||
- **Virtual Scrolling**: Fast, smooth browsing of thousands of fonts
|
- **Virtual Scrolling**: Fast, smooth browsing of thousands of fonts
|
||||||
- **Responsive UI**: Beautiful interface built with shadcn components and Tailwind CSS
|
- **Responsive UI**: Beautiful interface built with Tailwind CSS
|
||||||
- **Type-Safe**: Full TypeScript coverage with strict mode enabled
|
- **Type-Safe**: Full TypeScript coverage with strict mode enabled
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- **Framework**: Svelte 5 with reactive primitives (runes)
|
- **Framework**: Svelte 5 with reactive primitives (runes)
|
||||||
- **Styling**: Tailwind CSS v4
|
- **Styling**: Tailwind CSS v4
|
||||||
- **Components**: shadcn-svelte (via bits-ui)
|
- **Components**: Bits UI primitives
|
||||||
- **State Management**: TanStack Query for async data
|
- **State Management**: TanStack Query for async data
|
||||||
- **Architecture**: Feature-Sliced Design (FSD)
|
- **Architecture**: Feature-Sliced Design (FSD)
|
||||||
- **Quality**: oxlint (linting), dprint (formatting), lefthook (git hooks)
|
- **Quality**: oxlint (linting), dprint (formatting), lefthook (git hooks)
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://shadcn-svelte.com/schema.json",
|
|
||||||
"tailwind": {
|
|
||||||
"css": "src/app.css",
|
|
||||||
"baseColor": "zinc"
|
|
||||||
},
|
|
||||||
"aliases": {
|
|
||||||
"components": "$shared/shadcn/ui",
|
|
||||||
"utils": "$shared/shadcn/utils/shadcn-utils",
|
|
||||||
"ui": "$shared/shadcn/ui",
|
|
||||||
"hooks": "$shared/shadcn/hooks",
|
|
||||||
"lib": "$shared"
|
|
||||||
},
|
|
||||||
"typescript": true,
|
|
||||||
"registry": "https://shadcn-svelte.com/registry"
|
|
||||||
}
|
|
||||||
@@ -1,592 +0,0 @@
|
|||||||
# 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.
|
|
||||||
+15
-6
@@ -13,7 +13,7 @@
|
|||||||
"https://plugins.dprint.dev/typescript-0.93.0.wasm",
|
"https://plugins.dprint.dev/typescript-0.93.0.wasm",
|
||||||
"https://plugins.dprint.dev/json-0.19.3.wasm",
|
"https://plugins.dprint.dev/json-0.19.3.wasm",
|
||||||
"https://plugins.dprint.dev/markdown-0.17.8.wasm",
|
"https://plugins.dprint.dev/markdown-0.17.8.wasm",
|
||||||
"https://plugins.dprint.dev/g-plane/markup_fmt-v0.25.3.wasm"
|
"https://plugins.dprint.dev/g-plane/markup_fmt-v0.27.0.wasm"
|
||||||
],
|
],
|
||||||
"typescript": {
|
"typescript": {
|
||||||
"lineWidth": 120,
|
"lineWidth": 120,
|
||||||
@@ -31,7 +31,17 @@
|
|||||||
"importDeclaration.forceMultiLine": "whenMultiple",
|
"importDeclaration.forceMultiLine": "whenMultiple",
|
||||||
"importDeclaration.forceSingleLine": false,
|
"importDeclaration.forceSingleLine": false,
|
||||||
"exportDeclaration.forceMultiLine": "whenMultiple",
|
"exportDeclaration.forceMultiLine": "whenMultiple",
|
||||||
"exportDeclaration.forceSingleLine": false
|
"exportDeclaration.forceSingleLine": false,
|
||||||
|
"ifStatement.useBraces": "always",
|
||||||
|
"ifStatement.singleBodyPosition": "nextLine",
|
||||||
|
"whileStatement.useBraces": "always",
|
||||||
|
"whileStatement.singleBodyPosition": "nextLine",
|
||||||
|
"forStatement.useBraces": "always",
|
||||||
|
"forStatement.singleBodyPosition": "nextLine",
|
||||||
|
"forInStatement.useBraces": "always",
|
||||||
|
"forInStatement.singleBodyPosition": "nextLine",
|
||||||
|
"forOfStatement.useBraces": "always",
|
||||||
|
"forOfStatement.singleBodyPosition": "nextLine"
|
||||||
},
|
},
|
||||||
"json": {
|
"json": {
|
||||||
"indentWidth": 2,
|
"indentWidth": 2,
|
||||||
@@ -47,9 +57,8 @@
|
|||||||
"quotes": "double",
|
"quotes": "double",
|
||||||
"scriptIndent": false,
|
"scriptIndent": false,
|
||||||
"styleIndent": false,
|
"styleIndent": false,
|
||||||
|
"formatComments": true,
|
||||||
"vBindStyle": "short",
|
"svelteAttrShorthand": true,
|
||||||
"vOnStyle": "short",
|
"svelteDirectiveShorthand": true
|
||||||
"formatComments": true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
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';
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
+5
-1
@@ -13,11 +13,15 @@ pre-commit:
|
|||||||
pre-push:
|
pre-push:
|
||||||
parallel: true
|
parallel: true
|
||||||
commands:
|
commands:
|
||||||
|
test-unit:
|
||||||
|
run: yarn test:unit
|
||||||
|
test-component:
|
||||||
|
run: yarn test:component
|
||||||
type-check:
|
type-check:
|
||||||
run: yarn tsc --noEmit
|
run: yarn tsc --noEmit
|
||||||
|
|
||||||
svelte-check:
|
svelte-check:
|
||||||
run: yarn check:shadcn-excluded --threshold warning
|
run: yarn check --threshold warning
|
||||||
|
|
||||||
format-check:
|
format-check:
|
||||||
glob: "*.{ts,js,svelte,json,md}"
|
glob: "*.{ts,js,svelte,json,md}"
|
||||||
|
|||||||
-27
@@ -1,27 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+37
-34
@@ -4,6 +4,10 @@
|
|||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"packageManager": "yarn@4.11.0",
|
"packageManager": "yarn@4.11.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"sideEffects": [
|
||||||
|
"*.css",
|
||||||
|
"**/router.ts"
|
||||||
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
@@ -11,7 +15,6 @@
|
|||||||
"prepare": "svelte-check --tsconfig ./tsconfig.json || echo ''",
|
"prepare": "svelte-check --tsconfig ./tsconfig.json || echo ''",
|
||||||
"check": "svelte-check",
|
"check": "svelte-check",
|
||||||
"check:watch": "svelte-check --tsconfig ./tsconfig.json --watch",
|
"check:watch": "svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
"check:shadcn-excluded": "svelte-check --no-tsconfig --ignore \"src/shared/shadcn\"",
|
|
||||||
"lint": "oxlint",
|
"lint": "oxlint",
|
||||||
"format": "dprint fmt",
|
"format": "dprint fmt",
|
||||||
"format:check": "dprint check",
|
"format:check": "dprint check",
|
||||||
@@ -28,45 +31,45 @@
|
|||||||
"build-storybook": "storybook build"
|
"build-storybook": "storybook build"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@chromatic-com/storybook": "^4.1.3",
|
"@chromatic-com/storybook": "5.1.2",
|
||||||
"@internationalized/date": "^3.10.0",
|
"@internationalized/date": "3.12.1",
|
||||||
"@lucide/svelte": "^0.561.0",
|
"@lucide/svelte": "^1.14.0",
|
||||||
"@playwright/test": "^1.57.0",
|
"@playwright/test": "1.59.1",
|
||||||
"@storybook/addon-a11y": "^10.1.11",
|
"@storybook/addon-a11y": "10.3.6",
|
||||||
"@storybook/addon-docs": "^10.1.11",
|
"@storybook/addon-docs": "10.3.6",
|
||||||
"@storybook/addon-svelte-csf": "^5.0.10",
|
"@storybook/addon-svelte-csf": "5.1.2",
|
||||||
"@storybook/addon-vitest": "^10.1.11",
|
"@storybook/addon-vitest": "10.3.6",
|
||||||
"@storybook/svelte-vite": "^10.1.11",
|
"@storybook/svelte-vite": "10.3.6",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
"@sveltejs/vite-plugin-svelte": "7.1.0",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "4.2.4",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/svelte": "^5.3.1",
|
"@testing-library/svelte": "^5.3.1",
|
||||||
"@tsconfig/svelte": "^5.0.6",
|
"@tsconfig/svelte": "5.0.8",
|
||||||
"@types/jsdom": "^27",
|
"@types/jsdom": "28.0.1",
|
||||||
"@vitest/browser-playwright": "^4.0.16",
|
"@vitest/browser-playwright": "4.1.5",
|
||||||
"@vitest/coverage-v8": "^4.0.16",
|
"@vitest/coverage-v8": "4.1.5",
|
||||||
"bits-ui": "^2.14.4",
|
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dprint": "^0.50.2",
|
"dprint": "0.54.0",
|
||||||
"jsdom": "^27.4.0",
|
"jsdom": "29.1.1",
|
||||||
"lefthook": "^2.0.13",
|
"lefthook": "2.1.6",
|
||||||
"oxlint": "^1.35.0",
|
"oxlint": "1.62.0",
|
||||||
"playwright": "^1.57.0",
|
"playwright": "1.59.1",
|
||||||
"storybook": "^10.1.11",
|
"storybook": "10.3.6",
|
||||||
"svelte": "^5.45.6",
|
"svelte": "5.55.5",
|
||||||
"svelte-check": "^4.3.4",
|
"svelte-check": "4.4.8",
|
||||||
"svelte-language-server": "^0.17.23",
|
"svelte-language-server": "0.18.0",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "3.5.0",
|
||||||
"tailwind-variants": "^3.2.2",
|
"tailwind-variants": "^3.2.2",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "4.2.4",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "6.0.3",
|
||||||
"vite": "^7.2.6",
|
"vite": "8.0.10",
|
||||||
"vitest": "^4.0.16",
|
"vitest": "4.1.5",
|
||||||
"vitest-browser-svelte": "^2.0.1"
|
"vitest-browser-svelte": "2.1.1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@chenglou/pretext": "^0.0.5",
|
"@chenglou/pretext": "0.0.6",
|
||||||
"@tanstack/svelte-query": "^6.0.14"
|
"@tanstack/svelte-query": "6.1.28",
|
||||||
|
"sv-router": "^0.16.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+47
-3
@@ -1,10 +1,54 @@
|
|||||||
import { defineConfig } from '@playwright/test';
|
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 1–2GB, so we keep the E2E peak bounded.
|
||||||
|
*/
|
||||||
|
const isCI = !!process.env.CI;
|
||||||
|
|
||||||
export default defineConfig({
|
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: {
|
webServer: {
|
||||||
command: 'yarn build && yarn preview',
|
command: 'yarn build && yarn preview',
|
||||||
port: 4173,
|
port: 4173,
|
||||||
reuseExistingServer: true,
|
reuseExistingServer: !isCI,
|
||||||
|
timeout: 120_000,
|
||||||
},
|
},
|
||||||
testDir: 'e2e',
|
|
||||||
});
|
});
|
||||||
|
|||||||
+13
-7
@@ -6,21 +6,27 @@
|
|||||||
/**
|
/**
|
||||||
* App Component
|
* App Component
|
||||||
*
|
*
|
||||||
* Application entry point component. Wraps the main page route within the shared
|
* Application entry point component. Wraps the active route within the shared
|
||||||
* layout shell. This is the root component mounted by the application.
|
* layout shell. This is the root component mounted by the application.
|
||||||
*
|
*
|
||||||
* Structure:
|
* Structure:
|
||||||
* - QueryProvider provides TanStack Query client for data fetching
|
* - QueryProvider provides TanStack Query client for data fetching
|
||||||
* - Layout provides sidebar, header/footer, and page container
|
* - Layout provides sidebar, header/footer, and page container
|
||||||
* - Page renders the current route content
|
* - Router renders the matched route component
|
||||||
*/
|
*/
|
||||||
import Page from '$routes/Page.svelte';
|
import '$routes/router';
|
||||||
import { QueryProvider } from './providers';
|
import { Router } from 'sv-router';
|
||||||
|
import {
|
||||||
|
AppBindingsProvider,
|
||||||
|
QueryProvider,
|
||||||
|
} from './providers';
|
||||||
import Layout from './ui/Layout.svelte';
|
import Layout from './ui/Layout.svelte';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<QueryProvider>
|
<QueryProvider>
|
||||||
<Layout>
|
<AppBindingsProvider>
|
||||||
<Page />
|
<Layout>
|
||||||
</Layout>
|
<Router />
|
||||||
|
</Layout>
|
||||||
|
</AppBindingsProvider>
|
||||||
</QueryProvider>
|
</QueryProvider>
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,24 @@
|
|||||||
|
<!--
|
||||||
|
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?.()}
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
descendants of this provider.
|
descendants of this provider.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { queryClient } from '$shared/api/queryClient';
|
import { getQueryClient } from '$shared/api/queryClient';
|
||||||
import { QueryClientProvider } from '@tanstack/svelte-query';
|
import { QueryClientProvider } from '@tanstack/svelte-query';
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
@@ -18,6 +18,9 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let { children }: Props = $props();
|
let { children }: Props = $props();
|
||||||
|
|
||||||
|
// First call to the lazy singleton — constructs the shared client for the app.
|
||||||
|
const queryClient = getQueryClient();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
|
export { default as AppBindingsProvider } from './AppBindings.svelte';
|
||||||
export { default as QueryProvider } from './QueryProvider.svelte';
|
export { default as QueryProvider } from './QueryProvider.svelte';
|
||||||
|
|||||||
+175
-38
@@ -1,5 +1,6 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@import "tw-animate-css";
|
@import "tw-animate-css";
|
||||||
|
@import "./fonts.css";
|
||||||
|
|
||||||
@variant dark (&:where(.dark, .dark *));
|
@variant dark (&:where(.dark, .dark *));
|
||||||
|
|
||||||
@@ -7,13 +8,20 @@
|
|||||||
/* Base font size */
|
/* Base font size */
|
||||||
--font-size: 16px;
|
--font-size: 16px;
|
||||||
|
|
||||||
/* GLYPHDIFF Swiss Design System */
|
/* GLYPHDIFF Design System */
|
||||||
/* Primary Colors */
|
/* Primary Colors */
|
||||||
--swiss-beige: #f3f0e9;
|
--swiss-beige: #f3f0e9;
|
||||||
--swiss-red: #ff3b30;
|
--swiss-red: #ff3b30;
|
||||||
--swiss-black: #1a1a1a;
|
--swiss-black: #1a1a1a;
|
||||||
--swiss-white: #ffffff;
|
--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 Grays */
|
||||||
--neutral-50: #fafafa;
|
--neutral-50: #fafafa;
|
||||||
--neutral-100: #f5f5f5;
|
--neutral-100: #f5f5f5;
|
||||||
@@ -80,16 +88,6 @@
|
|||||||
--sidebar-border: oklch(0.922 0 0);
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
--sidebar-ring: oklch(0.708 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 */
|
/* Typography Scale */
|
||||||
--text-xs: 0.75rem;
|
--text-xs: 0.75rem;
|
||||||
--text-sm: 0.875rem;
|
--text-sm: 0.875rem;
|
||||||
@@ -114,6 +112,12 @@
|
|||||||
--color-surface: var(--dark-bg);
|
--color-surface: var(--dark-bg);
|
||||||
--color-paper: var(--dark-card);
|
--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);
|
--background: oklch(0.145 0 0);
|
||||||
--foreground: oklch(0.985 0 0);
|
--foreground: oklch(0.985 0 0);
|
||||||
--card: oklch(0.145 0 0);
|
--card: oklch(0.145 0 0);
|
||||||
@@ -206,12 +210,53 @@
|
|||||||
--font-secondary: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, Arial, sans-serif;
|
--font-secondary: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, Arial, sans-serif;
|
||||||
|
|
||||||
/* Micro typography scale — extends Tailwind's text-xs (0.75rem) downward */
|
/* Micro typography scale — extends Tailwind's text-xs (0.75rem) downward */
|
||||||
--font-size-5xs: 0.4375rem;
|
--text-5xs: 0.4375rem;
|
||||||
--font-size-4xs: 0.5rem;
|
--text-4xs: 0.5rem;
|
||||||
--font-size-3xs: 0.5625rem;
|
--text-3xs: 0.5625rem;
|
||||||
--font-size-2xs: 0.625rem;
|
--text-2xs: 0.625rem;
|
||||||
/* Monospace label tracking — used in Loader and Footnote */
|
/* Monospace label tracking — used in Loader and Footnote */
|
||||||
--tracking-wider-mono: 0.2em;
|
--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 {
|
@layer base {
|
||||||
@@ -219,9 +264,14 @@
|
|||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background-color: var(--color-brand);
|
||||||
|
color: var(--swiss-white);
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
font-family: "Karla", system-ui, -apple-system, "Segoe UI", Inter, Roboto, Arial, sans-serif;
|
font-family: var(--font-secondary);
|
||||||
font-optical-sizing: auto;
|
font-optical-sizing: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,22 +322,111 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer utilities {
|
/* Design-system utilities.
|
||||||
/* 21× border-black/5 dark:border-white/10 → single token */
|
Defined via `@utility` (Tailwind v4) so they integrate with the variant
|
||||||
.border-subtle {
|
system (`hover:`, `dark:`, breakpoints) and don't rely on `@apply`
|
||||||
@apply border-black/5 dark:border-white/10;
|
chains. Colors reference the mode-switching semantic vars defined in
|
||||||
}
|
`:root`/`.dark` above, so most utilities need no `dark:` variant in
|
||||||
/* Secondary text pair */
|
their definition or at call sites. */
|
||||||
.text-secondary {
|
|
||||||
@apply text-neutral-500 dark:text-neutral-400;
|
@utility border-subtle {
|
||||||
}
|
border-color: var(--color-border-subtle);
|
||||||
/* Standard focus ring */
|
}
|
||||||
.focus-ring {
|
|
||||||
@apply focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2;
|
/* 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Global utility - useful across your app */
|
/* Surface utilities */
|
||||||
|
|
||||||
|
@utility surface-canvas {
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility surface-card {
|
||||||
|
background-color: var(--color-paper);
|
||||||
|
border: 1px solid var(--color-border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility surface-card-elevated {
|
||||||
|
background-color: var(--color-paper);
|
||||||
|
border: 1px solid var(--color-border-subtle);
|
||||||
|
box-shadow: var(--shadow-rest);
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility surface-popover {
|
||||||
|
background-color: var(--color-paper);
|
||||||
|
border: 1px solid var(--color-border-subtle);
|
||||||
|
box-shadow: var(--shadow-popover);
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility surface-floating {
|
||||||
|
background-color: color-mix(in srgb, var(--color-surface) 80%, transparent);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid var(--color-border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shape / layout */
|
||||||
|
|
||||||
|
@utility flex-center {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility skeleton-fill {
|
||||||
|
background-color: color-mix(in srgb, var(--color-skeleton) 70%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Subtle dotted-grid overlay used as a decorative background on the
|
||||||
|
comparison paper surface. Color and intensity auto-switch via
|
||||||
|
--color-grid-line. `bg-grid-sm` uses a tighter cell — typical mobile
|
||||||
|
choice; `bg-grid` is the default desktop cell. Pair with absolute /
|
||||||
|
pointer-events-none on the overlay element. */
|
||||||
|
@utility bg-grid {
|
||||||
|
background-image:
|
||||||
|
linear-gradient(var(--color-grid-line) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, var(--color-grid-line) 1px, transparent 1px);
|
||||||
|
background-size: 20px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility bg-grid-sm {
|
||||||
|
background-image:
|
||||||
|
linear-gradient(var(--color-grid-line) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, var(--color-grid-line) 1px, transparent 1px);
|
||||||
|
background-size: 10px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Typography */
|
||||||
|
|
||||||
|
@utility text-label-mono {
|
||||||
|
font-family: var(--font-primary);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.025em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Honor prefers-reduced-motion: collapse animation and transition timing. */
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
* {
|
* {
|
||||||
animation-duration: 0.01ms !important;
|
animation-duration: 0.01ms !important;
|
||||||
@@ -296,12 +435,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Performance optimization for collapsible elements */
|
/* Hint the upcoming height animation on open collapsibles. */
|
||||||
[data-state="open"] {
|
[data-state="open"] {
|
||||||
will-change: height;
|
will-change: height;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Smooth focus transitions - good globally */
|
/* Transition siblings of a focus-visible peer. */
|
||||||
.peer:focus-visible ~ * {
|
.peer:focus-visible ~ * {
|
||||||
transition: all 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
transition: all 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
}
|
}
|
||||||
@@ -328,11 +467,9 @@
|
|||||||
animation: nudge 10s ease-in-out infinite;
|
animation: nudge 10s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* Scrollbar styling */
|
||||||
SCROLLBAR STYLES
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
/* ---- Modern API: color + width (Chrome 121+, FF 64+) ---- */
|
/* Standard API: color + width (Chrome 121+, Firefox 64+). */
|
||||||
@supports (scrollbar-width: auto) {
|
@supports (scrollbar-width: auto) {
|
||||||
* {
|
* {
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
@@ -344,8 +481,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- Webkit layer: runs ON TOP in Chrome, standalone in old Safari ---- */
|
/* WebKit fallback: applies on top of the standard API in Chrome, standalone in
|
||||||
/* Handles things scrollbar-width can't: hiding buttons, exact sizing */
|
older Safari. Covers what scrollbar-width can't — hiding buttons, exact sizing. */
|
||||||
@supports selector(::-webkit-scrollbar) {
|
@supports selector(::-webkit-scrollbar) {
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
@@ -353,7 +490,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-button {
|
::-webkit-scrollbar-button {
|
||||||
display: none; /* kills arrows */
|
display: none; /* hide scrollbar buttons */
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
/*
|
||||||
|
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;
|
||||||
|
}
|
||||||
Vendored
+7
@@ -36,6 +36,13 @@ declare module '*.jpg' {
|
|||||||
export default content;
|
export default content;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
declare module '*.css';
|
||||||
|
|
||||||
|
declare module '*.woff2?url' {
|
||||||
|
const content: string;
|
||||||
|
export default content;
|
||||||
|
}
|
||||||
|
|
||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
interface ImportMetaEnv {
|
interface ImportMetaEnv {
|
||||||
|
|||||||
+35
-48
@@ -3,23 +3,20 @@
|
|||||||
Application shell with providers and page wrapper
|
Application shell with providers and page wrapper
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
/**
|
import { getThemeManager } from '$features/ChangeAppTheme';
|
||||||
* Layout Component
|
import G from '$shared/assets/G.svg';
|
||||||
*
|
|
||||||
* Root layout wrapper that provides the application shell structure. Handles favicon,
|
|
||||||
* toolbar provider initialization, and renders child routes with consistent structure.
|
|
||||||
*
|
|
||||||
* Layout structure:
|
|
||||||
* - Header area (currently empty, reserved for future use)
|
|
||||||
*
|
|
||||||
* - Footer area (currently empty, reserved for future use)
|
|
||||||
*/
|
|
||||||
import { BreadcrumbHeader } from '$entities/Breadcrumb';
|
|
||||||
import { themeManager } from '$features/ChangeAppTheme';
|
|
||||||
import GD from '$shared/assets/GD.svg';
|
|
||||||
import { ResponsiveProvider } from '$shared/lib';
|
import { ResponsiveProvider } from '$shared/lib';
|
||||||
import { Provider as TooltipProvider } from '$shared/shadcn/ui/tooltip';
|
import { cn } from '$shared/lib';
|
||||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
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 {
|
import {
|
||||||
type Snippet,
|
type Snippet,
|
||||||
onDestroy,
|
onDestroy,
|
||||||
@@ -35,6 +32,8 @@ interface Props {
|
|||||||
|
|
||||||
let { children }: Props = $props();
|
let { children }: Props = $props();
|
||||||
let fontsReady = $state(true);
|
let fontsReady = $state(true);
|
||||||
|
|
||||||
|
const themeManager = getThemeManager();
|
||||||
const theme = $derived(themeManager.value);
|
const theme = $derived(themeManager.value);
|
||||||
|
|
||||||
onMount(() => themeManager.init());
|
onMount(() => themeManager.init());
|
||||||
@@ -42,54 +41,42 @@ onDestroy(() => themeManager.destroy());
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<link rel="icon" href={GD} />
|
<link rel="icon" href={G} type="image/svg+xml" />
|
||||||
|
|
||||||
<link rel="preconnect" href="https://api.fontshare.com" />
|
<!-- Self-hosted interface fonts (see src/app/styles/fonts/fonts.css). Preload the two critical faces. -->
|
||||||
<link
|
<link
|
||||||
rel="preconnect"
|
rel="preload"
|
||||||
href="https://cdn.fontshare.com"
|
as="font"
|
||||||
crossorigin="anonymous"
|
type="font/woff2"
|
||||||
/>
|
href={interWoff2}
|
||||||
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
||||||
<link
|
|
||||||
rel="preconnect"
|
|
||||||
href="https://fonts.gstatic.com"
|
|
||||||
crossorigin="anonymous"
|
crossorigin="anonymous"
|
||||||
/>
|
/>
|
||||||
<link
|
<link
|
||||||
rel="preload"
|
rel="preload"
|
||||||
as="style"
|
as="font"
|
||||||
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"
|
type="font/woff2"
|
||||||
|
href={spaceGroteskWoff2}
|
||||||
|
crossorigin="anonymous"
|
||||||
/>
|
/>
|
||||||
<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>
|
<title>GlyphDiff | Typography & Typefaces</title>
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Compare typefaces side by side. Adjust size, weight, leading, and tracking to find the perfect typographic pairing."
|
||||||
|
/>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<ResponsiveProvider>
|
<ResponsiveProvider>
|
||||||
<div
|
<div
|
||||||
id="app-root"
|
id="app-root"
|
||||||
class={cn(
|
class={cn(
|
||||||
'min-h-screen w-auto flex flex-col bg-surface dark:bg-dark-bg',
|
'min-h-dvh w-auto flex flex-col surface-canvas relative',
|
||||||
theme === 'dark' ? 'dark' : '',
|
theme === 'dark' ? 'dark' : '',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<TooltipProvider>
|
{#if fontsReady}
|
||||||
{#if fontsReady}
|
{@render children?.()}
|
||||||
{@render children?.()}
|
{/if}
|
||||||
{/if}
|
|
||||||
</TooltipProvider>
|
<Footer />
|
||||||
<footer></footer>
|
|
||||||
</div>
|
</div>
|
||||||
</ResponsiveProvider>
|
</ResponsiveProvider>
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
export * from './store/scrollBreadcrumbsStore.svelte';
|
|
||||||
export * from './types/types.ts';
|
|
||||||
@@ -9,6 +9,7 @@ export {
|
|||||||
fetchFontsByIds,
|
fetchFontsByIds,
|
||||||
fetchProxyFontById,
|
fetchProxyFontById,
|
||||||
fetchProxyFonts,
|
fetchProxyFonts,
|
||||||
|
seedFontCache,
|
||||||
} from './proxy/proxyFonts';
|
} from './proxy/proxyFonts';
|
||||||
export type {
|
export type {
|
||||||
ProxyFontsParams,
|
ProxyFontsParams,
|
||||||
|
|||||||
@@ -19,8 +19,11 @@ vi.mock('$shared/api/api', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import { api } from '$shared/api/api';
|
import { api } from '$shared/api/api';
|
||||||
import { queryClient } from '$shared/api/queryClient';
|
import { getQueryClient } from '$shared/api/queryClient';
|
||||||
|
|
||||||
|
const queryClient = getQueryClient();
|
||||||
import { fontKeys } from '$shared/api/queryKeys';
|
import { fontKeys } from '$shared/api/queryKeys';
|
||||||
|
import { FontResponseError } from '../../lib/errors/errors';
|
||||||
import {
|
import {
|
||||||
fetchFontsByIds,
|
fetchFontsByIds,
|
||||||
fetchProxyFontById,
|
fetchProxyFontById,
|
||||||
@@ -86,16 +89,20 @@ describe('proxyFonts', () => {
|
|||||||
expect(calledUrl).toContain('offset=0');
|
expect(calledUrl).toContain('offset=0');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should throw on invalid response (missing fonts array)', async () => {
|
test('should throw FontResponseError on invalid response (missing fonts array)', async () => {
|
||||||
mockApiGet({ total: 0 });
|
mockApiGet({ total: 0 });
|
||||||
|
|
||||||
await expect(fetchProxyFonts()).rejects.toThrow('Proxy API returned invalid response');
|
await expect(fetchProxyFonts()).rejects.toSatisfy(
|
||||||
|
e => e instanceof FontResponseError && e.field === 'response.fonts',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should throw on null response data', async () => {
|
test('should throw FontResponseError on null response data', async () => {
|
||||||
vi.mocked(api.get).mockResolvedValueOnce({ data: null, status: 200 });
|
vi.mocked(api.get).mockResolvedValueOnce({ data: null, status: 200 });
|
||||||
|
|
||||||
await expect(fetchProxyFonts()).rejects.toThrow('Proxy API returned invalid response');
|
await expect(fetchProxyFonts()).rejects.toSatisfy(
|
||||||
|
e => e instanceof FontResponseError && e.field === 'response',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -11,10 +11,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { api } from '$shared/api/api';
|
import { api } from '$shared/api/api';
|
||||||
import { queryClient } from '$shared/api/queryClient';
|
import { getQueryClient } from '$shared/api/queryClient';
|
||||||
import { fontKeys } from '$shared/api/queryKeys';
|
import { fontKeys } from '$shared/api/queryKeys';
|
||||||
import { buildQueryString } from '$shared/lib/utils';
|
import { buildQueryString } from '$shared/lib/utils';
|
||||||
import type { QueryParams } from '$shared/lib/utils';
|
import type { QueryParams } from '$shared/lib/utils';
|
||||||
|
import { FontResponseError } from '../../lib/errors/errors';
|
||||||
import type { UnifiedFont } from '../../model/types';
|
import type { UnifiedFont } from '../../model/types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -25,14 +26,16 @@ import type { UnifiedFont } from '../../model/types';
|
|||||||
*/
|
*/
|
||||||
export function seedFontCache(fonts: UnifiedFont[]): void {
|
export function seedFontCache(fonts: UnifiedFont[]): void {
|
||||||
fonts.forEach(font => {
|
fonts.forEach(font => {
|
||||||
queryClient.setQueryData(fontKeys.detail(font.id), font);
|
getQueryClient().setQueryData(fontKeys.detail(font.id), font);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import { API_ENDPOINTS } from '$shared/api/endpoints';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Proxy API base URL
|
* Proxy API endpoint for font resources.
|
||||||
*/
|
*/
|
||||||
const PROXY_API_URL = 'https://api.glyphdiff.com/api/v1/fonts' as const;
|
const PROXY_API_URL = API_ENDPOINTS.fonts;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Proxy API parameters
|
* Proxy API parameters
|
||||||
@@ -94,19 +97,32 @@ export interface ProxyFontsParams extends QueryParams {
|
|||||||
/**
|
/**
|
||||||
* Proxy API response
|
* Proxy API response
|
||||||
*
|
*
|
||||||
* Includes pagination metadata alongside font data
|
* Includes pagination metadata alongside font data.
|
||||||
|
*
|
||||||
|
* Contract: `fonts` is always an array — never `null` or omitted, even when
|
||||||
|
* `total === 0`. Returning `null` on the wire is a backend regression and
|
||||||
|
* surfaces as FontResponseError (non-retryable) on the client.
|
||||||
*/
|
*/
|
||||||
export interface ProxyFontsResponse {
|
export interface ProxyFontsResponse {
|
||||||
/** Array of unified font objects */
|
/**
|
||||||
|
* List of font objects returned by the proxy.
|
||||||
|
* Always an array; empty when no matches.
|
||||||
|
*/
|
||||||
fonts: UnifiedFont[];
|
fonts: UnifiedFont[];
|
||||||
|
|
||||||
/** Total number of fonts matching the query */
|
/**
|
||||||
|
* Total number of matching fonts (ignoring limit/offset)
|
||||||
|
*/
|
||||||
total: number;
|
total: number;
|
||||||
|
|
||||||
/** Limit used for this request */
|
/**
|
||||||
|
* Page size used for the request
|
||||||
|
*/
|
||||||
limit: number;
|
limit: number;
|
||||||
|
|
||||||
/** Offset used for this request */
|
/**
|
||||||
|
* Start index for the result set
|
||||||
|
*/
|
||||||
offset: number;
|
offset: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,8 +162,11 @@ export async function fetchProxyFonts(
|
|||||||
|
|
||||||
const response = await api.get<ProxyFontsResponse>(url);
|
const response = await api.get<ProxyFontsResponse>(url);
|
||||||
|
|
||||||
if (!response.data || !Array.isArray(response.data.fonts)) {
|
if (!response.data) {
|
||||||
throw new Error('Proxy API returned invalid response');
|
throw new FontResponseError('response', response.data);
|
||||||
|
}
|
||||||
|
if (!Array.isArray(response.data.fonts)) {
|
||||||
|
throw new FontResponseError('response.fonts', response.data.fonts);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.data;
|
return response.data;
|
||||||
@@ -189,7 +208,9 @@ export async function fetchProxyFontById(
|
|||||||
* @returns Promise resolving to an array of fonts
|
* @returns Promise resolving to an array of fonts
|
||||||
*/
|
*/
|
||||||
export async function fetchFontsByIds(ids: string[]): Promise<UnifiedFont[]> {
|
export async function fetchFontsByIds(ids: string[]): Promise<UnifiedFont[]> {
|
||||||
if (ids.length === 0) return [];
|
if (ids.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
const queryString = ids.join(',');
|
const queryString = ids.join(',');
|
||||||
const url = `${PROXY_API_URL}/batch?ids=${queryString}`;
|
const url = `${PROXY_API_URL}/batch?ids=${queryString}`;
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
// @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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export {
|
||||||
|
type ComparisonLine,
|
||||||
|
type ComparisonResult,
|
||||||
|
DualFontLayout,
|
||||||
|
} from './DualFontLayout/DualFontLayout';
|
||||||
|
export {
|
||||||
|
computeLineRenderModel,
|
||||||
|
findSplitIndex,
|
||||||
|
type LineRenderModel,
|
||||||
|
} from './computeLineRenderModel/computeLineRenderModel';
|
||||||
|
export { windowSizeForLine } from './windowSizeForLine/windowSizeForLine';
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* 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 1–2, 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)));
|
||||||
|
}
|
||||||
@@ -1,4 +1,93 @@
|
|||||||
export * from './api';
|
export {
|
||||||
export * from './lib';
|
computeLineRenderModel,
|
||||||
export * from './model';
|
DualFontLayout,
|
||||||
export * from './ui';
|
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`.
|
||||||
|
|||||||
+111
@@ -0,0 +1,111 @@
|
|||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
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,3 +1,5 @@
|
|||||||
|
import { NonRetryableError } from '$shared/api/nonRetryableError';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Thrown when the network request to the proxy API fails.
|
* Thrown when the network request to the proxy API fails.
|
||||||
* Wraps the underlying fetch error (timeout, DNS failure, connection refused, etc.).
|
* Wraps the underlying fetch error (timeout, DNS failure, connection refused, etc.).
|
||||||
@@ -12,11 +14,13 @@ export class FontNetworkError extends Error {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Thrown when the proxy API returns a response with an unexpected shape.
|
* 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 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.
|
* @property received - The actual value received at that field, for debugging.
|
||||||
*/
|
*/
|
||||||
export class FontResponseError extends Error {
|
export class FontResponseError extends NonRetryableError {
|
||||||
readonly name = 'FontResponseError';
|
readonly name = 'FontResponseError';
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import type {
|
|||||||
UnifiedFont,
|
UnifiedFont,
|
||||||
} from '../../model';
|
} from '../../model';
|
||||||
|
|
||||||
/** Valid font weight values (100-900 in increments of 100) */
|
/**
|
||||||
|
* Valid font weight values (100-900 in increments of 100)
|
||||||
|
*/
|
||||||
const SIZES = [100, 200, 300, 400, 500, 600, 700, 800, 900];
|
const SIZES = [100, 200, 300, 400, 500, 600, 700, 800, 900];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,49 +1,5 @@
|
|||||||
export { getFontUrl } from './getFontUrl/getFontUrl';
|
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 {
|
export {
|
||||||
FontNetworkError,
|
FontNetworkError,
|
||||||
FontResponseError,
|
FontResponseError,
|
||||||
|
|||||||
@@ -1,7 +1,20 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import { TextLayoutEngine } from '$shared/lib';
|
|
||||||
import { installCanvasMock } from '$shared/lib/helpers/__mocks__/canvas';
|
import { installCanvasMock } from '$shared/lib/helpers/__mocks__/canvas';
|
||||||
import { clearCache } from '@chenglou/pretext';
|
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 {
|
import {
|
||||||
beforeEach,
|
beforeEach,
|
||||||
describe,
|
describe,
|
||||||
@@ -10,7 +23,6 @@ import {
|
|||||||
vi,
|
vi,
|
||||||
} from 'vitest';
|
} from 'vitest';
|
||||||
import type { FontLoadStatus } from '../../model/types';
|
import type { FontLoadStatus } from '../../model/types';
|
||||||
import { mockUnifiedFont } from '../mocks';
|
|
||||||
import { createFontRowSizeResolver } from './createFontRowSizeResolver';
|
import { createFontRowSizeResolver } from './createFontRowSizeResolver';
|
||||||
|
|
||||||
// Fixed-width canvas mock: every character is 10px wide regardless of font.
|
// Fixed-width canvas mock: every character is 10px wide regardless of font.
|
||||||
@@ -112,13 +124,13 @@ describe('createFontRowSizeResolver', () => {
|
|||||||
const { resolver } = makeResolver();
|
const { resolver } = makeResolver();
|
||||||
statusMap.set('inter@400', 'loaded');
|
statusMap.set('inter@400', 'loaded');
|
||||||
|
|
||||||
const layoutSpy = vi.spyOn(TextLayoutEngine.prototype, 'layout');
|
const layoutSpy = vi.mocked(layout);
|
||||||
|
layoutSpy.mockClear();
|
||||||
|
|
||||||
resolver(0);
|
resolver(0);
|
||||||
resolver(0);
|
resolver(0);
|
||||||
|
|
||||||
expect(layoutSpy).toHaveBeenCalledTimes(1);
|
expect(layoutSpy).toHaveBeenCalledTimes(1);
|
||||||
layoutSpy.mockRestore();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls layout() again when containerWidth changes (cache miss)', () => {
|
it('calls layout() again when containerWidth changes (cache miss)', () => {
|
||||||
@@ -126,14 +138,14 @@ describe('createFontRowSizeResolver', () => {
|
|||||||
const { resolver } = makeResolver({ getContainerWidth: () => width });
|
const { resolver } = makeResolver({ getContainerWidth: () => width });
|
||||||
statusMap.set('inter@400', 'loaded');
|
statusMap.set('inter@400', 'loaded');
|
||||||
|
|
||||||
const layoutSpy = vi.spyOn(TextLayoutEngine.prototype, 'layout');
|
const layoutSpy = vi.mocked(layout);
|
||||||
|
layoutSpy.mockClear();
|
||||||
|
|
||||||
resolver(0);
|
resolver(0);
|
||||||
width = 100;
|
width = 100;
|
||||||
resolver(0);
|
resolver(0);
|
||||||
|
|
||||||
expect(layoutSpy).toHaveBeenCalledTimes(2);
|
expect(layoutSpy).toHaveBeenCalledTimes(2);
|
||||||
layoutSpy.mockRestore();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns greater height when container narrows (more wrapping)', () => {
|
it('returns greater height when container narrows (more wrapping)', () => {
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { TextLayoutEngine } from '$shared/lib';
|
import {
|
||||||
import { generateFontKey } from '../../model/store/appliedFontsStore/utils/generateFontKey/generateFontKey';
|
layout,
|
||||||
|
prepare,
|
||||||
|
} from '@chenglou/pretext';
|
||||||
|
import { generateFontKey } from '../../model/store/fontLifecycleManager/utils/generateFontKey/generateFontKey';
|
||||||
import type {
|
import type {
|
||||||
FontLoadStatus,
|
FontLoadStatus,
|
||||||
UnifiedFont,
|
UnifiedFont,
|
||||||
@@ -13,15 +16,25 @@ import type {
|
|||||||
* (e.g. `SvelteMap.get()`) is automatically tracked as a dependency.
|
* (e.g. `SvelteMap.get()`) is automatically tracked as a dependency.
|
||||||
*/
|
*/
|
||||||
export interface FontRowSizeResolverOptions {
|
export interface FontRowSizeResolverOptions {
|
||||||
/** Returns the current fonts array. Index `i` corresponds to row `i`. */
|
/**
|
||||||
|
* Returns the current fonts array. Index `i` corresponds to row `i`.
|
||||||
|
*/
|
||||||
getFonts: () => UnifiedFont[];
|
getFonts: () => UnifiedFont[];
|
||||||
/** Returns the active font weight (e.g. 400). */
|
/**
|
||||||
|
* Returns the active font weight (e.g. 400).
|
||||||
|
*/
|
||||||
getWeight: () => number;
|
getWeight: () => number;
|
||||||
/** Returns the preview text string. */
|
/**
|
||||||
|
* Returns the preview text string.
|
||||||
|
*/
|
||||||
getPreviewText: () => string;
|
getPreviewText: () => string;
|
||||||
/** Returns the scroll container's inner width in pixels. Returns 0 before mount. */
|
/**
|
||||||
|
* Returns the scroll container's inner width in pixels. Returns 0 before mount.
|
||||||
|
*/
|
||||||
getContainerWidth: () => number;
|
getContainerWidth: () => number;
|
||||||
/** Returns the font size in pixels (e.g. `controlManager.renderedSize`). */
|
/**
|
||||||
|
* Returns the font size in pixels (e.g. `controlManager.renderedSize`).
|
||||||
|
*/
|
||||||
getFontSizePx: () => number;
|
getFontSizePx: () => number;
|
||||||
/**
|
/**
|
||||||
* Returns the computed line height in pixels.
|
* Returns the computed line height in pixels.
|
||||||
@@ -31,7 +44,7 @@ export interface FontRowSizeResolverOptions {
|
|||||||
/**
|
/**
|
||||||
* Returns the font load status for a given font key (`'{id}@{weight}'` or `'{id}@vf'`).
|
* Returns the font load status for a given font key (`'{id}@{weight}'` or `'{id}@vf'`).
|
||||||
*
|
*
|
||||||
* In production: `(key) => appliedFontsManager.statuses.get(key)`.
|
* In production: `(key) => fontLifecycleManager.statuses.get(key)`.
|
||||||
* Injected for testability — avoids a module-level singleton dependency in tests.
|
* 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
|
* 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
|
* for reactivity to work. This is satisfied when `itemHeight` is called by
|
||||||
@@ -44,9 +57,13 @@ export interface FontRowSizeResolverOptions {
|
|||||||
* the content width is never over-estimated, keeping the height estimate safe.
|
* the content width is never over-estimated, keeping the height estimate safe.
|
||||||
*/
|
*/
|
||||||
contentHorizontalPadding: number;
|
contentHorizontalPadding: number;
|
||||||
/** Fixed height in pixels of chrome that is not text content (header bar, etc.). */
|
/**
|
||||||
|
* Fixed height in pixels of chrome that is not text content (header bar, etc.).
|
||||||
|
*/
|
||||||
chromeHeight: number;
|
chromeHeight: number;
|
||||||
/** Height in pixels to return when the font is not loaded or container width is 0. */
|
/**
|
||||||
|
* Height in pixels to return when the font is not loaded or container width is 0.
|
||||||
|
*/
|
||||||
fallbackHeight: number;
|
fallbackHeight: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,35 +82,40 @@ export interface FontRowSizeResolverOptions {
|
|||||||
* no DOM snap occurs.
|
* no DOM snap occurs.
|
||||||
*
|
*
|
||||||
* **Caching:** A `Map` keyed by `fontCssString|text|contentWidth|lineHeightPx`
|
* **Caching:** A `Map` keyed by `fontCssString|text|contentWidth|lineHeightPx`
|
||||||
* prevents redundant `TextLayoutEngine.layout()` calls. The cache is invalidated
|
* prevents redundant `pretext.layout()` calls. The cache is invalidated
|
||||||
* naturally because a change in any input produces a different cache key.
|
* naturally because a change in any input produces a different cache key.
|
||||||
*
|
*
|
||||||
* @param options - Configuration and getter functions (all injected for testability).
|
* @param options - Configuration and getter functions (all injected for testability).
|
||||||
* @returns A function `(rowIndex: number) => number` for use as `VirtualList.itemHeight`.
|
* @returns A function `(rowIndex: number) => number` for use as `VirtualList.itemHeight`.
|
||||||
*/
|
*/
|
||||||
export function createFontRowSizeResolver(options: FontRowSizeResolverOptions): (rowIndex: number) => number {
|
export function createFontRowSizeResolver(options: FontRowSizeResolverOptions): (rowIndex: number) => number {
|
||||||
const engine = new TextLayoutEngine();
|
|
||||||
// Key: `${fontCssString}|${text}|${contentWidth}|${lineHeightPx}`
|
// Key: `${fontCssString}|${text}|${contentWidth}|${lineHeightPx}`
|
||||||
const cache = new Map<string, number>();
|
const cache = new Map<string, number>();
|
||||||
|
|
||||||
return function resolveRowHeight(rowIndex: number): number {
|
return function resolveRowHeight(rowIndex: number): number {
|
||||||
const fonts = options.getFonts();
|
const fonts = options.getFonts();
|
||||||
const font = fonts[rowIndex];
|
const font = fonts[rowIndex];
|
||||||
if (!font) return options.fallbackHeight;
|
if (!font) {
|
||||||
|
return options.fallbackHeight;
|
||||||
|
}
|
||||||
|
|
||||||
const containerWidth = options.getContainerWidth();
|
const containerWidth = options.getContainerWidth();
|
||||||
const previewText = options.getPreviewText();
|
const previewText = options.getPreviewText();
|
||||||
|
|
||||||
if (containerWidth <= 0 || !previewText) return options.fallbackHeight;
|
if (containerWidth <= 0 || !previewText) {
|
||||||
|
return options.fallbackHeight;
|
||||||
|
}
|
||||||
|
|
||||||
const weight = options.getWeight();
|
const weight = options.getWeight();
|
||||||
// generateFontKey: '{id}@{weight}' for static fonts, '{id}@vf' for variable fonts.
|
// generateFontKey: '{id}@{weight}' for static fonts, '{id}@vf' for variable fonts.
|
||||||
const fontKey = generateFontKey({ id: font.id, weight, isVariable: font.features?.isVariable });
|
const fontKey = generateFontKey({ id: font.id, weight, isVariable: font.features?.isVariable });
|
||||||
|
|
||||||
// Reading via getStatus() allows the caller to pass appliedFontsManager.statuses.get(),
|
// Reading via getStatus() allows the caller to pass fontLifecycleManager.statuses.get(),
|
||||||
// which creates a Svelte 5 reactive dependency when called inside $derived.by.
|
// which creates a Svelte 5 reactive dependency when called inside $derived.by.
|
||||||
const status = options.getStatus(fontKey);
|
const status = options.getStatus(fontKey);
|
||||||
if (status !== 'loaded') return options.fallbackHeight;
|
if (status !== 'loaded') {
|
||||||
|
return options.fallbackHeight;
|
||||||
|
}
|
||||||
|
|
||||||
const fontSizePx = options.getFontSizePx();
|
const fontSizePx = options.getFontSizePx();
|
||||||
const lineHeightPx = options.getLineHeightPx();
|
const lineHeightPx = options.getLineHeightPx();
|
||||||
@@ -102,9 +124,15 @@ export function createFontRowSizeResolver(options: FontRowSizeResolverOptions):
|
|||||||
|
|
||||||
const cacheKey = `${fontCssString}|${previewText}|${contentWidth}|${lineHeightPx}`;
|
const cacheKey = `${fontCssString}|${previewText}|${contentWidth}|${lineHeightPx}`;
|
||||||
const cached = cache.get(cacheKey);
|
const cached = cache.get(cacheKey);
|
||||||
if (cached !== undefined) return cached;
|
if (cached !== undefined) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
const { totalHeight } = engine.layout(previewText, fontCssString, contentWidth, lineHeightPx);
|
// Pretext docs recommend `layout()` (not `layoutWithLines`) for the
|
||||||
|
// resize hot path — pure arithmetic on cached segment widths, no canvas
|
||||||
|
// calls, no string allocations.
|
||||||
|
const prepared = prepare(previewText, fontCssString);
|
||||||
|
const { height: totalHeight } = layout(prepared, contentWidth, lineHeightPx);
|
||||||
const result = totalHeight + options.chromeHeight;
|
const result = totalHeight + options.chromeHeight;
|
||||||
cache.set(cacheKey, result);
|
cache.set(cacheKey, result);
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
import type { ControlModel } from '$shared/lib';
|
|
||||||
import type { ControlId } from '../types/typography';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Font size constants
|
* Font size constants
|
||||||
*/
|
*/
|
||||||
@@ -33,56 +30,8 @@ export const MIN_LETTER_SPACING = -0.1;
|
|||||||
export const MAX_LETTER_SPACING = 0.5;
|
export const MAX_LETTER_SPACING = 0.5;
|
||||||
export const LETTER_SPACING_STEP = 0.01;
|
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
|
* Index value for items not yet loaded in a virtualized list.
|
||||||
|
* Treated as being at the very bottom of the infinite scroll.
|
||||||
*/
|
*/
|
||||||
export const MULTIPLIER_S = 0.5;
|
export const VIRTUAL_INDEX_NOT_LOADED = Infinity;
|
||||||
export const MULTIPLIER_M = 0.75;
|
|
||||||
export const MULTIPLIER_L = 1;
|
|
||||||
|
|||||||
@@ -1,3 +1,51 @@
|
|||||||
export * from './const/const';
|
export {
|
||||||
export * from './store';
|
DEFAULT_FONT_SIZE,
|
||||||
export * from './types';
|
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';
|
||||||
|
|||||||
+99
-41
@@ -1,4 +1,7 @@
|
|||||||
import { QueryClient } from '@tanstack/query-core';
|
import {
|
||||||
|
generateMixedCategoryFonts,
|
||||||
|
generateMockFonts,
|
||||||
|
} from '$entities/Font/testing';
|
||||||
import { flushSync } from 'svelte';
|
import { flushSync } from 'svelte';
|
||||||
import {
|
import {
|
||||||
afterEach,
|
afterEach,
|
||||||
@@ -12,23 +15,33 @@ import {
|
|||||||
FontNetworkError,
|
FontNetworkError,
|
||||||
FontResponseError,
|
FontResponseError,
|
||||||
} from '../../../lib/errors/errors';
|
} from '../../../lib/errors/errors';
|
||||||
import {
|
|
||||||
generateMixedCategoryFonts,
|
|
||||||
generateMockFonts,
|
|
||||||
} from '../../../lib/mocks/fonts.mock';
|
|
||||||
import type { UnifiedFont } from '../../types';
|
import type { UnifiedFont } from '../../types';
|
||||||
import { FontStore } from './fontStore.svelte';
|
import { FontCatalogStore } from './fontCatalogStore.svelte';
|
||||||
|
|
||||||
vi.mock('$shared/api/queryClient', () => ({
|
vi.mock('$shared/api/queryClient', async importOriginal => {
|
||||||
queryClient: new QueryClient({
|
/**
|
||||||
|
* Import QueryClient inside the factory rather than referencing the top-level binding.
|
||||||
|
* A hoisted vi.mock factory that touches a module-level import can hit that import
|
||||||
|
* before it is initialized (ReferenceError) when the import sits in a circular/eager
|
||||||
|
* barrel chain — which it now does via $shared/lib → BaseQueryStore → query-core.
|
||||||
|
*/
|
||||||
|
const { QueryClient } = await import('@tanstack/query-core');
|
||||||
|
const actual = await importOriginal<typeof import('$shared/api/queryClient')>();
|
||||||
|
const mockClient = new QueryClient({
|
||||||
defaultOptions: { queries: { retry: 0, gcTime: 0 } },
|
defaultOptions: { queries: { retry: 0, gcTime: 0 } },
|
||||||
}),
|
});
|
||||||
}));
|
return {
|
||||||
|
...actual,
|
||||||
|
getQueryClient: () => mockClient,
|
||||||
|
};
|
||||||
|
});
|
||||||
vi.mock('../../../api', () => ({ fetchProxyFonts: vi.fn() }));
|
vi.mock('../../../api', () => ({ fetchProxyFonts: vi.fn() }));
|
||||||
|
|
||||||
import { queryClient } from '$shared/api/queryClient';
|
import { getQueryClient } from '$shared/api/queryClient';
|
||||||
import { fetchProxyFonts } from '../../../api';
|
import { fetchProxyFonts } from '../../../api';
|
||||||
|
|
||||||
|
const queryClient = getQueryClient();
|
||||||
|
|
||||||
const fetch = fetchProxyFonts as ReturnType<typeof vi.fn>;
|
const fetch = fetchProxyFonts as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
type FontPage = { fonts: UnifiedFont[]; total: number; limit: number; offset: number };
|
type FontPage = { fonts: UnifiedFont[]; total: number; limit: number; offset: number };
|
||||||
@@ -44,7 +57,7 @@ const makeResponse = (
|
|||||||
});
|
});
|
||||||
|
|
||||||
function makeStore(params = {}) {
|
function makeStore(params = {}) {
|
||||||
return new FontStore({ limit: 10, ...params });
|
return new FontCatalogStore({ limit: 10, ...params });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchedStore(params = {}, fonts = generateMockFonts(5), meta: Parameters<typeof makeResponse>[1] = {}) {
|
async function fetchedStore(params = {}, fonts = generateMockFonts(5), meta: Parameters<typeof makeResponse>[1] = {}) {
|
||||||
@@ -55,13 +68,12 @@ async function fetchedStore(params = {}, fonts = generateMockFonts(5), meta: Par
|
|||||||
return store;
|
return store;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('FontStore', () => {
|
describe('FontCatalogStore', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
queryClient.clear();
|
queryClient.clear();
|
||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
describe('construction', () => {
|
describe('construction', () => {
|
||||||
it('stores initial params', () => {
|
it('stores initial params', () => {
|
||||||
const store = makeStore({ limit: 20 });
|
const store = makeStore({ limit: 20 });
|
||||||
@@ -70,7 +82,7 @@ describe('FontStore', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('defaults limit to 50 when not provided', () => {
|
it('defaults limit to 50 when not provided', () => {
|
||||||
const store = new FontStore();
|
const store = new FontCatalogStore();
|
||||||
expect(store.params.limit).toBe(50);
|
expect(store.params.limit).toBe(50);
|
||||||
store.destroy();
|
store.destroy();
|
||||||
});
|
});
|
||||||
@@ -81,16 +93,16 @@ describe('FontStore', () => {
|
|||||||
store.destroy();
|
store.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('starts with isEmpty false — initial fetch is in progress', () => {
|
it('starts with isEmpty false — observer is gated until setParams enables it', () => {
|
||||||
// The observer starts fetching immediately on construction.
|
// The observer is disabled on construction (no auto-fetch) — see
|
||||||
// isEmpty must be false so the UI shows a loader, not "no results".
|
// `#enabled` in the store. isEmpty must still be false so the UI
|
||||||
|
// doesn't flash "no results" before bindings configures the query.
|
||||||
const store = makeStore();
|
const store = makeStore();
|
||||||
expect(store.isEmpty).toBe(false);
|
expect(store.isEmpty).toBe(false);
|
||||||
store.destroy();
|
store.destroy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
describe('state after fetch', () => {
|
describe('state after fetch', () => {
|
||||||
it('exposes loaded fonts', async () => {
|
it('exposes loaded fonts', async () => {
|
||||||
const store = await fetchedStore({}, generateMockFonts(7));
|
const store = await fetchedStore({}, generateMockFonts(7));
|
||||||
@@ -129,7 +141,6 @@ describe('FontStore', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
describe('error states', () => {
|
describe('error states', () => {
|
||||||
it('isError is false before any fetch', () => {
|
it('isError is false before any fetch', () => {
|
||||||
const store = makeStore();
|
const store = makeStore();
|
||||||
@@ -178,7 +189,6 @@ describe('FontStore', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
describe('font accumulation', () => {
|
describe('font accumulation', () => {
|
||||||
it('replaces fonts when refetching the first page', async () => {
|
it('replaces fonts when refetching the first page', async () => {
|
||||||
const store = makeStore();
|
const store = makeStore();
|
||||||
@@ -212,7 +222,6 @@ describe('FontStore', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
describe('pagination state', () => {
|
describe('pagination state', () => {
|
||||||
it('returns zero-value defaults before any fetch', () => {
|
it('returns zero-value defaults before any fetch', () => {
|
||||||
const store = makeStore();
|
const store = makeStore();
|
||||||
@@ -248,7 +257,6 @@ describe('FontStore', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
describe('setParams', () => {
|
describe('setParams', () => {
|
||||||
it('merges updates into existing params', () => {
|
it('merges updates into existing params', () => {
|
||||||
const store = makeStore({ limit: 10 });
|
const store = makeStore({ limit: 10 });
|
||||||
@@ -266,7 +274,6 @@ describe('FontStore', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
describe('filter change resets', () => {
|
describe('filter change resets', () => {
|
||||||
it('clears accumulated fonts when a filter changes', async () => {
|
it('clears accumulated fonts when a filter changes', async () => {
|
||||||
const store = await fetchedStore({}, generateMockFonts(5));
|
const store = await fetchedStore({}, generateMockFonts(5));
|
||||||
@@ -302,7 +309,6 @@ describe('FontStore', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
describe('staleTime in buildOptions', () => {
|
describe('staleTime in buildOptions', () => {
|
||||||
it('is 5 minutes with no active filters', () => {
|
it('is 5 minutes with no active filters', () => {
|
||||||
const store = makeStore();
|
const store = makeStore();
|
||||||
@@ -331,7 +337,6 @@ describe('FontStore', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
describe('buildQueryKey', () => {
|
describe('buildQueryKey', () => {
|
||||||
it('omits empty-string params', () => {
|
it('omits empty-string params', () => {
|
||||||
const store = makeStore();
|
const store = makeStore();
|
||||||
@@ -366,7 +371,6 @@ describe('FontStore', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
describe('destroy', () => {
|
describe('destroy', () => {
|
||||||
it('does not throw', () => {
|
it('does not throw', () => {
|
||||||
const store = makeStore();
|
const store = makeStore();
|
||||||
@@ -380,7 +384,6 @@ describe('FontStore', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
describe('refetch', () => {
|
describe('refetch', () => {
|
||||||
it('triggers a fetch', async () => {
|
it('triggers a fetch', async () => {
|
||||||
const store = makeStore();
|
const store = makeStore();
|
||||||
@@ -400,13 +403,12 @@ describe('FontStore', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
describe('nextPage', () => {
|
describe('nextPage', () => {
|
||||||
let store: FontStore;
|
let store: FontCatalogStore;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
fetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 30, limit: 10, offset: 0 }));
|
fetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 30, limit: 10, offset: 0 }));
|
||||||
store = new FontStore({ limit: 10 });
|
store = new FontCatalogStore({ limit: 10 });
|
||||||
await store.refetch();
|
await store.refetch();
|
||||||
flushSync();
|
flushSync();
|
||||||
});
|
});
|
||||||
@@ -427,7 +429,7 @@ describe('FontStore', () => {
|
|||||||
// Set up a store where all fonts fit in one page (hasMore = false)
|
// Set up a store where all fonts fit in one page (hasMore = false)
|
||||||
queryClient.clear();
|
queryClient.clear();
|
||||||
fetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 10, limit: 10, offset: 0 }));
|
fetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 10, limit: 10, offset: 0 }));
|
||||||
store = new FontStore({ limit: 10 });
|
store = new FontCatalogStore({ limit: 10 });
|
||||||
await store.refetch();
|
await store.refetch();
|
||||||
flushSync();
|
flushSync();
|
||||||
|
|
||||||
@@ -437,7 +439,6 @@ describe('FontStore', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
describe('prevPage and goToPage', () => {
|
describe('prevPage and goToPage', () => {
|
||||||
it('prevPage is a no-op — infinite scroll does not support backward navigation', async () => {
|
it('prevPage is a no-op — infinite scroll does not support backward navigation', async () => {
|
||||||
const store = await fetchedStore({}, generateMockFonts(5));
|
const store = await fetchedStore({}, generateMockFonts(5));
|
||||||
@@ -454,7 +455,6 @@ describe('FontStore', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
describe('prefetch', () => {
|
describe('prefetch', () => {
|
||||||
it('triggers a fetch for the provided params', async () => {
|
it('triggers a fetch for the provided params', async () => {
|
||||||
const store = makeStore();
|
const store = makeStore();
|
||||||
@@ -465,11 +465,10 @@ describe('FontStore', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
describe('getCachedData / setQueryData', () => {
|
describe('getCachedData / setQueryData', () => {
|
||||||
it('getCachedData returns undefined before any fetch', () => {
|
it('getCachedData returns undefined before any fetch', () => {
|
||||||
queryClient.clear();
|
queryClient.clear();
|
||||||
const store = new FontStore({ limit: 10 });
|
const store = new FontCatalogStore({ limit: 10 });
|
||||||
expect(store.getCachedData()).toBeUndefined();
|
expect(store.getCachedData()).toBeUndefined();
|
||||||
store.destroy();
|
store.destroy();
|
||||||
});
|
});
|
||||||
@@ -497,7 +496,6 @@ describe('FontStore', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
describe('invalidate', () => {
|
describe('invalidate', () => {
|
||||||
it('calls invalidateQueries', async () => {
|
it('calls invalidateQueries', async () => {
|
||||||
const store = await fetchedStore();
|
const store = await fetchedStore();
|
||||||
@@ -508,7 +506,6 @@ describe('FontStore', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
describe('setLimit', () => {
|
describe('setLimit', () => {
|
||||||
it('updates the limit param', () => {
|
it('updates the limit param', () => {
|
||||||
const store = makeStore({ limit: 10 });
|
const store = makeStore({ limit: 10 });
|
||||||
@@ -518,9 +515,8 @@ describe('FontStore', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
describe('filter shortcut methods', () => {
|
describe('filter shortcut methods', () => {
|
||||||
let store: FontStore;
|
let store: FontCatalogStore;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
store = makeStore();
|
store = makeStore();
|
||||||
@@ -561,7 +557,6 @@ describe('FontStore', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
describe('category getters', () => {
|
describe('category getters', () => {
|
||||||
it('each getter returns only fonts of that category', async () => {
|
it('each getter returns only fonts of that category', async () => {
|
||||||
const fonts = generateMixedCategoryFonts(2); // 2 of each category = 10 total
|
const fonts = generateMixedCategoryFonts(2); // 2 of each category = 10 total
|
||||||
@@ -580,4 +575,67 @@ describe('FontStore', () => {
|
|||||||
store.destroy();
|
store.destroy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('fetchAllPagesTo', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
fetch.mockReset();
|
||||||
|
queryClient.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetches all missing pages in parallel up to targetIndex', async () => {
|
||||||
|
// First page already loaded (offset 0, limit 10, total 50)
|
||||||
|
const firstFonts = generateMockFonts(10);
|
||||||
|
fetch.mockResolvedValueOnce(makeResponse(firstFonts, { total: 50, limit: 10, offset: 0 }));
|
||||||
|
const store = makeStore();
|
||||||
|
await store.refetch();
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
expect(store.fonts).toHaveLength(10);
|
||||||
|
|
||||||
|
// Mock remaining pages
|
||||||
|
for (let offset = 10; offset < 50; offset += 10) {
|
||||||
|
fetch.mockResolvedValueOnce(
|
||||||
|
makeResponse(generateMockFonts(10), { total: 50, limit: 10, offset }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await store.fetchAllPagesTo(40);
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
expect(store.fonts).toHaveLength(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips pages that fail and still merges successful ones', async () => {
|
||||||
|
const firstFonts = generateMockFonts(10);
|
||||||
|
fetch.mockResolvedValueOnce(makeResponse(firstFonts, { total: 30, limit: 10, offset: 0 }));
|
||||||
|
const store = makeStore();
|
||||||
|
await store.refetch();
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
// offset=10 fails, offset=20 succeeds
|
||||||
|
fetch.mockRejectedValueOnce(new Error('network error'));
|
||||||
|
fetch.mockResolvedValueOnce(
|
||||||
|
makeResponse(generateMockFonts(10), { total: 30, limit: 10, offset: 20 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await store.fetchAllPagesTo(25);
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
// Page at offset=20 merged, page at offset=10 missing — 20 total
|
||||||
|
expect(store.fonts).toHaveLength(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a no-op when target is within already-loaded data', async () => {
|
||||||
|
const firstFonts = generateMockFonts(10);
|
||||||
|
fetch.mockResolvedValueOnce(makeResponse(firstFonts, { total: 50, limit: 10, offset: 0 }));
|
||||||
|
const store = makeStore();
|
||||||
|
await store.refetch();
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
const callsBefore = fetch.mock.calls.length;
|
||||||
|
await store.fetchAllPagesTo(5);
|
||||||
|
|
||||||
|
expect(fetch.mock.calls.length).toBe(callsBefore);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,495 @@
|
|||||||
|
import {
|
||||||
|
DEFAULT_QUERY_GC_TIME_MS,
|
||||||
|
DEFAULT_QUERY_STALE_TIME_MS,
|
||||||
|
getQueryClient,
|
||||||
|
} from '$shared/api/queryClient';
|
||||||
|
import { createSingleton } from '$shared/lib/helpers/createSingleton/createSingleton';
|
||||||
|
import {
|
||||||
|
type InfiniteData,
|
||||||
|
InfiniteQueryObserver,
|
||||||
|
type InfiniteQueryObserverResult,
|
||||||
|
type QueryFunctionContext,
|
||||||
|
} from '@tanstack/query-core';
|
||||||
|
import {
|
||||||
|
type ProxyFontsParams,
|
||||||
|
type ProxyFontsResponse,
|
||||||
|
fetchProxyFonts,
|
||||||
|
} from '../../../api';
|
||||||
|
import {
|
||||||
|
FontNetworkError,
|
||||||
|
FontResponseError,
|
||||||
|
} from '../../../lib/errors/errors';
|
||||||
|
import type { UnifiedFont } from '../../types';
|
||||||
|
|
||||||
|
type PageParam = { offset: number };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter params + limit — offset is managed by TQ as a page param, not a user param.
|
||||||
|
*/
|
||||||
|
type FontStoreParams = Omit<ProxyFontsParams, 'offset'>;
|
||||||
|
|
||||||
|
type FontStoreResult = InfiniteQueryObserverResult<InfiniteData<ProxyFontsResponse, PageParam>, Error>;
|
||||||
|
|
||||||
|
export class FontCatalogStore {
|
||||||
|
#params = $state<FontStoreParams>({ limit: 50 });
|
||||||
|
/**
|
||||||
|
* Gates the initial fetch. The observer starts disabled so the constructor
|
||||||
|
* cannot race ahead of the bindings module — which is the single source of
|
||||||
|
* truth for query params. The first setParams flips this on, producing a
|
||||||
|
* single fetch with the correctly merged queryKey.
|
||||||
|
*/
|
||||||
|
#enabled = $state(false);
|
||||||
|
#result = $state<FontStoreResult>({} as FontStoreResult);
|
||||||
|
#observer: InfiniteQueryObserver<
|
||||||
|
ProxyFontsResponse,
|
||||||
|
Error,
|
||||||
|
InfiniteData<ProxyFontsResponse, PageParam>,
|
||||||
|
readonly unknown[],
|
||||||
|
PageParam
|
||||||
|
>;
|
||||||
|
#qc = getQueryClient();
|
||||||
|
#unsubscribe: () => void;
|
||||||
|
|
||||||
|
constructor(params: FontStoreParams = {}) {
|
||||||
|
this.#params = { limit: 50, ...params };
|
||||||
|
this.#observer = new InfiniteQueryObserver(this.#qc, this.buildOptions());
|
||||||
|
// Seed result synchronously; subscribe may not fire on disabled observers.
|
||||||
|
this.#result = this.#observer.getCurrentResult();
|
||||||
|
this.#unsubscribe = this.#observer.subscribe(r => {
|
||||||
|
this.#result = r;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current filter and limit configuration
|
||||||
|
*/
|
||||||
|
get params(): FontStoreParams {
|
||||||
|
return this.#params;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Flattened list of all fonts loaded across all pages (reactive)
|
||||||
|
*/
|
||||||
|
get fonts(): UnifiedFont[] {
|
||||||
|
return this.#result.data?.pages.flatMap((p: ProxyFontsResponse) => p.fonts) ?? [];
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* True if the first page is currently being fetched
|
||||||
|
*/
|
||||||
|
get isLoading(): boolean {
|
||||||
|
return this.#result.isLoading;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* True if any background fetch is in progress (initial or pagination)
|
||||||
|
*/
|
||||||
|
get isFetching(): boolean {
|
||||||
|
return this.#result.isFetching;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* True if the last fetch attempt resulted in an error
|
||||||
|
*/
|
||||||
|
get isError(): boolean {
|
||||||
|
return this.#result.isError;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Last caught error from the query observer
|
||||||
|
*/
|
||||||
|
get error(): Error | null {
|
||||||
|
return this.#result.error ?? null;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* True if no fonts were found for the current filter criteria.
|
||||||
|
* Always false until the observer has been enabled (via setParams) — otherwise
|
||||||
|
* the UI would briefly render "no results" on mount before bindings configures
|
||||||
|
* the query.
|
||||||
|
*/
|
||||||
|
get isEmpty(): boolean {
|
||||||
|
return this.#enabled && !this.isLoading && !this.isFetching && this.fonts.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pagination metadata derived from the last loaded page
|
||||||
|
*/
|
||||||
|
get pagination() {
|
||||||
|
const pages = this.#result.data?.pages;
|
||||||
|
const last = pages?.at(-1);
|
||||||
|
if (!last) {
|
||||||
|
return {
|
||||||
|
total: 0,
|
||||||
|
limit: this.#params.limit ?? 50,
|
||||||
|
offset: 0,
|
||||||
|
hasMore: false,
|
||||||
|
page: 1,
|
||||||
|
totalPages: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
total: last.total,
|
||||||
|
limit: last.limit,
|
||||||
|
offset: last.offset,
|
||||||
|
hasMore: this.#result.hasNextPage,
|
||||||
|
page: pages!.length,
|
||||||
|
totalPages: Math.ceil(last.total / last.limit),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleans up subscriptions and destroys the observer
|
||||||
|
*/
|
||||||
|
destroy() {
|
||||||
|
this.#unsubscribe();
|
||||||
|
this.#observer.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge new parameters into existing state and trigger a refetch.
|
||||||
|
* The first call also enables the observer (see `#enabled`).
|
||||||
|
*/
|
||||||
|
setParams(updates: Partial<FontStoreParams>) {
|
||||||
|
this.#params = { ...this.#params, ...updates };
|
||||||
|
this.#enabled = true;
|
||||||
|
this.#observer.setOptions(this.buildOptions());
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Forcefully invalidate and refetch the current query from the network
|
||||||
|
*/
|
||||||
|
invalidate() {
|
||||||
|
this.#qc.invalidateQueries({ queryKey: this.buildQueryKey(this.#params) });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manually trigger a query refetch
|
||||||
|
*/
|
||||||
|
async refetch() {
|
||||||
|
await this.#observer.refetch();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prime the cache with data for a specific parameter set
|
||||||
|
*/
|
||||||
|
async prefetch(params: FontStoreParams) {
|
||||||
|
await this.#qc.prefetchInfiniteQuery(this.buildOptions(params));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abort any active network requests for this store
|
||||||
|
*/
|
||||||
|
cancel() {
|
||||||
|
this.#qc.cancelQueries({ queryKey: this.buildQueryKey(this.#params) });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve current font list from cache without triggering a fetch
|
||||||
|
*/
|
||||||
|
getCachedData(): UnifiedFont[] | undefined {
|
||||||
|
const data = this.#qc.getQueryData<InfiniteData<ProxyFontsResponse, PageParam>>(
|
||||||
|
this.buildQueryKey(this.#params),
|
||||||
|
);
|
||||||
|
if (!data) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return data.pages.flatMap(p => p.fonts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manually update the cached font data (useful for optimistic updates)
|
||||||
|
*/
|
||||||
|
setQueryData(updater: (old: UnifiedFont[] | undefined) => UnifiedFont[]) {
|
||||||
|
const key = this.buildQueryKey(this.#params);
|
||||||
|
this.#qc.setQueryData<InfiniteData<ProxyFontsResponse, PageParam>>(
|
||||||
|
key,
|
||||||
|
old => {
|
||||||
|
const flatFonts = old?.pages.flatMap(p => p.fonts);
|
||||||
|
const newFonts = updater(flatFonts);
|
||||||
|
// Re-distribute the updated fonts back into the existing page structure
|
||||||
|
// Define the first page. If old data exists, we merge into the first page template.
|
||||||
|
const limit = typeof this.#params.limit === 'number' ? this.#params.limit : 50;
|
||||||
|
const template = old?.pages[0] ?? {
|
||||||
|
total: newFonts.length,
|
||||||
|
limit,
|
||||||
|
offset: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedPage: ProxyFontsResponse = {
|
||||||
|
...template,
|
||||||
|
fonts: newFonts,
|
||||||
|
total: newFonts.length, // Synchronize total with the new font count
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
pages: [updatedPage],
|
||||||
|
pageParams: [{ offset: 0 }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shortcut to update provider filters
|
||||||
|
*/
|
||||||
|
setProviders(v: ProxyFontsParams['providers']) {
|
||||||
|
this.setParams({ providers: v });
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Shortcut to update category filters
|
||||||
|
*/
|
||||||
|
setCategories(v: ProxyFontsParams['categories']) {
|
||||||
|
this.setParams({ categories: v });
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Shortcut to update subset filters
|
||||||
|
*/
|
||||||
|
setSubsets(v: ProxyFontsParams['subsets']) {
|
||||||
|
this.setParams({ subsets: v });
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Shortcut to update search query
|
||||||
|
*/
|
||||||
|
setSearch(v: string) {
|
||||||
|
this.setParams({ q: v || undefined });
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Shortcut to update sort order
|
||||||
|
*/
|
||||||
|
setSort(v: ProxyFontsParams['sort']) {
|
||||||
|
this.setParams({ sort: v });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the next page of results if available
|
||||||
|
*/
|
||||||
|
async nextPage(): Promise<void> {
|
||||||
|
await this.#observer.fetchNextPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
#isCatchingUp = false;
|
||||||
|
#inFlightOffsets = new Set<number>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all pages between the current loaded count and targetIndex in parallel.
|
||||||
|
* Pages are merged into the cache as they arrive (sorted by offset).
|
||||||
|
* Failed pages are silently skipped — normal scroll will re-fetch them on demand.
|
||||||
|
*/
|
||||||
|
async fetchAllPagesTo(targetIndex: number): Promise<void> {
|
||||||
|
if (this.#isCatchingUp) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageSize = typeof this.#params.limit === 'number' ? this.#params.limit : 50;
|
||||||
|
const key = this.buildQueryKey(this.#params);
|
||||||
|
const existing = this.#qc.getQueryData<InfiniteData<ProxyFontsResponse, PageParam>>(key);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadedOffsets = new Set(existing.pageParams.map(p => p.offset));
|
||||||
|
|
||||||
|
// Collect offsets for all missing and not-in-flight pages
|
||||||
|
const missingOffsets: number[] = [];
|
||||||
|
for (let offset = 0; offset <= targetIndex; offset += pageSize) {
|
||||||
|
if (!loadedOffsets.has(offset) && !this.#inFlightOffsets.has(offset)) {
|
||||||
|
missingOffsets.push(offset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (missingOffsets.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#isCatchingUp = true;
|
||||||
|
|
||||||
|
// Sorted merge buffer — flush in offset order as pages arrive
|
||||||
|
const buffer = new Map<number, ProxyFontsResponse>();
|
||||||
|
const failed = new Set<number>();
|
||||||
|
let nextFlushOffset = (existing.pageParams.at(-1)?.offset ?? -pageSize) + pageSize;
|
||||||
|
|
||||||
|
const flush = () => {
|
||||||
|
while (buffer.has(nextFlushOffset) || failed.has(nextFlushOffset)) {
|
||||||
|
if (buffer.has(nextFlushOffset)) {
|
||||||
|
this.#appendPageToCache(buffer.get(nextFlushOffset)!);
|
||||||
|
buffer.delete(nextFlushOffset);
|
||||||
|
}
|
||||||
|
failed.delete(nextFlushOffset);
|
||||||
|
nextFlushOffset += pageSize;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.allSettled(
|
||||||
|
missingOffsets.map(async offset => {
|
||||||
|
this.#inFlightOffsets.add(offset);
|
||||||
|
try {
|
||||||
|
const page = await this.fetchPage({ ...this.#params, offset });
|
||||||
|
buffer.set(offset, page);
|
||||||
|
} catch {
|
||||||
|
failed.add(offset);
|
||||||
|
} finally {
|
||||||
|
this.#inFlightOffsets.delete(offset);
|
||||||
|
}
|
||||||
|
flush();
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
this.#isCatchingUp = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backward pagination (no-op: infinite scroll accumulates forward only)
|
||||||
|
*/
|
||||||
|
prevPage(): void {}
|
||||||
|
/**
|
||||||
|
* Jump to specific page (no-op for infinite scroll)
|
||||||
|
*/
|
||||||
|
goToPage(_page: number): void {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the number of items fetched per page
|
||||||
|
*/
|
||||||
|
setLimit(limit: number) {
|
||||||
|
this.setParams({ limit });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derived list of sans-serif fonts in the current set
|
||||||
|
*/
|
||||||
|
get sansSerifFonts() {
|
||||||
|
return this.fonts.filter(f => f.category === 'sans-serif');
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Derived list of serif fonts in the current set
|
||||||
|
*/
|
||||||
|
get serifFonts() {
|
||||||
|
return this.fonts.filter(f => f.category === 'serif');
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Derived list of display fonts in the current set
|
||||||
|
*/
|
||||||
|
get displayFonts() {
|
||||||
|
return this.fonts.filter(f => f.category === 'display');
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Derived list of handwriting fonts in the current set
|
||||||
|
*/
|
||||||
|
get handwritingFonts() {
|
||||||
|
return this.fonts.filter(f => f.category === 'handwriting');
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Derived list of monospace fonts in the current set
|
||||||
|
*/
|
||||||
|
get monospaceFonts() {
|
||||||
|
return this.fonts.filter(f => f.category === 'monospace');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge a single page into the InfiniteQuery cache in offset order.
|
||||||
|
* Called by fetchAllPagesTo as each parallel fetch resolves.
|
||||||
|
*/
|
||||||
|
#appendPageToCache(page: ProxyFontsResponse): void {
|
||||||
|
const key = this.buildQueryKey(this.#params);
|
||||||
|
const existing = this.#qc.getQueryData<InfiniteData<ProxyFontsResponse, PageParam>>(key);
|
||||||
|
if (!existing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guard against duplicates
|
||||||
|
const loadedOffsets = new Set(existing.pageParams.map(p => p.offset));
|
||||||
|
if (loadedOffsets.has(page.offset)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allPages = [...existing.pages, page].sort((a, b) => a.offset - b.offset);
|
||||||
|
const allParams = [...existing.pageParams, { offset: page.offset }].sort(
|
||||||
|
(a, b) => a.offset - b.offset,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.#qc.setQueryData<InfiniteData<ProxyFontsResponse, PageParam>>(key, {
|
||||||
|
pages: allPages,
|
||||||
|
pageParams: allParams,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildQueryKey(params: FontStoreParams): readonly unknown[] {
|
||||||
|
const filtered: Record<string, any> = {};
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(params)) {
|
||||||
|
// Ensure we DO NOT 'continue' or skip the limit key here.
|
||||||
|
// The limit is a fundamental part of the data identity.
|
||||||
|
if (
|
||||||
|
value !== undefined
|
||||||
|
&& value !== null
|
||||||
|
&& value !== ''
|
||||||
|
&& !(Array.isArray(value) && value.length === 0)
|
||||||
|
) {
|
||||||
|
filtered[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['fonts', filtered];
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildOptions(params = this.#params) {
|
||||||
|
const activeParams = { ...params };
|
||||||
|
const hasFilters = !!(
|
||||||
|
activeParams.q
|
||||||
|
|| (Array.isArray(activeParams.providers) && activeParams.providers.length > 0)
|
||||||
|
|| (Array.isArray(activeParams.categories) && activeParams.categories.length > 0)
|
||||||
|
|| (Array.isArray(activeParams.subsets) && activeParams.subsets.length > 0)
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
queryKey: this.buildQueryKey(activeParams),
|
||||||
|
queryFn: ({ pageParam }: QueryFunctionContext<readonly unknown[], PageParam>) =>
|
||||||
|
this.fetchPage({ ...activeParams, ...pageParam }),
|
||||||
|
initialPageParam: { offset: 0 } as PageParam,
|
||||||
|
getNextPageParam: (lastPage: ProxyFontsResponse): PageParam | undefined => {
|
||||||
|
const next = lastPage.offset + lastPage.limit;
|
||||||
|
return next < lastPage.total ? { offset: next } : undefined;
|
||||||
|
},
|
||||||
|
enabled: this.#enabled,
|
||||||
|
staleTime: hasFilters ? 0 : DEFAULT_QUERY_STALE_TIME_MS,
|
||||||
|
gcTime: DEFAULT_QUERY_GC_TIME_MS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchPage(params: ProxyFontsParams): Promise<ProxyFontsResponse> {
|
||||||
|
let response: ProxyFontsResponse;
|
||||||
|
try {
|
||||||
|
response = await fetchProxyFonts(params);
|
||||||
|
} catch (cause) {
|
||||||
|
// Preserve non-retryable validation errors so the query client doesn't
|
||||||
|
// burn the retry budget on a deterministic schema mismatch.
|
||||||
|
if (cause instanceof FontResponseError) {
|
||||||
|
throw cause;
|
||||||
|
}
|
||||||
|
throw new FontNetworkError(cause);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response) {
|
||||||
|
throw new FontResponseError('response', response);
|
||||||
|
}
|
||||||
|
if (!response.fonts) {
|
||||||
|
throw new FontResponseError('response.fonts', response.fonts);
|
||||||
|
}
|
||||||
|
if (!Array.isArray(response.fonts)) {
|
||||||
|
throw new FontResponseError('response.fonts', response.fonts);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
fonts: response.fonts,
|
||||||
|
total: response.total ?? 0,
|
||||||
|
limit: response.limit ?? params.limit ?? 50,
|
||||||
|
offset: response.offset ?? params.offset ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const catalog = createSingleton(
|
||||||
|
() => new FontCatalogStore({ limit: 50 }),
|
||||||
|
instance => instance.destroy(),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getFontCatalog = catalog.get;
|
||||||
|
|
||||||
|
// test-only reset, so specs don't share a live observer
|
||||||
|
export const __resetFontCatalog = catalog.reset;
|
||||||
+81
-25
@@ -1,3 +1,4 @@
|
|||||||
|
import { createSingleton } from '$shared/lib/helpers/createSingleton/createSingleton';
|
||||||
import { SvelteMap } from 'svelte/reactivity';
|
import { SvelteMap } from 'svelte/reactivity';
|
||||||
import {
|
import {
|
||||||
type FontLoadRequestConfig,
|
type FontLoadRequestConfig,
|
||||||
@@ -17,7 +18,36 @@ import { FontBufferCache } from './utils/fontBufferCache/FontBufferCache';
|
|||||||
import { FontEvictionPolicy } from './utils/fontEvictionPolicy/FontEvictionPolicy';
|
import { FontEvictionPolicy } from './utils/fontEvictionPolicy/FontEvictionPolicy';
|
||||||
import { FontLoadQueue } from './utils/fontLoadQueue/FontLoadQueue';
|
import { FontLoadQueue } from './utils/fontLoadQueue/FontLoadQueue';
|
||||||
|
|
||||||
interface AppliedFontsManagerDeps {
|
/**
|
||||||
|
* How often the periodic eviction sweep runs.
|
||||||
|
*/
|
||||||
|
const PURGE_INTERVAL_MS = 60000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Timeout for `requestIdleCallback`. After this elapses, the callback is
|
||||||
|
* forced to run regardless of whether the browser is idle.
|
||||||
|
*/
|
||||||
|
const IDLE_CALLBACK_TIMEOUT_MS = 150;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* setTimeout fallback delay when `requestIdleCallback` is unavailable.
|
||||||
|
* ~16ms ≈ one frame at 60fps.
|
||||||
|
*/
|
||||||
|
const SCHEDULE_FALLBACK_MS = 16;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How often the parse loop yields back to the main thread when the browser
|
||||||
|
* does not provide `isInputPending` (non-Chromium fallback).
|
||||||
|
*/
|
||||||
|
const YIELD_INTERVAL_MS = 8;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Font weights treated as "critical" in data-saver mode. Other weights are
|
||||||
|
* skipped to reduce network usage; variable fonts bypass this filter.
|
||||||
|
*/
|
||||||
|
const CRITICAL_FONT_WEIGHTS = [400, 700];
|
||||||
|
|
||||||
|
interface FontLifecycleManagerDeps {
|
||||||
cache?: FontBufferCache;
|
cache?: FontBufferCache;
|
||||||
eviction?: FontEvictionPolicy;
|
eviction?: FontEvictionPolicy;
|
||||||
queue?: FontLoadQueue;
|
queue?: FontLoadQueue;
|
||||||
@@ -46,7 +76,7 @@ interface AppliedFontsManagerDeps {
|
|||||||
*
|
*
|
||||||
* **Browser APIs Used:** `scheduler.yield()`, `isInputPending()`, `requestIdleCallback`, Cache API, Network Information API
|
* **Browser APIs Used:** `scheduler.yield()`, `isInputPending()`, `requestIdleCallback`, Cache API, Network Information API
|
||||||
*/
|
*/
|
||||||
export class AppliedFontsManager {
|
export class FontLifecycleManager {
|
||||||
// Injected collaborators - each handles one concern for better testability
|
// Injected collaborators - each handles one concern for better testability
|
||||||
readonly #cache: FontBufferCache;
|
readonly #cache: FontBufferCache;
|
||||||
readonly #eviction: FontEvictionPolicy;
|
readonly #eviction: FontEvictionPolicy;
|
||||||
@@ -70,22 +100,20 @@ export class AppliedFontsManager {
|
|||||||
// Tracks which callback type is pending ('idle' | 'timeout' | null) for proper cancellation
|
// Tracks which callback type is pending ('idle' | 'timeout' | null) for proper cancellation
|
||||||
#pendingType: 'idle' | 'timeout' | null = null;
|
#pendingType: 'idle' | 'timeout' | null = null;
|
||||||
|
|
||||||
readonly #PURGE_INTERVAL = 60000;
|
|
||||||
|
|
||||||
// Reactive status map for Svelte components to track font states
|
// Reactive status map for Svelte components to track font states
|
||||||
statuses = new SvelteMap<string, FontLoadStatus>();
|
statuses = new SvelteMap<string, FontLoadStatus>();
|
||||||
|
|
||||||
// Starts periodic cleanup timer (browser-only).
|
// Starts periodic cleanup timer (browser-only).
|
||||||
constructor(
|
constructor(
|
||||||
{ cache = new FontBufferCache(), eviction = new FontEvictionPolicy(), queue = new FontLoadQueue() }:
|
{ cache = new FontBufferCache(), eviction = new FontEvictionPolicy(), queue = new FontLoadQueue() }:
|
||||||
AppliedFontsManagerDeps = {},
|
FontLifecycleManagerDeps = {},
|
||||||
) {
|
) {
|
||||||
// Inject collaborators - defaults provided for production, fakes for testing
|
// Inject collaborators - defaults provided for production, fakes for testing
|
||||||
this.#cache = cache;
|
this.#cache = cache;
|
||||||
this.#eviction = eviction;
|
this.#eviction = eviction;
|
||||||
this.#queue = queue;
|
this.#queue = queue;
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
this.#intervalId = setInterval(() => this.#purgeUnused(), this.#PURGE_INTERVAL);
|
this.#intervalId = setInterval(() => this.#purgeUnused(), PURGE_INTERVAL_MS);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,16 +175,18 @@ export class AppliedFontsManager {
|
|||||||
if (typeof requestIdleCallback !== 'undefined') {
|
if (typeof requestIdleCallback !== 'undefined') {
|
||||||
this.#timeoutId = requestIdleCallback(
|
this.#timeoutId = requestIdleCallback(
|
||||||
() => this.#processQueue(),
|
() => this.#processQueue(),
|
||||||
{ timeout: 150 },
|
{ timeout: IDLE_CALLBACK_TIMEOUT_MS },
|
||||||
) as unknown as ReturnType<typeof setTimeout>;
|
) as unknown as ReturnType<typeof setTimeout>;
|
||||||
this.#pendingType = 'idle';
|
this.#pendingType = 'idle';
|
||||||
} else {
|
} else {
|
||||||
this.#timeoutId = setTimeout(() => this.#processQueue(), 16);
|
this.#timeoutId = setTimeout(() => this.#processQueue(), SCHEDULE_FALLBACK_MS);
|
||||||
this.#pendingType = 'timeout';
|
this.#pendingType = 'timeout';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns true if data-saver mode is enabled (defers non-critical weights). */
|
/**
|
||||||
|
* Returns true if data-saver mode is enabled (defers non-critical weights).
|
||||||
|
*/
|
||||||
#shouldDeferNonCritical(): boolean {
|
#shouldDeferNonCritical(): boolean {
|
||||||
return (navigator as any).connection?.saveData === true;
|
return (navigator as any).connection?.saveData === true;
|
||||||
}
|
}
|
||||||
@@ -181,24 +211,21 @@ export class AppliedFontsManager {
|
|||||||
|
|
||||||
// In data-saver mode, only load variable fonts and common weights (400, 700)
|
// In data-saver mode, only load variable fonts and common weights (400, 700)
|
||||||
if (this.#shouldDeferNonCritical()) {
|
if (this.#shouldDeferNonCritical()) {
|
||||||
entries = entries.filter(([, c]) => c.isVariable || [400, 700].includes(c.weight));
|
entries = entries.filter(([, c]) => c.isVariable || CRITICAL_FONT_WEIGHTS.includes(c.weight));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine optimal concurrent fetches based on network speed (1-4)
|
// Determine optimal concurrent fetches based on network speed (1-4)
|
||||||
const concurrency = getEffectiveConcurrency();
|
const concurrency = getEffectiveConcurrency();
|
||||||
const buffers = new Map<string, ArrayBuffer>();
|
const buffers = new Map<string, ArrayBuffer>();
|
||||||
|
|
||||||
// ==================== PHASE 1: Concurrent Fetching ====================
|
|
||||||
// Fetch multiple font files in parallel since network I/O is non-blocking
|
// Fetch multiple font files in parallel since network I/O is non-blocking
|
||||||
for (let i = 0; i < entries.length; i += concurrency) {
|
for (let i = 0; i < entries.length; i += concurrency) {
|
||||||
await this.#fetchChunk(entries.slice(i, i + concurrency), buffers);
|
await this.#fetchChunk(entries.slice(i, i + concurrency), buffers);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== PHASE 2: Sequential Parsing ====================
|
|
||||||
// Parse buffers one at a time with periodic yields to avoid blocking UI
|
// Parse buffers one at a time with periodic yields to avoid blocking UI
|
||||||
const hasInputPending = !!(navigator as any).scheduling?.isInputPending;
|
const hasInputPending = !!(navigator as any).scheduling?.isInputPending;
|
||||||
let lastYield = performance.now();
|
let lastYield = performance.now();
|
||||||
const YIELD_INTERVAL = 8;
|
|
||||||
|
|
||||||
for (const [key, config] of entries) {
|
for (const [key, config] of entries) {
|
||||||
const buffer = buffers.get(key);
|
const buffer = buffers.get(key);
|
||||||
@@ -214,7 +241,7 @@ export class AppliedFontsManager {
|
|||||||
// Others: yield every 8ms as fallback
|
// Others: yield every 8ms as fallback
|
||||||
const shouldYield = hasInputPending
|
const shouldYield = hasInputPending
|
||||||
? (navigator as any).scheduling.isInputPending({ includeContinuous: true })
|
? (navigator as any).scheduling.isInputPending({ includeContinuous: true })
|
||||||
: performance.now() - lastYield > YIELD_INTERVAL;
|
: performance.now() - lastYield > YIELD_INTERVAL_MS;
|
||||||
|
|
||||||
if (shouldYield) {
|
if (shouldYield) {
|
||||||
await yieldToMainThread();
|
await yieldToMainThread();
|
||||||
@@ -246,12 +273,16 @@ export class AppliedFontsManager {
|
|||||||
);
|
);
|
||||||
|
|
||||||
for (const result of results) {
|
for (const result of results) {
|
||||||
if (result.ok) continue;
|
if (result.ok) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const { key, config, reason } = result;
|
const { key, config, reason } = result;
|
||||||
const isAbort = reason instanceof FontFetchError
|
const isAbort = reason instanceof FontFetchError
|
||||||
&& reason.cause instanceof Error
|
&& reason.cause instanceof Error
|
||||||
&& reason.cause.name === 'AbortError';
|
&& reason.cause.name === 'AbortError';
|
||||||
if (isAbort) continue;
|
if (isAbort) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (reason instanceof FontFetchError) {
|
if (reason instanceof FontFetchError) {
|
||||||
console.error(`Font fetch failed: ${config.name}`, reason);
|
console.error(`Font fetch failed: ${config.name}`, reason);
|
||||||
}
|
}
|
||||||
@@ -279,7 +310,9 @@ export class AppliedFontsManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Removes fonts unused within TTL (LRU-style cleanup). Runs every PURGE_INTERVAL. Pinned fonts are never evicted. */
|
/**
|
||||||
|
* Removes fonts unused within TTL (LRU-style cleanup). Runs every PURGE_INTERVAL. Pinned fonts are never evicted.
|
||||||
|
*/
|
||||||
#purgeUnused() {
|
#purgeUnused() {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
// Iterate through all tracked font keys
|
// Iterate through all tracked font keys
|
||||||
@@ -291,7 +324,9 @@ export class AppliedFontsManager {
|
|||||||
|
|
||||||
// Remove FontFace from document to free memory
|
// Remove FontFace from document to free memory
|
||||||
const font = this.#loadedFonts.get(key);
|
const font = this.#loadedFonts.get(key);
|
||||||
if (font) document.fonts.delete(font);
|
if (font) {
|
||||||
|
document.fonts.delete(font);
|
||||||
|
}
|
||||||
|
|
||||||
// Evict from cache and cleanup URL mapping
|
// Evict from cache and cleanup URL mapping
|
||||||
const url = this.#urlByKey.get(key);
|
const url = this.#urlByKey.get(key);
|
||||||
@@ -307,7 +342,9 @@ export class AppliedFontsManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns current loading status for a font, or undefined if never requested. */
|
/**
|
||||||
|
* Returns current loading status for a font, or undefined if never requested.
|
||||||
|
*/
|
||||||
getFontStatus(id: string, weight: number, isVariable = false) {
|
getFontStatus(id: string, weight: number, isVariable = false) {
|
||||||
try {
|
try {
|
||||||
return this.statuses.get(generateFontKey({ id, weight, isVariable }));
|
return this.statuses.get(generateFontKey({ id, weight, isVariable }));
|
||||||
@@ -316,17 +353,23 @@ export class AppliedFontsManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Pins a font so it is never evicted by #purgeUnused(), regardless of TTL. */
|
/**
|
||||||
|
* Pins a font so it is never evicted by #purgeUnused(), regardless of TTL.
|
||||||
|
*/
|
||||||
pin(id: string, weight: number, isVariable = false): void {
|
pin(id: string, weight: number, isVariable = false): void {
|
||||||
this.#eviction.pin(generateFontKey({ id, weight, isVariable }));
|
this.#eviction.pin(generateFontKey({ id, weight, isVariable }));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Unpins a font, allowing it to be evicted by #purgeUnused() once its TTL expires. */
|
/**
|
||||||
|
* Unpins a font, allowing it to be evicted by #purgeUnused() once its TTL expires.
|
||||||
|
*/
|
||||||
unpin(id: string, weight: number, isVariable = false): void {
|
unpin(id: string, weight: number, isVariable = false): void {
|
||||||
this.#eviction.unpin(generateFontKey({ id, weight, isVariable }));
|
this.#eviction.unpin(generateFontKey({ id, weight, isVariable }));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Waits for all fonts to finish loading using document.fonts.ready. */
|
/**
|
||||||
|
* Waits for all fonts to finish loading using document.fonts.ready.
|
||||||
|
*/
|
||||||
async ready(): Promise<void> {
|
async ready(): Promise<void> {
|
||||||
if (typeof document === 'undefined') {
|
if (typeof document === 'undefined') {
|
||||||
return;
|
return;
|
||||||
@@ -336,7 +379,9 @@ export class AppliedFontsManager {
|
|||||||
} catch { /* document unloaded */ }
|
} catch { /* document unloaded */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Aborts all operations, removes fonts from document, and clears state. Manager cannot be reused after. */
|
/**
|
||||||
|
* Aborts all operations, removes fonts from document, and clears state. Manager cannot be reused after.
|
||||||
|
*/
|
||||||
destroy() {
|
destroy() {
|
||||||
// Abort all in-flight network requests
|
// Abort all in-flight network requests
|
||||||
this.#abortController.abort();
|
this.#abortController.abort();
|
||||||
@@ -375,5 +420,16 @@ export class AppliedFontsManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Singleton instance — use throughout the application for unified font loading state. */
|
/**
|
||||||
export const appliedFontsManager = new AppliedFontsManager();
|
* App-wide font lifecycle manager, created on first access. Lazy so its
|
||||||
|
* AbortController / FontFace bookkeeping isn't set up at module load.
|
||||||
|
*/
|
||||||
|
const fontLifecycleManager = createSingleton(
|
||||||
|
() => new FontLifecycleManager(),
|
||||||
|
instance => instance.destroy(),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getFontLifecycleManager = fontLifecycleManager.get;
|
||||||
|
|
||||||
|
// test-only reset, so specs don't share loaded-font/eviction state
|
||||||
|
export const __resetFontLifecycleManager = fontLifecycleManager.reset;
|
||||||
+14
-28
@@ -1,10 +1,10 @@
|
|||||||
/** @vitest-environment jsdom */
|
/**
|
||||||
import { AppliedFontsManager } from './appliedFontsStore.svelte';
|
* @vitest-environment jsdom
|
||||||
|
*/
|
||||||
import { FontFetchError } from './errors';
|
import { FontFetchError } from './errors';
|
||||||
|
import { FontLifecycleManager } from './fontLifecycleManager.svelte';
|
||||||
import { FontEvictionPolicy } from './utils/fontEvictionPolicy/FontEvictionPolicy';
|
import { FontEvictionPolicy } from './utils/fontEvictionPolicy/FontEvictionPolicy';
|
||||||
|
|
||||||
// ── Fake collaborators ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class FakeBufferCache {
|
class FakeBufferCache {
|
||||||
async get(_url: string): Promise<ArrayBuffer> {
|
async get(_url: string): Promise<ArrayBuffer> {
|
||||||
return new ArrayBuffer(8);
|
return new ArrayBuffer(8);
|
||||||
@@ -13,7 +13,9 @@ class FakeBufferCache {
|
|||||||
clear(): void {}
|
clear(): void {}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Throws {@link FontFetchError} on every `get()` — simulates network/HTTP failure. */
|
/**
|
||||||
|
* Throws {@link FontFetchError} on every `get()` — simulates network/HTTP failure.
|
||||||
|
*/
|
||||||
class FailingBufferCache {
|
class FailingBufferCache {
|
||||||
async get(url: string): Promise<never> {
|
async get(url: string): Promise<never> {
|
||||||
throw new FontFetchError(url, new Error('network error'), 500);
|
throw new FontFetchError(url, new Error('network error'), 500);
|
||||||
@@ -22,8 +24,6 @@ class FailingBufferCache {
|
|||||||
clear(): void {}
|
clear(): void {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const makeConfig = (id: string, overrides: Partial<{ weight: number; isVariable: boolean }> = {}) => ({
|
const makeConfig = (id: string, overrides: Partial<{ weight: number; isVariable: boolean }> = {}) => ({
|
||||||
id,
|
id,
|
||||||
name: id,
|
name: id,
|
||||||
@@ -32,10 +32,8 @@ const makeConfig = (id: string, overrides: Partial<{ weight: number; isVariable:
|
|||||||
...overrides,
|
...overrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Suite ─────────────────────────────────────────────────────────────────────
|
describe('FontLifecycleManager', () => {
|
||||||
|
let manager: FontLifecycleManager;
|
||||||
describe('AppliedFontsManager', () => {
|
|
||||||
let manager: AppliedFontsManager;
|
|
||||||
let eviction: FontEvictionPolicy;
|
let eviction: FontEvictionPolicy;
|
||||||
let mockFontFaceSet: { add: ReturnType<typeof vi.fn>; delete: ReturnType<typeof vi.fn> };
|
let mockFontFaceSet: { add: ReturnType<typeof vi.fn>; delete: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
@@ -57,7 +55,7 @@ describe('AppliedFontsManager', () => {
|
|||||||
});
|
});
|
||||||
vi.stubGlobal('FontFace', MockFontFace);
|
vi.stubGlobal('FontFace', MockFontFace);
|
||||||
|
|
||||||
manager = new AppliedFontsManager({ cache: new FakeBufferCache() as any, eviction });
|
manager = new FontLifecycleManager({ cache: new FakeBufferCache() as any, eviction });
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -66,8 +64,6 @@ describe('AppliedFontsManager', () => {
|
|||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── touch() ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('touch()', () => {
|
describe('touch()', () => {
|
||||||
it('queues and loads a new font', async () => {
|
it('queues and loads a new font', async () => {
|
||||||
manager.touch([makeConfig('roboto')]);
|
manager.touch([makeConfig('roboto')]);
|
||||||
@@ -105,7 +101,7 @@ describe('AppliedFontsManager', () => {
|
|||||||
|
|
||||||
it('skips fonts that have exhausted retries', async () => {
|
it('skips fonts that have exhausted retries', async () => {
|
||||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
const failManager = new AppliedFontsManager({ cache: new FailingBufferCache() as any, eviction });
|
const failManager = new FontLifecycleManager({ cache: new FailingBufferCache() as any, eviction });
|
||||||
|
|
||||||
// exhaust all 3 retries
|
// exhaust all 3 retries
|
||||||
for (let i = 0; i < 3; i++) {
|
for (let i = 0; i < 3; i++) {
|
||||||
@@ -131,8 +127,6 @@ describe('AppliedFontsManager', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── queue processing ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('queue processing', () => {
|
describe('queue processing', () => {
|
||||||
it('filters non-critical weights in data-saver mode', async () => {
|
it('filters non-critical weights in data-saver mode', async () => {
|
||||||
(navigator as any).connection = { saveData: true };
|
(navigator as any).connection = { saveData: true };
|
||||||
@@ -163,12 +157,10 @@ describe('AppliedFontsManager', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Phase 1: fetch ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('Phase 1 — fetch', () => {
|
describe('Phase 1 — fetch', () => {
|
||||||
it('sets status to error on fetch failure', async () => {
|
it('sets status to error on fetch failure', async () => {
|
||||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
const failManager = new AppliedFontsManager({ cache: new FailingBufferCache() as any, eviction });
|
const failManager = new FontLifecycleManager({ cache: new FailingBufferCache() as any, eviction });
|
||||||
|
|
||||||
failManager.touch([makeConfig('broken')]);
|
failManager.touch([makeConfig('broken')]);
|
||||||
await vi.advanceTimersByTimeAsync(50);
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
@@ -179,7 +171,7 @@ describe('AppliedFontsManager', () => {
|
|||||||
|
|
||||||
it('logs a console error on fetch failure', async () => {
|
it('logs a console error on fetch failure', async () => {
|
||||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
const failManager = new AppliedFontsManager({ cache: new FailingBufferCache() as any, eviction });
|
const failManager = new FontLifecycleManager({ cache: new FailingBufferCache() as any, eviction });
|
||||||
|
|
||||||
failManager.touch([makeConfig('broken')]);
|
failManager.touch([makeConfig('broken')]);
|
||||||
await vi.advanceTimersByTimeAsync(50);
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
@@ -197,7 +189,7 @@ describe('AppliedFontsManager', () => {
|
|||||||
evict() {},
|
evict() {},
|
||||||
clear() {},
|
clear() {},
|
||||||
};
|
};
|
||||||
const abortManager = new AppliedFontsManager({ cache: abortingCache as any, eviction });
|
const abortManager = new FontLifecycleManager({ cache: abortingCache as any, eviction });
|
||||||
|
|
||||||
abortManager.touch([makeConfig('aborted')]);
|
abortManager.touch([makeConfig('aborted')]);
|
||||||
await vi.advanceTimersByTimeAsync(50);
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
@@ -209,8 +201,6 @@ describe('AppliedFontsManager', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Phase 2: parse ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('Phase 2 — parse', () => {
|
describe('Phase 2 — parse', () => {
|
||||||
it('sets status to error on parse failure', async () => {
|
it('sets status to error on parse failure', async () => {
|
||||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
@@ -241,8 +231,6 @@ describe('AppliedFontsManager', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── #purgeUnused ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('#purgeUnused', () => {
|
describe('#purgeUnused', () => {
|
||||||
it('evicts fonts after TTL expires', async () => {
|
it('evicts fonts after TTL expires', async () => {
|
||||||
manager.touch([makeConfig('ephemeral')]);
|
manager.touch([makeConfig('ephemeral')]);
|
||||||
@@ -300,8 +288,6 @@ describe('AppliedFontsManager', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── destroy() ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('destroy()', () => {
|
describe('destroy()', () => {
|
||||||
it('clears all statuses', async () => {
|
it('clears all statuses', async () => {
|
||||||
manager.touch([makeConfig('roboto')]);
|
manager.touch([makeConfig('roboto')]);
|
||||||
+3
-1
@@ -1,4 +1,6 @@
|
|||||||
/** @vitest-environment jsdom */
|
/**
|
||||||
|
* @vitest-environment jsdom
|
||||||
|
*/
|
||||||
import { FontFetchError } from '../../errors';
|
import { FontFetchError } from '../../errors';
|
||||||
import { FontBufferCache } from './FontBufferCache';
|
import { FontBufferCache } from './FontBufferCache';
|
||||||
|
|
||||||
+12
-4
@@ -3,9 +3,13 @@ import { FontFetchError } from '../../errors';
|
|||||||
type Fetcher = (url: string, init?: RequestInit) => Promise<Response>;
|
type Fetcher = (url: string, init?: RequestInit) => Promise<Response>;
|
||||||
|
|
||||||
interface FontBufferCacheOptions {
|
interface FontBufferCacheOptions {
|
||||||
/** Custom fetch implementation. Defaults to `globalThis.fetch`. Inject in tests for isolation. */
|
/**
|
||||||
|
* Custom fetch implementation. Defaults to `globalThis.fetch`. Inject in tests for isolation.
|
||||||
|
*/
|
||||||
fetcher?: Fetcher;
|
fetcher?: Fetcher;
|
||||||
/** Cache API cache name. Defaults to `'font-cache-v1'`. */
|
/**
|
||||||
|
* Cache API cache name. Defaults to `'font-cache-v1'`.
|
||||||
|
*/
|
||||||
cacheName?: string;
|
cacheName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,12 +89,16 @@ export class FontBufferCache {
|
|||||||
return buffer;
|
return buffer;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Removes a URL from the in-memory cache. Next call to `get()` will re-fetch. */
|
/**
|
||||||
|
* Removes a URL from the in-memory cache. Next call to `get()` will re-fetch.
|
||||||
|
*/
|
||||||
evict(url: string): void {
|
evict(url: string): void {
|
||||||
this.#buffersByUrl.delete(url);
|
this.#buffersByUrl.delete(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Clears all in-memory cached buffers. */
|
/**
|
||||||
|
* Clears all in-memory cached buffers.
|
||||||
|
*/
|
||||||
clear(): void {
|
clear(): void {
|
||||||
this.#buffersByUrl.clear();
|
this.#buffersByUrl.clear();
|
||||||
}
|
}
|
||||||
+24
-7
@@ -1,5 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Default TTL after which an unpinned font is eligible for eviction.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_FONT_TTL_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
interface FontEvictionPolicyOptions {
|
interface FontEvictionPolicyOptions {
|
||||||
/** TTL in milliseconds. Defaults to 5 minutes. */
|
/**
|
||||||
|
* TTL in milliseconds. Defaults to {@link DEFAULT_FONT_TTL_MS}.
|
||||||
|
*/
|
||||||
ttl?: number;
|
ttl?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -15,7 +22,7 @@ export class FontEvictionPolicy {
|
|||||||
|
|
||||||
readonly #TTL: number;
|
readonly #TTL: number;
|
||||||
|
|
||||||
constructor({ ttl = 5 * 60 * 1000 }: FontEvictionPolicyOptions = {}) {
|
constructor({ ttl = DEFAULT_FONT_TTL_MS }: FontEvictionPolicyOptions = {}) {
|
||||||
this.#TTL = ttl;
|
this.#TTL = ttl;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,12 +35,16 @@ export class FontEvictionPolicy {
|
|||||||
this.#usageTracker.set(key, now);
|
this.#usageTracker.set(key, now);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Pins a font key so it is never evicted regardless of TTL. */
|
/**
|
||||||
|
* Pins a font key so it is never evicted regardless of TTL.
|
||||||
|
*/
|
||||||
pin(key: string): void {
|
pin(key: string): void {
|
||||||
this.#pinnedFonts.add(key);
|
this.#pinnedFonts.add(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Unpins a font key, allowing it to be evicted once its TTL expires. */
|
/**
|
||||||
|
* Unpins a font key, allowing it to be evicted once its TTL expires.
|
||||||
|
*/
|
||||||
unpin(key: string): void {
|
unpin(key: string): void {
|
||||||
this.#pinnedFonts.delete(key);
|
this.#pinnedFonts.delete(key);
|
||||||
}
|
}
|
||||||
@@ -57,18 +68,24 @@ export class FontEvictionPolicy {
|
|||||||
return now - lastUsed >= this.#TTL;
|
return now - lastUsed >= this.#TTL;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns an iterator over all tracked font keys. */
|
/**
|
||||||
|
* Returns an iterator over all tracked font keys.
|
||||||
|
*/
|
||||||
keys(): IterableIterator<string> {
|
keys(): IterableIterator<string> {
|
||||||
return this.#usageTracker.keys();
|
return this.#usageTracker.keys();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Removes a font key from tracking. Called by the orchestrator after eviction. */
|
/**
|
||||||
|
* Removes a font key from tracking. Called by the orchestrator after eviction.
|
||||||
|
*/
|
||||||
remove(key: string): void {
|
remove(key: string): void {
|
||||||
this.#usageTracker.delete(key);
|
this.#usageTracker.delete(key);
|
||||||
this.#pinnedFonts.delete(key);
|
this.#pinnedFonts.delete(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Clears all usage timestamps and pinned keys. */
|
/**
|
||||||
|
* Clears all usage timestamps and pinned keys.
|
||||||
|
*/
|
||||||
clear(): void {
|
clear(): void {
|
||||||
this.#usageTracker.clear();
|
this.#usageTracker.clear();
|
||||||
this.#pinnedFonts.clear();
|
this.#pinnedFonts.clear();
|
||||||
+19
-7
@@ -1,5 +1,11 @@
|
|||||||
import type { FontLoadRequestConfig } from '../../../../types';
|
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.
|
* Manages the font load queue and per-font retry counts.
|
||||||
*
|
*
|
||||||
@@ -10,8 +16,6 @@ export class FontLoadQueue {
|
|||||||
#queue = new Map<string, FontLoadRequestConfig>();
|
#queue = new Map<string, FontLoadRequestConfig>();
|
||||||
#retryCounts = new Map<string, number>();
|
#retryCounts = new Map<string, number>();
|
||||||
|
|
||||||
readonly #MAX_RETRIES = 3;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds a font to the queue.
|
* Adds a font to the queue.
|
||||||
* @returns `true` if the key was newly enqueued, `false` if it was already present.
|
* @returns `true` if the key was newly enqueued, `false` if it was already present.
|
||||||
@@ -34,22 +38,30 @@ export class FontLoadQueue {
|
|||||||
return entries;
|
return entries;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns `true` if the key is currently in the queue. */
|
/**
|
||||||
|
* Returns `true` if the key is currently in the queue.
|
||||||
|
*/
|
||||||
has(key: string): boolean {
|
has(key: string): boolean {
|
||||||
return this.#queue.has(key);
|
return this.#queue.has(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Increments the retry count for a font key. */
|
/**
|
||||||
|
* Increments the retry count for a font key.
|
||||||
|
*/
|
||||||
incrementRetry(key: string): void {
|
incrementRetry(key: string): void {
|
||||||
this.#retryCounts.set(key, (this.#retryCounts.get(key) ?? 0) + 1);
|
this.#retryCounts.set(key, (this.#retryCounts.get(key) ?? 0) + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns `true` if the font has reached or exceeded the maximum retry limit. */
|
/**
|
||||||
|
* Returns `true` if the font has reached or exceeded the maximum retry limit.
|
||||||
|
*/
|
||||||
isMaxRetriesReached(key: string): boolean {
|
isMaxRetriesReached(key: string): boolean {
|
||||||
return (this.#retryCounts.get(key) ?? 0) >= this.#MAX_RETRIES;
|
return (this.#retryCounts.get(key) ?? 0) >= FONT_LOAD_MAX_RETRIES;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Clears all queued fonts and resets all retry counts. */
|
/**
|
||||||
|
* Clears all queued fonts and resets all retry counts.
|
||||||
|
*/
|
||||||
clear(): void {
|
clear(): void {
|
||||||
this.#queue.clear();
|
this.#queue.clear();
|
||||||
this.#retryCounts.clear();
|
this.#retryCounts.clear();
|
||||||
+4
-2
@@ -1,4 +1,6 @@
|
|||||||
/** @vitest-environment jsdom */
|
/**
|
||||||
|
* @vitest-environment jsdom
|
||||||
|
*/
|
||||||
import { FontParseError } from '../../errors';
|
import { FontParseError } from '../../errors';
|
||||||
import { loadFont } from './loadFont';
|
import { loadFont } from './loadFont';
|
||||||
|
|
||||||
@@ -69,7 +71,7 @@ describe('loadFont', () => {
|
|||||||
it('throws FontParseError when font.load() rejects', async () => {
|
it('throws FontParseError when font.load() rejects', async () => {
|
||||||
const loadError = new Error('parse failed');
|
const loadError = new Error('parse failed');
|
||||||
const MockFontFace = vi.fn(
|
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);
|
this.load = vi.fn().mockRejectedValue(loadError);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
-2
@@ -2,9 +2,7 @@
|
|||||||
* Yields to main thread during CPU-intensive parsing. Uses scheduler.yield() where available or MessageChannel fallback.
|
* Yields to main thread during CPU-intensive parsing. Uses scheduler.yield() where available or MessageChannel fallback.
|
||||||
*/
|
*/
|
||||||
export async function yieldToMainThread(): Promise<void> {
|
export async function yieldToMainThread(): Promise<void> {
|
||||||
// @ts-expect-error - scheduler not in TypeScript lib yet
|
|
||||||
if (typeof scheduler !== 'undefined' && 'yield' in scheduler) {
|
if (typeof scheduler !== 'undefined' && 'yield' in scheduler) {
|
||||||
// @ts-expect-error - scheduler.yield not in TypeScript lib yet
|
|
||||||
await scheduler.yield();
|
await scheduler.yield();
|
||||||
} else {
|
} else {
|
||||||
await new Promise<void>(resolve => {
|
await new Promise<void>(resolve => {
|
||||||
@@ -1,283 +0,0 @@
|
|||||||
import { queryClient } from '$shared/api/queryClient';
|
|
||||||
import {
|
|
||||||
type InfiniteData,
|
|
||||||
InfiniteQueryObserver,
|
|
||||||
type InfiniteQueryObserverResult,
|
|
||||||
type QueryFunctionContext,
|
|
||||||
} from '@tanstack/query-core';
|
|
||||||
import {
|
|
||||||
type ProxyFontsParams,
|
|
||||||
type ProxyFontsResponse,
|
|
||||||
fetchProxyFonts,
|
|
||||||
} from '../../../api';
|
|
||||||
import {
|
|
||||||
FontNetworkError,
|
|
||||||
FontResponseError,
|
|
||||||
} from '../../../lib/errors/errors';
|
|
||||||
import type { UnifiedFont } from '../../types';
|
|
||||||
|
|
||||||
type PageParam = { offset: number };
|
|
||||||
|
|
||||||
/** Filter params + limit — offset is managed by TQ as a page param, not a user param. */
|
|
||||||
type FontStoreParams = Omit<ProxyFontsParams, 'offset'>;
|
|
||||||
|
|
||||||
type FontStoreResult = InfiniteQueryObserverResult<InfiniteData<ProxyFontsResponse, PageParam>, Error>;
|
|
||||||
|
|
||||||
export class FontStore {
|
|
||||||
#params = $state<FontStoreParams>({ limit: 50 });
|
|
||||||
#result = $state<FontStoreResult>({} as FontStoreResult);
|
|
||||||
#observer: InfiniteQueryObserver<
|
|
||||||
ProxyFontsResponse,
|
|
||||||
Error,
|
|
||||||
InfiniteData<ProxyFontsResponse, PageParam>,
|
|
||||||
readonly unknown[],
|
|
||||||
PageParam
|
|
||||||
>;
|
|
||||||
#qc = queryClient;
|
|
||||||
#unsubscribe: () => void;
|
|
||||||
|
|
||||||
constructor(params: FontStoreParams = {}) {
|
|
||||||
this.#params = { limit: 50, ...params };
|
|
||||||
this.#observer = new InfiniteQueryObserver(this.#qc, this.buildOptions());
|
|
||||||
this.#unsubscribe = this.#observer.subscribe(r => {
|
|
||||||
this.#result = r;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Public state --
|
|
||||||
|
|
||||||
get params(): FontStoreParams {
|
|
||||||
return this.#params;
|
|
||||||
}
|
|
||||||
get fonts(): UnifiedFont[] {
|
|
||||||
return this.#result.data?.pages.flatMap((p: ProxyFontsResponse) => p.fonts) ?? [];
|
|
||||||
}
|
|
||||||
get isLoading(): boolean {
|
|
||||||
return this.#result.isLoading;
|
|
||||||
}
|
|
||||||
get isFetching(): boolean {
|
|
||||||
return this.#result.isFetching;
|
|
||||||
}
|
|
||||||
get isError(): boolean {
|
|
||||||
return this.#result.isError;
|
|
||||||
}
|
|
||||||
|
|
||||||
get error(): Error | null {
|
|
||||||
return this.#result.error ?? null;
|
|
||||||
}
|
|
||||||
// isEmpty is false during loading/fetching so the UI never flashes "no results"
|
|
||||||
// while a fetch is in progress. The !isFetching guard is specifically for the filter-change
|
|
||||||
// transition: fonts clear synchronously → isFetching becomes true → isEmpty stays false.
|
|
||||||
get isEmpty(): boolean {
|
|
||||||
return !this.isLoading && !this.isFetching && this.fonts.length === 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
get pagination() {
|
|
||||||
const pages = this.#result.data?.pages;
|
|
||||||
const last = pages?.at(-1);
|
|
||||||
if (!last) {
|
|
||||||
return {
|
|
||||||
total: 0,
|
|
||||||
limit: this.#params.limit ?? 50,
|
|
||||||
offset: 0,
|
|
||||||
hasMore: false,
|
|
||||||
page: 1,
|
|
||||||
totalPages: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
total: last.total,
|
|
||||||
limit: last.limit,
|
|
||||||
offset: last.offset,
|
|
||||||
hasMore: this.#result.hasNextPage,
|
|
||||||
page: pages!.length,
|
|
||||||
totalPages: Math.ceil(last.total / last.limit),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Lifecycle --
|
|
||||||
|
|
||||||
destroy() {
|
|
||||||
this.#unsubscribe();
|
|
||||||
this.#observer.destroy();
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Param management --
|
|
||||||
|
|
||||||
setParams(updates: Partial<FontStoreParams>) {
|
|
||||||
this.#params = { ...this.#params, ...updates };
|
|
||||||
this.#observer.setOptions(this.buildOptions());
|
|
||||||
}
|
|
||||||
invalidate() {
|
|
||||||
this.#qc.invalidateQueries({ queryKey: this.buildQueryKey(this.#params) });
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Async operations --
|
|
||||||
|
|
||||||
async refetch() {
|
|
||||||
await this.#observer.refetch();
|
|
||||||
}
|
|
||||||
|
|
||||||
async prefetch(params: FontStoreParams) {
|
|
||||||
await this.#qc.prefetchInfiniteQuery(this.buildOptions(params));
|
|
||||||
}
|
|
||||||
|
|
||||||
cancel() {
|
|
||||||
this.#qc.cancelQueries({ queryKey: this.buildQueryKey(this.#params) });
|
|
||||||
}
|
|
||||||
|
|
||||||
getCachedData(): UnifiedFont[] | undefined {
|
|
||||||
const data = this.#qc.getQueryData<InfiniteData<ProxyFontsResponse, PageParam>>(
|
|
||||||
this.buildQueryKey(this.#params),
|
|
||||||
);
|
|
||||||
if (!data) return undefined;
|
|
||||||
return data.pages.flatMap(p => p.fonts);
|
|
||||||
}
|
|
||||||
|
|
||||||
setQueryData(updater: (old: UnifiedFont[] | undefined) => UnifiedFont[]) {
|
|
||||||
const key = this.buildQueryKey(this.#params);
|
|
||||||
this.#qc.setQueryData<InfiniteData<ProxyFontsResponse, PageParam>>(
|
|
||||||
key,
|
|
||||||
old => {
|
|
||||||
const flatFonts = old?.pages.flatMap(p => p.fonts);
|
|
||||||
const newFonts = updater(flatFonts);
|
|
||||||
// Re-distribute the updated fonts back into the existing page structure
|
|
||||||
// Define the first page. If old data exists, we merge into the first page template.
|
|
||||||
const limit = typeof this.#params.limit === 'number' ? this.#params.limit : 50;
|
|
||||||
const template = old?.pages[0] ?? {
|
|
||||||
total: newFonts.length,
|
|
||||||
limit,
|
|
||||||
offset: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
const updatedPage: ProxyFontsResponse = {
|
|
||||||
...template,
|
|
||||||
fonts: newFonts,
|
|
||||||
total: newFonts.length, // Synchronize total with the new font count
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
pages: [updatedPage],
|
|
||||||
pageParams: [{ offset: 0 }],
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Filter shortcuts --
|
|
||||||
|
|
||||||
setProviders(v: ProxyFontsParams['providers']) {
|
|
||||||
this.setParams({ providers: v });
|
|
||||||
}
|
|
||||||
setCategories(v: ProxyFontsParams['categories']) {
|
|
||||||
this.setParams({ categories: v });
|
|
||||||
}
|
|
||||||
setSubsets(v: ProxyFontsParams['subsets']) {
|
|
||||||
this.setParams({ subsets: v });
|
|
||||||
}
|
|
||||||
setSearch(v: string) {
|
|
||||||
this.setParams({ q: v || undefined });
|
|
||||||
}
|
|
||||||
setSort(v: ProxyFontsParams['sort']) {
|
|
||||||
this.setParams({ sort: v });
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Pagination navigation --
|
|
||||||
|
|
||||||
async nextPage(): Promise<void> {
|
|
||||||
await this.#observer.fetchNextPage();
|
|
||||||
}
|
|
||||||
prevPage(): void {} // no-op: infinite scroll accumulates forward only; method kept for API compatibility
|
|
||||||
goToPage(_page: number): void {} // no-op
|
|
||||||
|
|
||||||
setLimit(limit: number) {
|
|
||||||
this.setParams({ limit });
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Category views --
|
|
||||||
|
|
||||||
get sansSerifFonts() {
|
|
||||||
return this.fonts.filter(f => f.category === 'sans-serif');
|
|
||||||
}
|
|
||||||
get serifFonts() {
|
|
||||||
return this.fonts.filter(f => f.category === 'serif');
|
|
||||||
}
|
|
||||||
get displayFonts() {
|
|
||||||
return this.fonts.filter(f => f.category === 'display');
|
|
||||||
}
|
|
||||||
get handwritingFonts() {
|
|
||||||
return this.fonts.filter(f => f.category === 'handwriting');
|
|
||||||
}
|
|
||||||
get monospaceFonts() {
|
|
||||||
return this.fonts.filter(f => f.category === 'monospace');
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Private helpers (TypeScript-private so tests can spy via `as any`) --
|
|
||||||
|
|
||||||
private buildQueryKey(params: FontStoreParams): readonly unknown[] {
|
|
||||||
const filtered: Record<string, any> = {};
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(params)) {
|
|
||||||
// Ensure we DO NOT 'continue' or skip the limit key here.
|
|
||||||
// The limit is a fundamental part of the data identity.
|
|
||||||
if (
|
|
||||||
value !== undefined
|
|
||||||
&& value !== null
|
|
||||||
&& value !== ''
|
|
||||||
&& !(Array.isArray(value) && value.length === 0)
|
|
||||||
) {
|
|
||||||
filtered[key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ['fonts', filtered];
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildOptions(params = this.#params) {
|
|
||||||
const activeParams = { ...params };
|
|
||||||
const hasFilters = !!(
|
|
||||||
activeParams.q
|
|
||||||
|| (Array.isArray(activeParams.providers) && activeParams.providers.length > 0)
|
|
||||||
|| (Array.isArray(activeParams.categories) && activeParams.categories.length > 0)
|
|
||||||
|| (Array.isArray(activeParams.subsets) && activeParams.subsets.length > 0)
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
queryKey: this.buildQueryKey(activeParams),
|
|
||||||
queryFn: ({ pageParam }: QueryFunctionContext<readonly unknown[], PageParam>) =>
|
|
||||||
this.fetchPage({ ...activeParams, ...pageParam }),
|
|
||||||
initialPageParam: { offset: 0 } as PageParam,
|
|
||||||
getNextPageParam: (lastPage: ProxyFontsResponse): PageParam | undefined => {
|
|
||||||
const next = lastPage.offset + lastPage.limit;
|
|
||||||
return next < lastPage.total ? { offset: next } : undefined;
|
|
||||||
},
|
|
||||||
staleTime: hasFilters ? 0 : 5 * 60 * 1000,
|
|
||||||
gcTime: 10 * 60 * 1000,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private async fetchPage(params: ProxyFontsParams): Promise<ProxyFontsResponse> {
|
|
||||||
let response: ProxyFontsResponse;
|
|
||||||
try {
|
|
||||||
response = await fetchProxyFonts(params);
|
|
||||||
} catch (cause) {
|
|
||||||
throw new FontNetworkError(cause);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response) throw new FontResponseError('response', response);
|
|
||||||
if (!response.fonts) throw new FontResponseError('response.fonts', response.fonts);
|
|
||||||
if (!Array.isArray(response.fonts)) throw new FontResponseError('response.fonts', response.fonts);
|
|
||||||
|
|
||||||
return {
|
|
||||||
fonts: response.fonts,
|
|
||||||
total: response.total ?? 0,
|
|
||||||
limit: response.limit ?? params.limit ?? 50,
|
|
||||||
offset: response.offset ?? params.offset ?? 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createFontStore(params: FontStoreParams = {}): FontStore {
|
|
||||||
return new FontStore(params);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const fontStore = new FontStore({ limit: 50 });
|
|
||||||
+10
-9
@@ -1,21 +1,23 @@
|
|||||||
import { fontKeys } from '$shared/api/queryKeys';
|
import { fontKeys } from '$shared/api/queryKeys';
|
||||||
import { BaseQueryStore } from '$shared/lib/helpers/BaseQueryStore.svelte';
|
import { BaseQueryStore } from '$shared/lib/helpers/BaseQueryStore/BaseQueryStore.svelte';
|
||||||
import {
|
import {
|
||||||
fetchFontsByIds,
|
fetchFontsByIds,
|
||||||
seedFontCache,
|
seedFontCache,
|
||||||
} from '../../api/proxy/proxyFonts';
|
} from '../../../api/proxy/proxyFonts';
|
||||||
import {
|
import {
|
||||||
FontNetworkError,
|
FontNetworkError,
|
||||||
FontResponseError,
|
FontResponseError,
|
||||||
} from '../../lib/errors/errors';
|
} from '../../../lib/errors/errors';
|
||||||
import type { UnifiedFont } from '../../model/types';
|
import type { UnifiedFont } from '../../types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Internal fetcher that seeds the cache and handles error wrapping.
|
* Internal fetcher that seeds the cache and handles error wrapping.
|
||||||
* Standalone function to avoid 'this' issues during construction.
|
* Standalone function to avoid 'this' issues during construction.
|
||||||
*/
|
*/
|
||||||
async function fetchAndSeed(ids: string[]): Promise<UnifiedFont[]> {
|
async function fetchAndSeed(ids: string[]): Promise<UnifiedFont[]> {
|
||||||
if (ids.length === 0) return [];
|
if (ids.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
let response: UnifiedFont[];
|
let response: UnifiedFont[];
|
||||||
try {
|
try {
|
||||||
@@ -33,11 +35,10 @@ async function fetchAndSeed(ids: string[]): Promise<UnifiedFont[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reactive store for fetching and caching batches of fonts by ID.
|
* Reactive store for fetching specific fonts by ID via the proxy batch endpoint.
|
||||||
* Integrates with TanStack Query via BaseQueryStore and handles
|
* Wraps TanStack Query and seeds the detail cache for sibling consumers.
|
||||||
* normalized cache seeding.
|
|
||||||
*/
|
*/
|
||||||
export class BatchFontStore extends BaseQueryStore<UnifiedFont[]> {
|
export class FontsByIdsStore extends BaseQueryStore<UnifiedFont[]> {
|
||||||
constructor(initialIds: string[] = []) {
|
constructor(initialIds: string[] = []) {
|
||||||
super({
|
super({
|
||||||
queryKey: fontKeys.batch(initialIds),
|
queryKey: fontKeys.batch(initialIds),
|
||||||
+15
-13
@@ -1,4 +1,6 @@
|
|||||||
import { queryClient } from '$shared/api/queryClient';
|
import { getQueryClient } from '$shared/api/queryClient';
|
||||||
|
|
||||||
|
const queryClient = getQueryClient();
|
||||||
import { fontKeys } from '$shared/api/queryKeys';
|
import { fontKeys } from '$shared/api/queryKeys';
|
||||||
import {
|
import {
|
||||||
beforeEach,
|
beforeEach,
|
||||||
@@ -7,14 +9,14 @@ import {
|
|||||||
it,
|
it,
|
||||||
vi,
|
vi,
|
||||||
} from 'vitest';
|
} from 'vitest';
|
||||||
import * as api from '../../api/proxy/proxyFonts';
|
import * as api from '../../../api/proxy/proxyFonts';
|
||||||
import {
|
import {
|
||||||
FontNetworkError,
|
FontNetworkError,
|
||||||
FontResponseError,
|
FontResponseError,
|
||||||
} from '../../lib/errors/errors';
|
} from '../../../lib/errors/errors';
|
||||||
import { BatchFontStore } from './batchFontStore.svelte';
|
import { FontsByIdsStore } from './fontsByIdsStore.svelte';
|
||||||
|
|
||||||
describe('BatchFontStore', () => {
|
describe('FontsByIdsStore', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
queryClient.clear();
|
queryClient.clear();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -23,7 +25,7 @@ describe('BatchFontStore', () => {
|
|||||||
describe('Fetch Behavior', () => {
|
describe('Fetch Behavior', () => {
|
||||||
it('should skip fetch when initialized with empty IDs', async () => {
|
it('should skip fetch when initialized with empty IDs', async () => {
|
||||||
const spy = vi.spyOn(api, 'fetchFontsByIds');
|
const spy = vi.spyOn(api, 'fetchFontsByIds');
|
||||||
const store = new BatchFontStore([]);
|
const store = new FontsByIdsStore([]);
|
||||||
expect(spy).not.toHaveBeenCalled();
|
expect(spy).not.toHaveBeenCalled();
|
||||||
expect(store.fonts).toEqual([]);
|
expect(store.fonts).toEqual([]);
|
||||||
});
|
});
|
||||||
@@ -31,7 +33,7 @@ describe('BatchFontStore', () => {
|
|||||||
it('should fetch and seed cache for valid IDs', async () => {
|
it('should fetch and seed cache for valid IDs', async () => {
|
||||||
const fonts = [{ id: 'a', name: 'A' }] as any[];
|
const fonts = [{ id: 'a', name: 'A' }] as any[];
|
||||||
vi.spyOn(api, 'fetchFontsByIds').mockResolvedValue(fonts);
|
vi.spyOn(api, 'fetchFontsByIds').mockResolvedValue(fonts);
|
||||||
const store = new BatchFontStore(['a']);
|
const store = new FontsByIdsStore(['a']);
|
||||||
await vi.waitFor(() => expect(store.fonts).toEqual(fonts), { timeout: 1000 });
|
await vi.waitFor(() => expect(store.fonts).toEqual(fonts), { timeout: 1000 });
|
||||||
expect(queryClient.getQueryData(fontKeys.detail('a'))).toEqual(fonts[0]);
|
expect(queryClient.getQueryData(fontKeys.detail('a'))).toEqual(fonts[0]);
|
||||||
});
|
});
|
||||||
@@ -42,7 +44,7 @@ describe('BatchFontStore', () => {
|
|||||||
vi.spyOn(api, 'fetchFontsByIds').mockImplementation(() =>
|
vi.spyOn(api, 'fetchFontsByIds').mockImplementation(() =>
|
||||||
new Promise(r => setTimeout(() => r([{ id: 'a' }] as any), 50))
|
new Promise(r => setTimeout(() => r([{ id: 'a' }] as any), 50))
|
||||||
);
|
);
|
||||||
const store = new BatchFontStore(['a']);
|
const store = new FontsByIdsStore(['a']);
|
||||||
expect(store.isLoading).toBe(true);
|
expect(store.isLoading).toBe(true);
|
||||||
await vi.waitFor(() => expect(store.isLoading).toBe(false), { timeout: 1000 });
|
await vi.waitFor(() => expect(store.isLoading).toBe(false), { timeout: 1000 });
|
||||||
});
|
});
|
||||||
@@ -51,7 +53,7 @@ describe('BatchFontStore', () => {
|
|||||||
describe('Error Handling', () => {
|
describe('Error Handling', () => {
|
||||||
it('should wrap network failures in FontNetworkError', async () => {
|
it('should wrap network failures in FontNetworkError', async () => {
|
||||||
vi.spyOn(api, 'fetchFontsByIds').mockRejectedValue(new Error('Network fail'));
|
vi.spyOn(api, 'fetchFontsByIds').mockRejectedValue(new Error('Network fail'));
|
||||||
const store = new BatchFontStore(['a']);
|
const store = new FontsByIdsStore(['a']);
|
||||||
await vi.waitFor(() => expect(store.isError).toBe(true), { timeout: 1000 });
|
await vi.waitFor(() => expect(store.isError).toBe(true), { timeout: 1000 });
|
||||||
expect(store.error).toBeInstanceOf(FontNetworkError);
|
expect(store.error).toBeInstanceOf(FontNetworkError);
|
||||||
});
|
});
|
||||||
@@ -59,7 +61,7 @@ describe('BatchFontStore', () => {
|
|||||||
it('should handle malformed API responses with FontResponseError', async () => {
|
it('should handle malformed API responses with FontResponseError', async () => {
|
||||||
// Mocking a malformed response that the store should validate
|
// Mocking a malformed response that the store should validate
|
||||||
vi.spyOn(api, 'fetchFontsByIds').mockResolvedValue(null as any);
|
vi.spyOn(api, 'fetchFontsByIds').mockResolvedValue(null as any);
|
||||||
const store = new BatchFontStore(['a']);
|
const store = new FontsByIdsStore(['a']);
|
||||||
await vi.waitFor(() => expect(store.isError).toBe(true), { timeout: 1000 });
|
await vi.waitFor(() => expect(store.isError).toBe(true), { timeout: 1000 });
|
||||||
expect(store.error).toBeInstanceOf(FontResponseError);
|
expect(store.error).toBeInstanceOf(FontResponseError);
|
||||||
});
|
});
|
||||||
@@ -67,7 +69,7 @@ describe('BatchFontStore', () => {
|
|||||||
it('should have null error in success state', async () => {
|
it('should have null error in success state', async () => {
|
||||||
const fonts = [{ id: 'a' }] as any[];
|
const fonts = [{ id: 'a' }] as any[];
|
||||||
vi.spyOn(api, 'fetchFontsByIds').mockResolvedValue(fonts);
|
vi.spyOn(api, 'fetchFontsByIds').mockResolvedValue(fonts);
|
||||||
const store = new BatchFontStore(['a']);
|
const store = new FontsByIdsStore(['a']);
|
||||||
await vi.waitFor(() => expect(store.fonts).toEqual(fonts), { timeout: 1000 });
|
await vi.waitFor(() => expect(store.fonts).toEqual(fonts), { timeout: 1000 });
|
||||||
expect(store.error).toBeNull();
|
expect(store.error).toBeNull();
|
||||||
});
|
});
|
||||||
@@ -78,7 +80,7 @@ describe('BatchFontStore', () => {
|
|||||||
const fonts1 = [{ id: 'a' }] as any[];
|
const fonts1 = [{ id: 'a' }] as any[];
|
||||||
const spy = vi.spyOn(api, 'fetchFontsByIds').mockResolvedValueOnce(fonts1);
|
const spy = vi.spyOn(api, 'fetchFontsByIds').mockResolvedValueOnce(fonts1);
|
||||||
|
|
||||||
const store = new BatchFontStore(['a']);
|
const store = new FontsByIdsStore(['a']);
|
||||||
await vi.waitFor(() => expect(store.fonts).toEqual(fonts1), { timeout: 1000 });
|
await vi.waitFor(() => expect(store.fonts).toEqual(fonts1), { timeout: 1000 });
|
||||||
|
|
||||||
spy.mockClear();
|
spy.mockClear();
|
||||||
@@ -97,7 +99,7 @@ describe('BatchFontStore', () => {
|
|||||||
.mockResolvedValueOnce(fonts1)
|
.mockResolvedValueOnce(fonts1)
|
||||||
.mockResolvedValueOnce(fonts2);
|
.mockResolvedValueOnce(fonts2);
|
||||||
|
|
||||||
const store = new BatchFontStore(['a']);
|
const store = new FontsByIdsStore(['a']);
|
||||||
await vi.waitFor(() => expect(store.fonts).toEqual(fonts1), { timeout: 1000 });
|
await vi.waitFor(() => expect(store.fonts).toEqual(fonts1), { timeout: 1000 });
|
||||||
|
|
||||||
store.setIds(['b']);
|
store.setIds(['b']);
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
// Applied fonts manager
|
// Font lifecycle manager (browser-side load + cache + eviction)
|
||||||
export * from './appliedFontsStore/appliedFontsStore.svelte';
|
|
||||||
|
|
||||||
// Batch font store
|
|
||||||
export { BatchFontStore } from './batchFontStore.svelte';
|
|
||||||
|
|
||||||
// Single FontStore
|
|
||||||
export {
|
export {
|
||||||
createFontStore,
|
__resetFontLifecycleManager,
|
||||||
FontStore,
|
FontLifecycleManager,
|
||||||
fontStore,
|
getFontLifecycleManager,
|
||||||
} from './fontStore/fontStore.svelte';
|
} 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';
|
||||||
|
|||||||
@@ -31,18 +31,28 @@ export type FontSubset = 'latin' | 'latin-ext' | 'cyrillic' | 'greek' | 'arabic'
|
|||||||
* Combined filter state for font queries
|
* Combined filter state for font queries
|
||||||
*/
|
*/
|
||||||
export interface FontFilters {
|
export interface FontFilters {
|
||||||
/** Selected font providers */
|
/**
|
||||||
|
* Active font providers to fetch from
|
||||||
|
*/
|
||||||
providers: FontProvider[];
|
providers: FontProvider[];
|
||||||
/** Selected font categories */
|
/**
|
||||||
|
* Visual classifications (sans, serif, etc.)
|
||||||
|
*/
|
||||||
categories: FontCategory[];
|
categories: FontCategory[];
|
||||||
/** Selected character subsets */
|
/**
|
||||||
|
* Character sets required for the sample text
|
||||||
|
*/
|
||||||
subsets: FontSubset[];
|
subsets: FontSubset[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Filter group identifier */
|
/**
|
||||||
|
* Filter group identifier
|
||||||
|
*/
|
||||||
export type FilterGroup = 'providers' | 'categories' | 'subsets';
|
export type FilterGroup = 'providers' | 'categories' | 'subsets';
|
||||||
|
|
||||||
/** Filter type including search query */
|
/**
|
||||||
|
* Filter type including search query
|
||||||
|
*/
|
||||||
export type FilterType = FilterGroup | 'searchQuery';
|
export type FilterType = FilterGroup | 'searchQuery';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -80,15 +90,25 @@ export type UnifiedFontVariant = FontVariant;
|
|||||||
* Font style URLs
|
* Font style URLs
|
||||||
*/
|
*/
|
||||||
export interface FontStyleUrls {
|
export interface FontStyleUrls {
|
||||||
/** Regular weight URL */
|
/**
|
||||||
|
* URL for the regular (400) weight
|
||||||
|
*/
|
||||||
regular?: string;
|
regular?: string;
|
||||||
/** Italic URL */
|
/**
|
||||||
|
* URL for the italic (400) style
|
||||||
|
*/
|
||||||
italic?: string;
|
italic?: string;
|
||||||
/** Bold weight URL */
|
/**
|
||||||
|
* URL for the bold (700) weight
|
||||||
|
*/
|
||||||
bold?: string;
|
bold?: string;
|
||||||
/** Bold italic URL */
|
/**
|
||||||
|
* URL for the bold-italic (700) style
|
||||||
|
*/
|
||||||
boldItalic?: string;
|
boldItalic?: string;
|
||||||
/** Additional variant mapping */
|
/**
|
||||||
|
* Mapping for all other numeric/custom variants
|
||||||
|
*/
|
||||||
variants?: Partial<Record<UnifiedFontVariant, string>>;
|
variants?: Partial<Record<UnifiedFontVariant, string>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,19 +116,24 @@ export interface FontStyleUrls {
|
|||||||
* Font metadata
|
* Font metadata
|
||||||
*/
|
*/
|
||||||
export interface FontMetadata {
|
export interface FontMetadata {
|
||||||
/** Timestamp when font was cached */
|
/**
|
||||||
|
* Epoch timestamp of last successful fetch
|
||||||
|
*/
|
||||||
cachedAt: number;
|
cachedAt: number;
|
||||||
/** Font version from provider */
|
/**
|
||||||
|
* Semantic version string from upstream
|
||||||
|
*/
|
||||||
version?: string;
|
version?: string;
|
||||||
/** Last modified date from provider */
|
/**
|
||||||
|
* ISO date string of last remote update
|
||||||
|
*/
|
||||||
lastModified?: string;
|
lastModified?: string;
|
||||||
/** Popularity rank (if available from provider) */
|
/**
|
||||||
|
* Raw ranking integer from provider
|
||||||
|
*/
|
||||||
popularity?: number;
|
popularity?: number;
|
||||||
/**
|
/**
|
||||||
* Normalized popularity score (0-100)
|
* Normalized score (0-100) used for global sorting
|
||||||
*
|
|
||||||
* Normalized across all fonts for consistent ranking
|
|
||||||
* Higher values indicate more popular fonts
|
|
||||||
*/
|
*/
|
||||||
popularityScore?: number;
|
popularityScore?: number;
|
||||||
}
|
}
|
||||||
@@ -117,17 +142,38 @@ export interface FontMetadata {
|
|||||||
* Font features (variable fonts, axes, tags)
|
* Font features (variable fonts, axes, tags)
|
||||||
*/
|
*/
|
||||||
export interface FontFeatures {
|
export interface FontFeatures {
|
||||||
/** Whether this is a variable font */
|
/**
|
||||||
|
* Whether the font supports fluid weight/width axes
|
||||||
|
*/
|
||||||
isVariable?: boolean;
|
isVariable?: boolean;
|
||||||
/** Variable font axes (for Fontshare) */
|
/**
|
||||||
|
* Definable axes for variable font interpolation
|
||||||
|
*/
|
||||||
axes?: Array<{
|
axes?: Array<{
|
||||||
|
/**
|
||||||
|
* Human-readable axis name (e.g., 'Weight')
|
||||||
|
*/
|
||||||
name: string;
|
name: string;
|
||||||
|
/**
|
||||||
|
* CSS property name (e.g., 'wght')
|
||||||
|
*/
|
||||||
property: string;
|
property: string;
|
||||||
|
/**
|
||||||
|
* Default numeric value for the axis
|
||||||
|
*/
|
||||||
default: number;
|
default: number;
|
||||||
|
/**
|
||||||
|
* Minimum inclusive bound
|
||||||
|
*/
|
||||||
min: number;
|
min: number;
|
||||||
|
/**
|
||||||
|
* Maximum inclusive bound
|
||||||
|
*/
|
||||||
max: number;
|
max: number;
|
||||||
}>;
|
}>;
|
||||||
/** Usage tags (for Fontshare) */
|
/**
|
||||||
|
* Descriptive keywords for search indexing
|
||||||
|
*/
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,29 +184,44 @@ export interface FontFeatures {
|
|||||||
* for consistent font handling across the application.
|
* for consistent font handling across the application.
|
||||||
*/
|
*/
|
||||||
export interface UnifiedFont {
|
export interface UnifiedFont {
|
||||||
/** Unique identifier (Google: family name, Fontshare: slug) */
|
/**
|
||||||
|
* Unique ID (family name for Google, slug for Fontshare)
|
||||||
|
*/
|
||||||
id: string;
|
id: string;
|
||||||
/** Font display name */
|
/**
|
||||||
|
* Canonical family name for CSS font-family
|
||||||
|
*/
|
||||||
name: string;
|
name: string;
|
||||||
/** Font provider (google | fontshare) */
|
/**
|
||||||
|
* Upstream data source
|
||||||
|
*/
|
||||||
provider: FontProvider;
|
provider: FontProvider;
|
||||||
/**
|
/**
|
||||||
* Provider badge display name
|
* Display label for provider badges
|
||||||
*
|
|
||||||
* Human-readable provider name for UI display
|
|
||||||
* e.g., "Google Fonts" or "Fontshare"
|
|
||||||
*/
|
*/
|
||||||
providerBadge?: string;
|
providerBadge?: string;
|
||||||
/** Font category classification */
|
/**
|
||||||
|
* Primary typographic category
|
||||||
|
*/
|
||||||
category: FontCategory;
|
category: FontCategory;
|
||||||
/** Supported character subsets */
|
/**
|
||||||
|
* All supported character sets
|
||||||
|
*/
|
||||||
subsets: FontSubset[];
|
subsets: FontSubset[];
|
||||||
/** Available font variants (weights, styles) */
|
/**
|
||||||
|
* List of available weights and styles
|
||||||
|
*/
|
||||||
variants: UnifiedFontVariant[];
|
variants: UnifiedFontVariant[];
|
||||||
/** URL mapping for font file downloads */
|
/**
|
||||||
|
* Remote assets for font loading
|
||||||
|
*/
|
||||||
styles: FontStyleUrls;
|
styles: FontStyleUrls;
|
||||||
/** Additional metadata */
|
/**
|
||||||
|
* Technical metadata and rankings
|
||||||
|
*/
|
||||||
metadata: FontMetadata;
|
metadata: FontMetadata;
|
||||||
/** Advanced font features */
|
/**
|
||||||
|
* Variable font details and tags
|
||||||
|
*/
|
||||||
features: FontFeatures;
|
features: FontFeatures;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,3 @@
|
|||||||
/**
|
|
||||||
* ============================================================================
|
|
||||||
* SINGLE EXPORT POINT
|
|
||||||
* ============================================================================
|
|
||||||
*
|
|
||||||
* This is the single export point for all Font types.
|
|
||||||
* All imports should use: `import { X } from '$entities/Font/model/types'`
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Font domain and model types
|
// Font domain and model types
|
||||||
export type {
|
export type {
|
||||||
FilterGroup,
|
FilterGroup,
|
||||||
@@ -32,5 +23,7 @@ export type {
|
|||||||
FontCollectionState,
|
FontCollectionState,
|
||||||
} from './store';
|
} from './store';
|
||||||
|
|
||||||
export * from './store/appliedFonts';
|
export type {
|
||||||
export * from './typography';
|
FontLoadRequestConfig,
|
||||||
|
FontLoadStatus,
|
||||||
|
} from './store/fontLifecycle';
|
||||||
|
|||||||
@@ -1,9 +1,3 @@
|
|||||||
/**
|
|
||||||
* ============================================================================
|
|
||||||
* STORE TYPES
|
|
||||||
* ============================================================================
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
FontCategory,
|
FontCategory,
|
||||||
FontProvider,
|
FontProvider,
|
||||||
@@ -12,37 +6,55 @@ import type {
|
|||||||
} from './font';
|
} from './font';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Font collection state
|
* Global state for the local font collection
|
||||||
*/
|
*/
|
||||||
export interface FontCollectionState {
|
export interface FontCollectionState {
|
||||||
/** All cached fonts */
|
/**
|
||||||
|
* Map of cached fonts indexed by their unique family ID
|
||||||
|
*/
|
||||||
fonts: Record<string, UnifiedFont>;
|
fonts: Record<string, UnifiedFont>;
|
||||||
/** Active filters */
|
/**
|
||||||
|
* Set of active user-defined filters
|
||||||
|
*/
|
||||||
filters: FontCollectionFilters;
|
filters: FontCollectionFilters;
|
||||||
/** Sort configuration */
|
/**
|
||||||
|
* Current sorting parameters for the display list
|
||||||
|
*/
|
||||||
sort: FontCollectionSort;
|
sort: FontCollectionSort;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Font collection filters
|
* Filter configuration for narrow collections
|
||||||
*/
|
*/
|
||||||
export interface FontCollectionFilters {
|
export interface FontCollectionFilters {
|
||||||
/** Search query */
|
/**
|
||||||
|
* Partial family name to match against
|
||||||
|
*/
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
/** Filter by providers */
|
/**
|
||||||
|
* Data sources (Google, Fontshare) to include
|
||||||
|
*/
|
||||||
providers?: FontProvider[];
|
providers?: FontProvider[];
|
||||||
/** Filter by categories */
|
/**
|
||||||
|
* Typographic categories (Serif, Sans, etc.) to include
|
||||||
|
*/
|
||||||
categories?: FontCategory[];
|
categories?: FontCategory[];
|
||||||
/** Filter by subsets */
|
/**
|
||||||
|
* Character sets (Latin, Cyrillic, etc.) to include
|
||||||
|
*/
|
||||||
subsets?: FontSubset[];
|
subsets?: FontSubset[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Font collection sort configuration
|
* Ordering configuration for the font list
|
||||||
*/
|
*/
|
||||||
export interface FontCollectionSort {
|
export interface FontCollectionSort {
|
||||||
/** Sort field */
|
/**
|
||||||
|
* The font property to order by
|
||||||
|
*/
|
||||||
field: 'name' | 'popularity' | 'category';
|
field: 'name' | 'popularity' | 'category';
|
||||||
/** Sort direction */
|
/**
|
||||||
|
* The sort order (Ascending or Descending)
|
||||||
|
*/
|
||||||
direction: 'asc' | 'desc';
|
direction: 'asc' | 'desc';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export type ControlId = 'font_size' | 'font_weight' | 'line_height' | 'letter_spacing';
|
|
||||||
+30
-52
@@ -1,31 +1,3 @@
|
|||||||
/**
|
|
||||||
* Mock font filter data
|
|
||||||
*
|
|
||||||
* Factory functions and preset mock data for font-related filters.
|
|
||||||
* Used in Storybook stories for font filtering components.
|
|
||||||
*
|
|
||||||
* ## Usage
|
|
||||||
*
|
|
||||||
* ```ts
|
|
||||||
* import {
|
|
||||||
* createMockFilter,
|
|
||||||
* MOCK_FILTERS,
|
|
||||||
* } from '$entities/Font/lib/mocks';
|
|
||||||
*
|
|
||||||
* // Create a custom filter
|
|
||||||
* const customFilter = createMockFilter({
|
|
||||||
* properties: [
|
|
||||||
* { id: 'option1', name: 'Option 1', value: 'option1' },
|
|
||||||
* { id: 'option2', name: 'Option 2', value: 'option2', selected: true },
|
|
||||||
* ],
|
|
||||||
* });
|
|
||||||
*
|
|
||||||
* // Use preset filters
|
|
||||||
* const categoriesFilter = MOCK_FILTERS.categories;
|
|
||||||
* const subsetsFilter = MOCK_FILTERS.subsets;
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
FontCategory,
|
FontCategory,
|
||||||
FontProvider,
|
FontProvider,
|
||||||
@@ -34,13 +6,13 @@ import type {
|
|||||||
import type { Property } from '$shared/lib';
|
import type { Property } from '$shared/lib';
|
||||||
import { createFilter } from '$shared/lib';
|
import { createFilter } from '$shared/lib';
|
||||||
|
|
||||||
// TYPE DEFINITIONS
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Options for creating a mock filter
|
* Options for creating a mock filter
|
||||||
*/
|
*/
|
||||||
export interface MockFilterOptions {
|
export interface MockFilterOptions {
|
||||||
/** Filter properties */
|
/**
|
||||||
|
* Initial set of properties for the mock filter
|
||||||
|
*/
|
||||||
properties: Property<string>[];
|
properties: Property<string>[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,16 +20,20 @@ export interface MockFilterOptions {
|
|||||||
* Preset mock filters for font filtering
|
* Preset mock filters for font filtering
|
||||||
*/
|
*/
|
||||||
export interface MockFilters {
|
export interface MockFilters {
|
||||||
/** Provider filter (Google, Fontshare) */
|
/**
|
||||||
|
* Provider filter (Google, Fontshare)
|
||||||
|
*/
|
||||||
providers: ReturnType<typeof createFilter<'google' | 'fontshare'>>;
|
providers: ReturnType<typeof createFilter<'google' | 'fontshare'>>;
|
||||||
/** Category filter (sans-serif, serif, display, etc.) */
|
/**
|
||||||
|
* Category filter (sans-serif, serif, display, etc.)
|
||||||
|
*/
|
||||||
categories: ReturnType<typeof createFilter<FontCategory>>;
|
categories: ReturnType<typeof createFilter<FontCategory>>;
|
||||||
/** Subset filter (latin, latin-ext, cyrillic, etc.) */
|
/**
|
||||||
|
* Subset filter (latin, latin-ext, cyrillic, etc.)
|
||||||
|
*/
|
||||||
subsets: ReturnType<typeof createFilter<FontSubset>>;
|
subsets: ReturnType<typeof createFilter<FontSubset>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// FONT CATEGORIES
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unified categories (combines both providers)
|
* Unified categories (combines both providers)
|
||||||
*/
|
*/
|
||||||
@@ -71,8 +47,6 @@ export const UNIFIED_CATEGORIES: Property<FontCategory>[] = [
|
|||||||
{ id: 'script', name: 'Script', value: 'script' },
|
{ id: 'script', name: 'Script', value: 'script' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// FONT SUBSETS
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Common font subsets
|
* Common font subsets
|
||||||
*/
|
*/
|
||||||
@@ -85,8 +59,6 @@ export const FONT_SUBSETS: Property<FontSubset>[] = [
|
|||||||
{ id: 'devanagari', name: 'Devanagari', value: 'devanagari' },
|
{ id: 'devanagari', name: 'Devanagari', value: 'devanagari' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// FONT PROVIDERS
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Font providers
|
* Font providers
|
||||||
*/
|
*/
|
||||||
@@ -95,8 +67,6 @@ export const FONT_PROVIDERS: Property<FontProvider>[] = [
|
|||||||
{ id: 'fontshare', name: 'Fontshare', value: 'fontshare' },
|
{ id: 'fontshare', name: 'Fontshare', value: 'fontshare' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// FILTER FACTORIES
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a mock filter from properties
|
* Create a mock filter from properties
|
||||||
*/
|
*/
|
||||||
@@ -139,8 +109,6 @@ export function createProvidersFilter(options?: { selected?: FontProvider[] }) {
|
|||||||
return createFilter<FontProvider>({ properties });
|
return createFilter<FontProvider>({ properties });
|
||||||
}
|
}
|
||||||
|
|
||||||
// PRESET FILTERS
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Preset mock filters - use these directly in stories
|
* Preset mock filters - use these directly in stories
|
||||||
*/
|
*/
|
||||||
@@ -216,8 +184,6 @@ export const MOCK_FILTERS_ALL_SELECTED: MockFilters = {
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
// GENERIC FILTER MOCKS
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a mock filter with generic string properties
|
* Create a mock filter with generic string properties
|
||||||
* Useful for testing generic filter components
|
* Useful for testing generic filter components
|
||||||
@@ -239,7 +205,9 @@ export function createGenericFilter(
|
|||||||
* Preset generic filters for testing
|
* Preset generic filters for testing
|
||||||
*/
|
*/
|
||||||
export const GENERIC_FILTERS = {
|
export const GENERIC_FILTERS = {
|
||||||
/** Small filter with 3 items */
|
/**
|
||||||
|
* Small filter with 3 items
|
||||||
|
*/
|
||||||
small: createFilter({
|
small: createFilter({
|
||||||
properties: [
|
properties: [
|
||||||
{ id: 'option-1', name: 'Option 1', value: 'option-1' },
|
{ id: 'option-1', name: 'Option 1', value: 'option-1' },
|
||||||
@@ -247,7 +215,9 @@ export const GENERIC_FILTERS = {
|
|||||||
{ id: 'option-3', name: 'Option 3', value: 'option-3' },
|
{ id: 'option-3', name: 'Option 3', value: 'option-3' },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
/** Medium filter with 6 items */
|
/**
|
||||||
|
* Medium filter with 6 items
|
||||||
|
*/
|
||||||
medium: createFilter({
|
medium: createFilter({
|
||||||
properties: [
|
properties: [
|
||||||
{ id: 'alpha', name: 'Alpha', value: 'alpha' },
|
{ id: 'alpha', name: 'Alpha', value: 'alpha' },
|
||||||
@@ -258,7 +228,9 @@ export const GENERIC_FILTERS = {
|
|||||||
{ id: 'zeta', name: 'Zeta', value: 'zeta' },
|
{ id: 'zeta', name: 'Zeta', value: 'zeta' },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
/** Large filter with 12 items */
|
/**
|
||||||
|
* Large filter with 12 items
|
||||||
|
*/
|
||||||
large: createFilter({
|
large: createFilter({
|
||||||
properties: [
|
properties: [
|
||||||
{ id: 'jan', name: 'January', value: 'jan' },
|
{ id: 'jan', name: 'January', value: 'jan' },
|
||||||
@@ -275,7 +247,9 @@ export const GENERIC_FILTERS = {
|
|||||||
{ id: 'dec', name: 'December', value: 'dec' },
|
{ id: 'dec', name: 'December', value: 'dec' },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
/** Filter with some pre-selected items */
|
/**
|
||||||
|
* Filter with some pre-selected items
|
||||||
|
*/
|
||||||
partial: createFilter({
|
partial: createFilter({
|
||||||
properties: [
|
properties: [
|
||||||
{ id: 'red', name: 'Red', value: 'red', selected: true },
|
{ id: 'red', name: 'Red', value: 'red', selected: true },
|
||||||
@@ -284,7 +258,9 @@ export const GENERIC_FILTERS = {
|
|||||||
{ id: 'yellow', name: 'Yellow', value: 'yellow', selected: false },
|
{ id: 'yellow', name: 'Yellow', value: 'yellow', selected: false },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
/** Filter with all items selected */
|
/**
|
||||||
|
* Filter with all items selected
|
||||||
|
*/
|
||||||
allSelected: createFilter({
|
allSelected: createFilter({
|
||||||
properties: [
|
properties: [
|
||||||
{ id: 'cat', name: 'Cat', value: 'cat', selected: true },
|
{ id: 'cat', name: 'Cat', value: 'cat', selected: true },
|
||||||
@@ -292,7 +268,9 @@ export const GENERIC_FILTERS = {
|
|||||||
{ id: 'bird', name: 'Bird', value: 'bird', selected: true },
|
{ id: 'bird', name: 'Bird', value: 'bird', selected: true },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
/** Empty filter (no items) */
|
/**
|
||||||
|
* Empty filter (no items)
|
||||||
|
*/
|
||||||
empty: createFilter({
|
empty: createFilter({
|
||||||
properties: [],
|
properties: [],
|
||||||
}),
|
}),
|
||||||
+30
-16
@@ -1,9 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* ============================================================================
|
* 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.
|
* Used in Storybook stories, tests, and development.
|
||||||
*
|
*
|
||||||
* ## Usage
|
* ## Usage
|
||||||
@@ -16,7 +12,7 @@
|
|||||||
* GOOGLE_FONTS,
|
* GOOGLE_FONTS,
|
||||||
* FONTHARE_FONTS,
|
* FONTHARE_FONTS,
|
||||||
* UNIFIED_FONTS,
|
* UNIFIED_FONTS,
|
||||||
* } from '$entities/Font/lib/mocks';
|
* } from '$entities/Font/testing';
|
||||||
*
|
*
|
||||||
* // Create a mock Google Font
|
* // Create a mock Google Font
|
||||||
* const roboto = mockGoogleFont({ family: 'Roboto', category: 'sans-serif' });
|
* const roboto = mockGoogleFont({ family: 'Roboto', category: 'sans-serif' });
|
||||||
@@ -28,7 +24,7 @@
|
|||||||
* const font = mockUnifiedFont({ id: 'roboto', name: 'Roboto' });
|
* const font = mockUnifiedFont({ id: 'roboto', name: 'Roboto' });
|
||||||
*
|
*
|
||||||
* // Use preset fonts
|
* // Use preset fonts
|
||||||
* import { UNIFIED_FONTS } from '$entities/Font/lib/mocks';
|
* import { UNIFIED_FONTS } from '$entities/Font/testing';
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -51,23 +47,41 @@ import type {
|
|||||||
* Options for creating a mock UnifiedFont
|
* Options for creating a mock UnifiedFont
|
||||||
*/
|
*/
|
||||||
export interface MockUnifiedFontOptions {
|
export interface MockUnifiedFontOptions {
|
||||||
/** Unique identifier (default: derived from name) */
|
/**
|
||||||
|
* Unique identifier (default: derived from name)
|
||||||
|
*/
|
||||||
id?: string;
|
id?: string;
|
||||||
/** Font display name (default: 'Mock Font') */
|
/**
|
||||||
|
* Font display name (default: 'Mock Font')
|
||||||
|
*/
|
||||||
name?: string;
|
name?: string;
|
||||||
/** Font provider (default: 'google') */
|
/**
|
||||||
|
* Font provider (default: 'google')
|
||||||
|
*/
|
||||||
provider?: FontProvider;
|
provider?: FontProvider;
|
||||||
/** Font category (default: 'sans-serif') */
|
/**
|
||||||
|
* Font category (default: 'sans-serif')
|
||||||
|
*/
|
||||||
category?: FontCategory;
|
category?: FontCategory;
|
||||||
/** Font subsets (default: ['latin']) */
|
/**
|
||||||
|
* Font subsets (default: ['latin'])
|
||||||
|
*/
|
||||||
subsets?: FontSubset[];
|
subsets?: FontSubset[];
|
||||||
/** Font variants (default: ['regular', '700', 'italic', '700italic']) */
|
/**
|
||||||
|
* Font variants (default: ['regular', '700', 'italic', '700italic'])
|
||||||
|
*/
|
||||||
variants?: FontVariant[];
|
variants?: FontVariant[];
|
||||||
/** Style URLs (if not provided, mock URLs are generated) */
|
/**
|
||||||
|
* Style URLs (if not provided, mock URLs are generated)
|
||||||
|
*/
|
||||||
styles?: FontStyleUrls;
|
styles?: FontStyleUrls;
|
||||||
/** Metadata overrides */
|
/**
|
||||||
|
* Metadata overrides
|
||||||
|
*/
|
||||||
metadata?: Partial<FontMetadata>;
|
metadata?: Partial<FontMetadata>;
|
||||||
/** Features overrides */
|
/**
|
||||||
|
* Features overrides
|
||||||
|
*/
|
||||||
features?: Partial<FontFeatures>;
|
features?: Partial<FontFeatures>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,8 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* ============================================================================
|
* Mock data helpers (main export).
|
||||||
* MOCK DATA HELPERS - MAIN EXPORT
|
|
||||||
* ============================================================================
|
|
||||||
*
|
|
||||||
* Comprehensive mock data for Storybook stories, tests, and development.
|
* Comprehensive mock data for Storybook stories, tests, and development.
|
||||||
*
|
*
|
||||||
* ## Quick Start
|
* ## Quick Start
|
||||||
@@ -13,7 +10,7 @@
|
|||||||
* UNIFIED_FONTS,
|
* UNIFIED_FONTS,
|
||||||
* MOCK_FILTERS,
|
* MOCK_FILTERS,
|
||||||
* createMockFontStoreState,
|
* createMockFontStoreState,
|
||||||
* } from '$entities/Font/lib/mocks';
|
* } from '$entities/Font/testing';
|
||||||
*
|
*
|
||||||
* // Use in stories
|
* // Use in stories
|
||||||
* const font = mockUnifiedFont({ name: 'My Font', category: 'serif' });
|
* const font = mockUnifiedFont({ name: 'My Font', category: 'serif' });
|
||||||
+322
-50
@@ -1,8 +1,4 @@
|
|||||||
/**
|
/**
|
||||||
* ============================================================================
|
|
||||||
* MOCK FONT STORE HELPERS
|
|
||||||
* ============================================================================
|
|
||||||
*
|
|
||||||
* Factory functions and preset mock data for TanStack Query stores and state management.
|
* Factory functions and preset mock data for TanStack Query stores and state management.
|
||||||
* Used in Storybook stories for components that use reactive stores.
|
* Used in Storybook stories for components that use reactive stores.
|
||||||
*
|
*
|
||||||
@@ -12,7 +8,7 @@
|
|||||||
* import {
|
* import {
|
||||||
* createMockQueryState,
|
* createMockQueryState,
|
||||||
* MOCK_STORES,
|
* MOCK_STORES,
|
||||||
* } from '$entities/Font/lib/mocks';
|
* } from '$entities/Font/testing';
|
||||||
*
|
*
|
||||||
* // Create a mock query state
|
* // Create a mock query state
|
||||||
* const loadingState = createMockQueryState({ status: 'pending' });
|
* const loadingState = createMockQueryState({ status: 'pending' });
|
||||||
@@ -25,37 +21,79 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { UnifiedFont } from '$entities/Font/model/types';
|
import type { UnifiedFont } from '$entities/Font/model/types';
|
||||||
import type {
|
import type { QueryStatus } from '@tanstack/svelte-query';
|
||||||
QueryKey,
|
|
||||||
QueryObserverResult,
|
|
||||||
QueryStatus,
|
|
||||||
} from '@tanstack/svelte-query';
|
|
||||||
import {
|
import {
|
||||||
UNIFIED_FONTS,
|
UNIFIED_FONTS,
|
||||||
generateMockFonts,
|
generateMockFonts,
|
||||||
} from './fonts.mock';
|
} from './fonts.mock';
|
||||||
|
|
||||||
// TANSTACK QUERY MOCK TYPES
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mock TanStack Query state
|
* Mock TanStack Query state
|
||||||
*/
|
*/
|
||||||
export interface MockQueryState<TData = unknown, TError = Error> {
|
export interface MockQueryState<TData = unknown, TError = Error> {
|
||||||
|
/**
|
||||||
|
* Primary query status (pending, success, error)
|
||||||
|
*/
|
||||||
status: QueryStatus;
|
status: QueryStatus;
|
||||||
|
/**
|
||||||
|
* Payload data (present on success)
|
||||||
|
*/
|
||||||
data?: TData;
|
data?: TData;
|
||||||
|
/**
|
||||||
|
* Caught error object (present on error)
|
||||||
|
*/
|
||||||
error?: TError;
|
error?: TError;
|
||||||
|
/**
|
||||||
|
* True if initial load is in progress
|
||||||
|
*/
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
|
/**
|
||||||
|
* True if background fetch is in progress
|
||||||
|
*/
|
||||||
isFetching?: boolean;
|
isFetching?: boolean;
|
||||||
|
/**
|
||||||
|
* True if query resolved successfully
|
||||||
|
*/
|
||||||
isSuccess?: boolean;
|
isSuccess?: boolean;
|
||||||
|
/**
|
||||||
|
* True if query failed
|
||||||
|
*/
|
||||||
isError?: boolean;
|
isError?: boolean;
|
||||||
|
/**
|
||||||
|
* True if query is waiting to be executed
|
||||||
|
*/
|
||||||
isPending?: boolean;
|
isPending?: boolean;
|
||||||
|
/**
|
||||||
|
* Timestamp of last successful data retrieval
|
||||||
|
*/
|
||||||
dataUpdatedAt?: number;
|
dataUpdatedAt?: number;
|
||||||
|
/**
|
||||||
|
* Timestamp of last recorded error
|
||||||
|
*/
|
||||||
errorUpdatedAt?: number;
|
errorUpdatedAt?: number;
|
||||||
|
/**
|
||||||
|
* Total number of consecutive failures
|
||||||
|
*/
|
||||||
failureCount?: number;
|
failureCount?: number;
|
||||||
|
/**
|
||||||
|
* Detailed reason for the last failure
|
||||||
|
*/
|
||||||
failureReason?: TError;
|
failureReason?: TError;
|
||||||
|
/**
|
||||||
|
* Number of times an error has been caught
|
||||||
|
*/
|
||||||
errorUpdateCount?: number;
|
errorUpdateCount?: number;
|
||||||
|
/**
|
||||||
|
* True if currently refetching in background
|
||||||
|
*/
|
||||||
isRefetching?: boolean;
|
isRefetching?: boolean;
|
||||||
|
/**
|
||||||
|
* True if refetch attempt failed
|
||||||
|
*/
|
||||||
isRefetchError?: boolean;
|
isRefetchError?: boolean;
|
||||||
|
/**
|
||||||
|
* True if query is paused (e.g. offline)
|
||||||
|
*/
|
||||||
isPaused?: boolean;
|
isPaused?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,26 +101,72 @@ export interface MockQueryState<TData = unknown, TError = Error> {
|
|||||||
* Mock TanStack Query observer result
|
* Mock TanStack Query observer result
|
||||||
*/
|
*/
|
||||||
export interface MockQueryObserverResult<TData = unknown, TError = Error> {
|
export interface MockQueryObserverResult<TData = unknown, TError = Error> {
|
||||||
|
/**
|
||||||
|
* Current observer status
|
||||||
|
*/
|
||||||
status?: QueryStatus;
|
status?: QueryStatus;
|
||||||
|
/**
|
||||||
|
* Cached or active data payload
|
||||||
|
*/
|
||||||
data?: TData;
|
data?: TData;
|
||||||
|
/**
|
||||||
|
* Caught error from the observer
|
||||||
|
*/
|
||||||
error?: TError;
|
error?: TError;
|
||||||
|
/**
|
||||||
|
* Loading flag for the observer
|
||||||
|
*/
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
|
/**
|
||||||
|
* Fetching flag for the observer
|
||||||
|
*/
|
||||||
isFetching?: boolean;
|
isFetching?: boolean;
|
||||||
|
/**
|
||||||
|
* Success flag for the observer
|
||||||
|
*/
|
||||||
isSuccess?: boolean;
|
isSuccess?: boolean;
|
||||||
|
/**
|
||||||
|
* Error flag for the observer
|
||||||
|
*/
|
||||||
isError?: boolean;
|
isError?: boolean;
|
||||||
|
/**
|
||||||
|
* Pending flag for the observer
|
||||||
|
*/
|
||||||
isPending?: boolean;
|
isPending?: boolean;
|
||||||
|
/**
|
||||||
|
* Last update time for data
|
||||||
|
*/
|
||||||
dataUpdatedAt?: number;
|
dataUpdatedAt?: number;
|
||||||
|
/**
|
||||||
|
* Last update time for error
|
||||||
|
*/
|
||||||
errorUpdatedAt?: number;
|
errorUpdatedAt?: number;
|
||||||
|
/**
|
||||||
|
* Consecutive failure count
|
||||||
|
*/
|
||||||
failureCount?: number;
|
failureCount?: number;
|
||||||
|
/**
|
||||||
|
* Failure reason object
|
||||||
|
*/
|
||||||
failureReason?: TError;
|
failureReason?: TError;
|
||||||
|
/**
|
||||||
|
* Error count for the observer
|
||||||
|
*/
|
||||||
errorUpdateCount?: number;
|
errorUpdateCount?: number;
|
||||||
|
/**
|
||||||
|
* Refetching flag
|
||||||
|
*/
|
||||||
isRefetching?: boolean;
|
isRefetching?: boolean;
|
||||||
|
/**
|
||||||
|
* Refetch error flag
|
||||||
|
*/
|
||||||
isRefetchError?: boolean;
|
isRefetchError?: boolean;
|
||||||
|
/**
|
||||||
|
* Paused flag
|
||||||
|
*/
|
||||||
isPaused?: boolean;
|
isPaused?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TANSTACK QUERY MOCK FACTORIES
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a mock query state for TanStack Query
|
* Create a mock query state for TanStack Query
|
||||||
*/
|
*/
|
||||||
@@ -138,33 +222,53 @@ export function createSuccessState<TData>(data: TData): MockQueryObserverResult<
|
|||||||
return createMockQueryState<TData>({ status: 'success', data, error: undefined });
|
return createMockQueryState<TData>({ status: 'success', data, error: undefined });
|
||||||
}
|
}
|
||||||
|
|
||||||
// FONT STORE MOCKS
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mock UnifiedFontStore state
|
* Mock UnifiedFontStore state
|
||||||
*/
|
*/
|
||||||
export interface MockFontStoreState {
|
export interface MockFontStoreState {
|
||||||
/** All cached fonts */
|
/**
|
||||||
|
* Map of mock fonts indexed by ID
|
||||||
|
*/
|
||||||
fonts: Record<string, UnifiedFont>;
|
fonts: Record<string, UnifiedFont>;
|
||||||
/** Current page */
|
/**
|
||||||
|
* Currently active page number
|
||||||
|
*/
|
||||||
page: number;
|
page: number;
|
||||||
/** Total pages available */
|
/**
|
||||||
|
* Total number of pages calculated from limit
|
||||||
|
*/
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
/** Items per page */
|
/**
|
||||||
|
* Number of items per page
|
||||||
|
*/
|
||||||
limit: number;
|
limit: number;
|
||||||
/** Total font count */
|
/**
|
||||||
|
* Total number of available fonts
|
||||||
|
*/
|
||||||
total: number;
|
total: number;
|
||||||
/** Loading state */
|
/**
|
||||||
|
* Store-level loading status
|
||||||
|
*/
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
/** Error state */
|
/**
|
||||||
|
* Caught error object
|
||||||
|
*/
|
||||||
error: Error | null;
|
error: Error | null;
|
||||||
/** Search query */
|
/**
|
||||||
|
* Mock search filter string
|
||||||
|
*/
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
/** Selected provider */
|
/**
|
||||||
|
* Mock provider filter selection
|
||||||
|
*/
|
||||||
provider: 'google' | 'fontshare' | 'all';
|
provider: 'google' | 'fontshare' | 'all';
|
||||||
/** Selected category */
|
/**
|
||||||
|
* Mock category filter selection
|
||||||
|
*/
|
||||||
category: string | null;
|
category: string | null;
|
||||||
/** Selected subset */
|
/**
|
||||||
|
* Mock subset filter selection
|
||||||
|
*/
|
||||||
subset: string | null;
|
subset: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,10 +314,12 @@ export function createMockFontStoreState(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Preset font store states
|
* Preset font store states for UI testing
|
||||||
*/
|
*/
|
||||||
export const MOCK_FONT_STORE_STATES = {
|
export const MOCK_FONT_STORE_STATES = {
|
||||||
/** Initial loading state */
|
/**
|
||||||
|
* Initial loading state with no data
|
||||||
|
*/
|
||||||
loading: createMockFontStoreState({
|
loading: createMockFontStoreState({
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
fonts: {},
|
fonts: {},
|
||||||
@@ -221,7 +327,9 @@ export const MOCK_FONT_STORE_STATES = {
|
|||||||
page: 1,
|
page: 1,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
/** Empty state (no fonts found) */
|
/**
|
||||||
|
* State with no fonts matching filters
|
||||||
|
*/
|
||||||
empty: createMockFontStoreState({
|
empty: createMockFontStoreState({
|
||||||
fonts: {},
|
fonts: {},
|
||||||
total: 0,
|
total: 0,
|
||||||
@@ -229,7 +337,9 @@ export const MOCK_FONT_STORE_STATES = {
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
/** First page with fonts */
|
/**
|
||||||
|
* First page of results (10 items)
|
||||||
|
*/
|
||||||
firstPage: createMockFontStoreState({
|
firstPage: createMockFontStoreState({
|
||||||
fonts: Object.fromEntries(
|
fonts: Object.fromEntries(
|
||||||
Object.values(UNIFIED_FONTS).slice(0, 10).map(font => [font.id, font]),
|
Object.values(UNIFIED_FONTS).slice(0, 10).map(font => [font.id, font]),
|
||||||
@@ -241,7 +351,9 @@ export const MOCK_FONT_STORE_STATES = {
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
/** Second page with fonts */
|
/**
|
||||||
|
* Second page of results (10 items)
|
||||||
|
*/
|
||||||
secondPage: createMockFontStoreState({
|
secondPage: createMockFontStoreState({
|
||||||
fonts: Object.fromEntries(
|
fonts: Object.fromEntries(
|
||||||
Object.values(UNIFIED_FONTS).slice(10, 20).map(font => [font.id, font]),
|
Object.values(UNIFIED_FONTS).slice(10, 20).map(font => [font.id, font]),
|
||||||
@@ -253,7 +365,9 @@ export const MOCK_FONT_STORE_STATES = {
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
/** Last page with fonts */
|
/**
|
||||||
|
* Final page of results (5 items)
|
||||||
|
*/
|
||||||
lastPage: createMockFontStoreState({
|
lastPage: createMockFontStoreState({
|
||||||
fonts: Object.fromEntries(
|
fonts: Object.fromEntries(
|
||||||
Object.values(UNIFIED_FONTS).slice(0, 5).map(font => [font.id, font]),
|
Object.values(UNIFIED_FONTS).slice(0, 5).map(font => [font.id, font]),
|
||||||
@@ -265,7 +379,9 @@ export const MOCK_FONT_STORE_STATES = {
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
/** Error state */
|
/**
|
||||||
|
* Terminal failure state
|
||||||
|
*/
|
||||||
error: createMockFontStoreState({
|
error: createMockFontStoreState({
|
||||||
fonts: {},
|
fonts: {},
|
||||||
error: new Error('Failed to load fonts'),
|
error: new Error('Failed to load fonts'),
|
||||||
@@ -274,7 +390,9 @@ export const MOCK_FONT_STORE_STATES = {
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
/** With search query */
|
/**
|
||||||
|
* State with active search query
|
||||||
|
*/
|
||||||
withSearch: createMockFontStoreState({
|
withSearch: createMockFontStoreState({
|
||||||
fonts: Object.fromEntries(
|
fonts: Object.fromEntries(
|
||||||
Object.values(UNIFIED_FONTS).slice(0, 3).map(font => [font.id, font]),
|
Object.values(UNIFIED_FONTS).slice(0, 3).map(font => [font.id, font]),
|
||||||
@@ -285,7 +403,9 @@ export const MOCK_FONT_STORE_STATES = {
|
|||||||
searchQuery: 'Roboto',
|
searchQuery: 'Roboto',
|
||||||
}),
|
}),
|
||||||
|
|
||||||
/** Filtered by category */
|
/**
|
||||||
|
* State with active category filter
|
||||||
|
*/
|
||||||
filteredByCategory: createMockFontStoreState({
|
filteredByCategory: createMockFontStoreState({
|
||||||
fonts: Object.fromEntries(
|
fonts: Object.fromEntries(
|
||||||
Object.values(UNIFIED_FONTS)
|
Object.values(UNIFIED_FONTS)
|
||||||
@@ -299,7 +419,9 @@ export const MOCK_FONT_STORE_STATES = {
|
|||||||
category: 'serif',
|
category: 'serif',
|
||||||
}),
|
}),
|
||||||
|
|
||||||
/** Filtered by provider */
|
/**
|
||||||
|
* State with active provider filter
|
||||||
|
*/
|
||||||
filteredByProvider: createMockFontStoreState({
|
filteredByProvider: createMockFontStoreState({
|
||||||
fonts: Object.fromEntries(
|
fonts: Object.fromEntries(
|
||||||
Object.values(UNIFIED_FONTS)
|
Object.values(UNIFIED_FONTS)
|
||||||
@@ -313,7 +435,9 @@ export const MOCK_FONT_STORE_STATES = {
|
|||||||
provider: 'google',
|
provider: 'google',
|
||||||
}),
|
}),
|
||||||
|
|
||||||
/** Large dataset */
|
/**
|
||||||
|
* Large collection for performance testing (50 items)
|
||||||
|
*/
|
||||||
largeDataset: createMockFontStoreState({
|
largeDataset: createMockFontStoreState({
|
||||||
fonts: Object.fromEntries(
|
fonts: Object.fromEntries(
|
||||||
generateMockFonts(50).map(font => [font.id, font]),
|
generateMockFonts(50).map(font => [font.id, font]),
|
||||||
@@ -326,17 +450,30 @@ export const MOCK_FONT_STORE_STATES = {
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
// MOCK STORE OBJECT
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a mock store object that mimics TanStack Query behavior
|
* Create a mock store object that mimics TanStack Query behavior
|
||||||
* Useful for components that subscribe to store properties
|
* Useful for components that subscribe to store properties
|
||||||
*/
|
*/
|
||||||
export function createMockStore<T>(config: {
|
export function createMockStore<T>(config: {
|
||||||
|
/**
|
||||||
|
* Reactive data payload
|
||||||
|
*/
|
||||||
data?: T;
|
data?: T;
|
||||||
|
/**
|
||||||
|
* Loading status flag
|
||||||
|
*/
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
|
/**
|
||||||
|
* Error status flag
|
||||||
|
*/
|
||||||
isError?: boolean;
|
isError?: boolean;
|
||||||
|
/**
|
||||||
|
* Catch-all error object
|
||||||
|
*/
|
||||||
error?: Error;
|
error?: Error;
|
||||||
|
/**
|
||||||
|
* Background fetching flag
|
||||||
|
*/
|
||||||
isFetching?: boolean;
|
isFetching?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
@@ -348,50 +485,81 @@ export function createMockStore<T>(config: {
|
|||||||
} = config;
|
} = config;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
/**
|
||||||
|
* Returns the active data payload
|
||||||
|
*/
|
||||||
get data() {
|
get data() {
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* True if initially loading
|
||||||
|
*/
|
||||||
get isLoading() {
|
get isLoading() {
|
||||||
return isLoading;
|
return isLoading;
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* True if last request failed
|
||||||
|
*/
|
||||||
get isError() {
|
get isError() {
|
||||||
return isError;
|
return isError;
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* Returns the caught error object
|
||||||
|
*/
|
||||||
get error() {
|
get error() {
|
||||||
return error;
|
return error;
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* True if fetching in background
|
||||||
|
*/
|
||||||
get isFetching() {
|
get isFetching() {
|
||||||
return isFetching;
|
return isFetching;
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* True if query is stable and has data
|
||||||
|
*/
|
||||||
get isSuccess() {
|
get isSuccess() {
|
||||||
return !isLoading && !isError && data !== undefined;
|
return !isLoading && !isError && data !== undefined;
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* Returns semantic status string
|
||||||
|
*/
|
||||||
get status() {
|
get status() {
|
||||||
if (isLoading) return 'pending';
|
if (isLoading) {
|
||||||
if (isError) return 'error';
|
return 'pending';
|
||||||
|
}
|
||||||
|
if (isError) {
|
||||||
|
return 'error';
|
||||||
|
}
|
||||||
return 'success';
|
return 'success';
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Preset mock stores
|
* Preset mock stores for common UI states
|
||||||
*/
|
*/
|
||||||
export const MOCK_STORES = {
|
export const MOCK_STORES = {
|
||||||
/** Font store in loading state */
|
/**
|
||||||
|
* Initial loading state
|
||||||
|
*/
|
||||||
loadingFontStore: createMockStore<UnifiedFont[]>({
|
loadingFontStore: createMockStore<UnifiedFont[]>({
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
data: undefined,
|
data: undefined,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
/** Font store with fonts loaded */
|
/**
|
||||||
|
* Successful data load state
|
||||||
|
*/
|
||||||
successFontStore: createMockStore<UnifiedFont[]>({
|
successFontStore: createMockStore<UnifiedFont[]>({
|
||||||
data: Object.values(UNIFIED_FONTS),
|
data: Object.values(UNIFIED_FONTS),
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
isError: false,
|
isError: false,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
/** Font store with error */
|
/**
|
||||||
|
* API error state
|
||||||
|
*/
|
||||||
errorFontStore: createMockStore<UnifiedFont[]>({
|
errorFontStore: createMockStore<UnifiedFont[]>({
|
||||||
data: undefined,
|
data: undefined,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@@ -399,7 +567,9 @@ export const MOCK_STORES = {
|
|||||||
error: new Error('Failed to load fonts'),
|
error: new Error('Failed to load fonts'),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
/** Font store with empty results */
|
/**
|
||||||
|
* Empty result set state
|
||||||
|
*/
|
||||||
emptyFontStore: createMockStore<UnifiedFont[]>({
|
emptyFontStore: createMockStore<UnifiedFont[]>({
|
||||||
data: [],
|
data: [],
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@@ -414,36 +584,69 @@ export const MOCK_STORES = {
|
|||||||
const mockState = createMockFontStoreState(state);
|
const mockState = createMockFontStoreState(state);
|
||||||
return {
|
return {
|
||||||
// State properties
|
// State properties
|
||||||
|
/**
|
||||||
|
* Collection of mock fonts
|
||||||
|
*/
|
||||||
get fonts() {
|
get fonts() {
|
||||||
return mockState.fonts;
|
return mockState.fonts;
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* Current mock page
|
||||||
|
*/
|
||||||
get page() {
|
get page() {
|
||||||
return mockState.page;
|
return mockState.page;
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* Total mock pages
|
||||||
|
*/
|
||||||
get totalPages() {
|
get totalPages() {
|
||||||
return mockState.totalPages;
|
return mockState.totalPages;
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* Mock items per page
|
||||||
|
*/
|
||||||
get limit() {
|
get limit() {
|
||||||
return mockState.limit;
|
return mockState.limit;
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* Total mock items
|
||||||
|
*/
|
||||||
get total() {
|
get total() {
|
||||||
return mockState.total;
|
return mockState.total;
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* Mock loading status
|
||||||
|
*/
|
||||||
get isLoading() {
|
get isLoading() {
|
||||||
return mockState.isLoading;
|
return mockState.isLoading;
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* Mock error status
|
||||||
|
*/
|
||||||
get error() {
|
get error() {
|
||||||
return mockState.error;
|
return mockState.error;
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* Mock search string
|
||||||
|
*/
|
||||||
get searchQuery() {
|
get searchQuery() {
|
||||||
return mockState.searchQuery;
|
return mockState.searchQuery;
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* Mock provider filter
|
||||||
|
*/
|
||||||
get provider() {
|
get provider() {
|
||||||
return mockState.provider;
|
return mockState.provider;
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* Mock category filter
|
||||||
|
*/
|
||||||
get category() {
|
get category() {
|
||||||
return mockState.category;
|
return mockState.category;
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* Mock subset filter
|
||||||
|
*/
|
||||||
get subset() {
|
get subset() {
|
||||||
return mockState.subset;
|
return mockState.subset;
|
||||||
},
|
},
|
||||||
@@ -460,19 +663,49 @@ export const MOCK_STORES = {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* Create a mock FontStore object
|
* Create a mock FontCatalogStore object
|
||||||
* Matches FontStore's public API for Storybook use
|
* Matches FontCatalogStore's public API for Storybook use
|
||||||
*/
|
*/
|
||||||
fontStore: (config: {
|
fontCatalogStore: (config: {
|
||||||
|
/**
|
||||||
|
* Preset font list
|
||||||
|
*/
|
||||||
fonts?: UnifiedFont[];
|
fonts?: UnifiedFont[];
|
||||||
|
/**
|
||||||
|
* Total item count
|
||||||
|
*/
|
||||||
total?: number;
|
total?: number;
|
||||||
|
/**
|
||||||
|
* Items per page
|
||||||
|
*/
|
||||||
limit?: number;
|
limit?: number;
|
||||||
|
/**
|
||||||
|
* Pagination offset
|
||||||
|
*/
|
||||||
offset?: number;
|
offset?: number;
|
||||||
|
/**
|
||||||
|
* Loading flag
|
||||||
|
*/
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
|
/**
|
||||||
|
* Fetching flag
|
||||||
|
*/
|
||||||
isFetching?: boolean;
|
isFetching?: boolean;
|
||||||
|
/**
|
||||||
|
* Error flag
|
||||||
|
*/
|
||||||
isError?: boolean;
|
isError?: boolean;
|
||||||
|
/**
|
||||||
|
* Catch-all error object
|
||||||
|
*/
|
||||||
error?: Error | null;
|
error?: Error | null;
|
||||||
|
/**
|
||||||
|
* Has more pages flag
|
||||||
|
*/
|
||||||
hasMore?: boolean;
|
hasMore?: boolean;
|
||||||
|
/**
|
||||||
|
* Current page number
|
||||||
|
*/
|
||||||
page?: number;
|
page?: number;
|
||||||
} = {}) => {
|
} = {}) => {
|
||||||
const {
|
const {
|
||||||
@@ -495,27 +728,51 @@ export const MOCK_STORES = {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
// State getters
|
// State getters
|
||||||
|
/**
|
||||||
|
* Current mock parameters
|
||||||
|
*/
|
||||||
get params() {
|
get params() {
|
||||||
return state.params;
|
return state.params;
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* Mock font list
|
||||||
|
*/
|
||||||
get fonts() {
|
get fonts() {
|
||||||
return mockFonts;
|
return mockFonts;
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* Mock loading state
|
||||||
|
*/
|
||||||
get isLoading() {
|
get isLoading() {
|
||||||
return isLoading;
|
return isLoading;
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* Mock fetching state
|
||||||
|
*/
|
||||||
get isFetching() {
|
get isFetching() {
|
||||||
return isFetching;
|
return isFetching;
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* Mock error state
|
||||||
|
*/
|
||||||
get isError() {
|
get isError() {
|
||||||
return isError;
|
return isError;
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* Mock error object
|
||||||
|
*/
|
||||||
get error() {
|
get error() {
|
||||||
return error;
|
return error;
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* Mock empty state check
|
||||||
|
*/
|
||||||
get isEmpty() {
|
get isEmpty() {
|
||||||
return !isLoading && !isFetching && mockFonts.length === 0;
|
return !isLoading && !isFetching && mockFonts.length === 0;
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* Mock pagination metadata
|
||||||
|
*/
|
||||||
get pagination() {
|
get pagination() {
|
||||||
return {
|
return {
|
||||||
total: mockTotal,
|
total: mockTotal,
|
||||||
@@ -527,18 +784,33 @@ export const MOCK_STORES = {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
// Category getters
|
// Category getters
|
||||||
|
/**
|
||||||
|
* Derived sans-serif filter
|
||||||
|
*/
|
||||||
get sansSerifFonts() {
|
get sansSerifFonts() {
|
||||||
return mockFonts.filter(f => f.category === 'sans-serif');
|
return mockFonts.filter(f => f.category === 'sans-serif');
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* Derived serif filter
|
||||||
|
*/
|
||||||
get serifFonts() {
|
get serifFonts() {
|
||||||
return mockFonts.filter(f => f.category === 'serif');
|
return mockFonts.filter(f => f.category === 'serif');
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* Derived display filter
|
||||||
|
*/
|
||||||
get displayFonts() {
|
get displayFonts() {
|
||||||
return mockFonts.filter(f => f.category === 'display');
|
return mockFonts.filter(f => f.category === 'display');
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* Derived handwriting filter
|
||||||
|
*/
|
||||||
get handwritingFonts() {
|
get handwritingFonts() {
|
||||||
return mockFonts.filter(f => f.category === 'handwriting');
|
return mockFonts.filter(f => f.category === 'handwriting');
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* Derived monospace filter
|
||||||
|
*/
|
||||||
get monospaceFonts() {
|
get monospaceFonts() {
|
||||||
return mockFonts.filter(f => f.category === 'monospace');
|
return mockFonts.filter(f => f.category === 'monospace');
|
||||||
},
|
},
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<script module>
|
||||||
|
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||||
|
import FontApplicator from './FontApplicator.svelte';
|
||||||
|
|
||||||
|
const { Story } = defineMeta({
|
||||||
|
title: 'Entities/FontApplicator',
|
||||||
|
component: FontApplicator,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
parameters: {
|
||||||
|
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.',
|
||||||
|
},
|
||||||
|
story: { inline: false },
|
||||||
|
},
|
||||||
|
layout: 'centered',
|
||||||
|
},
|
||||||
|
argTypes: {
|
||||||
|
status: { control: 'select', options: ['loading', 'loaded', 'error'] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { mockUnifiedFont } from '$entities/Font/testing';
|
||||||
|
import type { ComponentProps } from 'svelte';
|
||||||
|
|
||||||
|
const fontUnknown = mockUnifiedFont({ id: 'nonexistent-font-xk92z', name: 'Nonexistent Font Xk92z' });
|
||||||
|
|
||||||
|
const fontArial = mockUnifiedFont({ id: 'arial', name: 'Arial' });
|
||||||
|
|
||||||
|
const fontArialBold = mockUnifiedFont({ id: 'arial-bold', name: 'Arial' });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Story
|
||||||
|
name="Loading State"
|
||||||
|
parameters={{
|
||||||
|
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.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
args={{ font: fontUnknown, status: 'loading' }}
|
||||||
|
>
|
||||||
|
{#snippet template(args: ComponentProps<typeof FontApplicator>)}
|
||||||
|
<FontApplicator {...args}>
|
||||||
|
<p class="text-xl">The quick brown fox jumps over the lazy dog</p>
|
||||||
|
</FontApplicator>
|
||||||
|
{/snippet}
|
||||||
|
</Story>
|
||||||
|
|
||||||
|
<Story
|
||||||
|
name="Loaded State"
|
||||||
|
parameters={{
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
story:
|
||||||
|
'Status is `loaded`: the component reveals the font, applying it to its children (Arial here, available in all browsers).',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
args={{ font: fontArial, status: 'loaded' }}
|
||||||
|
>
|
||||||
|
{#snippet template(args: ComponentProps<typeof FontApplicator>)}
|
||||||
|
<FontApplicator {...args}>
|
||||||
|
<p class="text-xl">The quick brown fox jumps over the lazy dog</p>
|
||||||
|
</FontApplicator>
|
||||||
|
{/snippet}
|
||||||
|
</Story>
|
||||||
|
|
||||||
|
<Story
|
||||||
|
name="Error State"
|
||||||
|
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.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
args={{ font: fontArialBold, status: 'error' }}
|
||||||
|
>
|
||||||
|
{#snippet template(args: ComponentProps<typeof FontApplicator>)}
|
||||||
|
<FontApplicator {...args}>
|
||||||
|
<p class="text-xl">The quick brown fox jumps over the lazy dog</p>
|
||||||
|
</FontApplicator>
|
||||||
|
{/snippet}
|
||||||
|
</Story>
|
||||||
@@ -1,19 +1,15 @@
|
|||||||
<!--
|
<!--
|
||||||
Component: FontApplicator
|
Component: FontApplicator
|
||||||
Loads fonts from fontshare with link tag
|
Applies a font to its children once the font file is loaded.
|
||||||
- Loads font only if it's not already applied
|
Shows the skeleton snippet while loading; falls back to system font if no skeleton is provided.
|
||||||
- Reacts to font load status to show/hide content
|
|
||||||
- Adds smooth transition when font appears
|
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
import { cn } from '$shared/lib';
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import { prefersReducedMotion } from 'svelte/motion';
|
import type {
|
||||||
import {
|
FontLoadStatus,
|
||||||
DEFAULT_FONT_WEIGHT,
|
UnifiedFont,
|
||||||
type UnifiedFont,
|
} from '../../model/types';
|
||||||
appliedFontsManager,
|
|
||||||
} from '../../model';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/**
|
/**
|
||||||
@@ -21,10 +17,13 @@ interface Props {
|
|||||||
*/
|
*/
|
||||||
font: UnifiedFont;
|
font: UnifiedFont;
|
||||||
/**
|
/**
|
||||||
* Font weight
|
* Current load status for this font, supplied by the composing layer.
|
||||||
* @default 400
|
* Kept out of the component so it does not depend on (and import) the
|
||||||
|
* lifecycle store — the owning widget reads the manager and passes the
|
||||||
|
* resolved status down. `undefined` means the font is not tracked yet and
|
||||||
|
* is treated as not-yet-revealed (skeleton / system-font fallback).
|
||||||
*/
|
*/
|
||||||
weight?: number;
|
status: FontLoadStatus | undefined;
|
||||||
/**
|
/**
|
||||||
* CSS classes
|
* CSS classes
|
||||||
*/
|
*/
|
||||||
@@ -33,47 +32,31 @@ interface Props {
|
|||||||
* Content snippet
|
* Content snippet
|
||||||
*/
|
*/
|
||||||
children?: Snippet;
|
children?: Snippet;
|
||||||
|
/**
|
||||||
|
* Shown while the font file is loading.
|
||||||
|
* When omitted, children render in system font until ready.
|
||||||
|
*/
|
||||||
|
skeleton?: Snippet;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
font,
|
font,
|
||||||
weight = DEFAULT_FONT_WEIGHT,
|
status,
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
|
skeleton,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const status = $derived(
|
|
||||||
appliedFontsManager.getFontStatus(
|
|
||||||
font.id,
|
|
||||||
weight,
|
|
||||||
font.features?.isVariable,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// The "Show" condition: Font is loaded OR it errored out OR it's a noTouch preview (like in search)
|
|
||||||
const shouldReveal = $derived(status === 'loaded' || status === 'error');
|
const shouldReveal = $derived(status === 'loaded' || status === 'error');
|
||||||
|
|
||||||
const transitionClasses = $derived(
|
|
||||||
prefersReducedMotion.current
|
|
||||||
? 'transition-none' // Disable CSS transitions if motion is reduced
|
|
||||||
: 'transition-all duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]',
|
|
||||||
);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
{#if !shouldReveal && skeleton}
|
||||||
style:font-family={shouldReveal
|
{@render skeleton()}
|
||||||
? `'${font.name}'`
|
{:else}
|
||||||
: 'system-ui, -apple-system, sans-serif'}
|
<div
|
||||||
class={cn(
|
style:font-family={shouldReveal ? `'${font.name}'` : 'system-ui, -apple-system, sans-serif'}
|
||||||
transitionClasses,
|
class={cn(className)}
|
||||||
// If reduced motion is on, we skip the transform/blur entirely
|
>
|
||||||
!shouldReveal
|
{@render children?.()}
|
||||||
&& !prefersReducedMotion.current
|
</div>
|
||||||
&& 'opacity-50 scale-[0.95] blur-sm',
|
{/if}
|
||||||
!shouldReveal && prefersReducedMotion.current && 'opacity-0', // Still hide until font is ready, but no movement
|
|
||||||
shouldReveal && 'opacity-100 scale-100 blur-0',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{@render children?.()}
|
|
||||||
</div>
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user