Compare commits
138 Commits
e88cca9289
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 0ebf75b24e | |||
| 7b46e06f8b | |||
| 0737db69a9 | |||
| 64b4a65e7b | |||
| 7f0d2b54e0 | |||
| 5b1a1d0b0a | |||
| 0562b94b03 | |||
| ef08512986 | |||
| 816d4b89ce | |||
| aa1379c15b | |||
| 33e589f041 | |||
| b12dc6257d | |||
| 35e0f06a77 | |||
| dde187e0b2 | |||
| 5a7c61ade7 | |||
| d2bce85f9c | |||
| e509463911 | |||
| db08f523f6 | |||
| c5fa159c14 | |||
| 8645c7dcc8 | |||
| fbeb84270b | |||
| c1ac9b5bc4 | |||
| 46d0d887b1 | |||
| 0a489a8adc | |||
| cd349aec92 | |||
| adaa6d7648 | |||
| 10f4781a67 | |||
| f4a568832a | |||
| 4e9670118a | |||
| 8e88d1b7cf | |||
| 1cbc262af7 | |||
| f072c5b270 | |||
| bfa99cde20 | |||
| 75b62265be | |||
| 5b81be6614 | |||
| a74abbb0b3 | |||
| 20accb9c93 | |||
| 46b9db1db3 | |||
| 4b017a83bb | |||
| 49822f8af7 | |||
| 338ca9b4fd | |||
| 99f662e2d5 | |||
| 5977e0a0dc | |||
| 2b0d8470e5 | |||
| 351ee9fd52 | |||
| a526a51af8 | |||
| fcde78abad | |||
| 26737f2f11 | |||
| d9fa2bc501 | |||
| 5f38996665 | |||
| d70fc9f918 | |||
| 14dbd374ec | |||
| dc6e15492a | |||
| 45eac0c396 | |||
| ed7d31bf5c | |||
| 468d2e7f8c | |||
| 2a761b9d47 | |||
| a9e4633b64 | |||
| 778988977f | |||
| 9a9ff95bf3 | |||
| 7517678e87 | |||
| 4281d94d66 | |||
| 752e38adf9 | |||
| 9c538069e4 | |||
| 71fed58af9 | |||
| fee3355a65 | |||
| 2ff7f1a13d | |||
| 6bf1b1ea87 | |||
| 3ef012eb43 | |||
| 5df60b236c | |||
| df3c694909 | |||
| a1a1fcf39d | |||
| b40e651be4 | |||
| 9427f4e50f | |||
| ed9791c176 | |||
| c6dabafd93 |
@@ -41,7 +41,13 @@ 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
|
||||||
|
run: yarn test:component
|
||||||
|
|
||||||
publish:
|
publish:
|
||||||
needs: build # Only runs if tests/lint pass
|
needs: build # Only runs if tests/lint pass
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ node_modules
|
|||||||
/build
|
/build
|
||||||
/dist
|
/dist
|
||||||
|
|
||||||
|
# Git worktrees (isolated development branches)
|
||||||
|
.worktrees
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
|
||||||
+11
-1
@@ -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,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>glyphdiff</title>
|
<title>glyphdiff</title>
|
||||||
|
<script src="https://mcp.figma.com/mcp/html-to-design/capture.js" async></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
+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}"
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,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",
|
||||||
@@ -66,6 +65,7 @@
|
|||||||
"vitest-browser-svelte": "^2.0.1"
|
"vitest-browser-svelte": "^2.0.1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@chenglou/pretext": "^0.0.5",
|
||||||
"@tanstack/svelte-query": "^6.0.14"
|
"@tanstack/svelte-query": "^6.0.14"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-2
@@ -7,7 +7,7 @@
|
|||||||
/* 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;
|
||||||
@@ -91,7 +91,6 @@
|
|||||||
--space-4xl: 4rem;
|
--space-4xl: 4rem;
|
||||||
|
|
||||||
/* Typography Scale */
|
/* Typography Scale */
|
||||||
--text-2xs: 0.625rem;
|
|
||||||
--text-xs: 0.75rem;
|
--text-xs: 0.75rem;
|
||||||
--text-sm: 0.875rem;
|
--text-sm: 0.875rem;
|
||||||
--text-base: 1rem;
|
--text-base: 1rem;
|
||||||
@@ -205,6 +204,14 @@
|
|||||||
--font-mono: 'Space Mono', monospace;
|
--font-mono: 'Space Mono', monospace;
|
||||||
--font-primary: 'Space Grotesk', system-ui, -apple-system, 'Segoe UI', Inter, Roboto, Arial, sans-serif;
|
--font-primary: 'Space Grotesk', system-ui, -apple-system, 'Segoe UI', Inter, Roboto, Arial, sans-serif;
|
||||||
--font-secondary: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, Arial, sans-serif;
|
--font-secondary: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, Arial, sans-serif;
|
||||||
|
|
||||||
|
/* Micro typography scale — extends Tailwind's text-xs (0.75rem) downward */
|
||||||
|
--text-5xs: 0.4375rem;
|
||||||
|
--text-4xs: 0.5rem;
|
||||||
|
--text-3xs: 0.5625rem;
|
||||||
|
--text-2xs: 0.625rem;
|
||||||
|
/* Monospace label tracking — used in Loader and Footnote */
|
||||||
|
--tracking-wider-mono: 0.2em;
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
@@ -265,6 +272,21 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
/* 21× border-black/5 dark:border-white/10 → single token */
|
||||||
|
.border-subtle {
|
||||||
|
@apply border-black/5 dark:border-white/10;
|
||||||
|
}
|
||||||
|
/* Secondary text pair */
|
||||||
|
.text-secondary {
|
||||||
|
@apply text-neutral-500 dark:text-neutral-400;
|
||||||
|
}
|
||||||
|
/* Standard focus ring */
|
||||||
|
.focus-ring {
|
||||||
|
@apply focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Global utility - useful across your app */
|
/* Global utility - useful across your app */
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
* {
|
* {
|
||||||
|
|||||||
+15
-31
@@ -3,23 +3,12 @@
|
|||||||
Application shell with providers and page wrapper
|
Application shell with providers and page wrapper
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
/**
|
|
||||||
* Layout Component
|
|
||||||
*
|
|
||||||
* Root layout wrapper that provides the application shell structure. Handles favicon,
|
|
||||||
* toolbar provider initialization, and renders child routes with consistent structure.
|
|
||||||
*
|
|
||||||
* Layout structure:
|
|
||||||
* - Header area (currently empty, reserved for future use)
|
|
||||||
*
|
|
||||||
* - Footer area (currently empty, reserved for future use)
|
|
||||||
*/
|
|
||||||
import { BreadcrumbHeader } from '$entities/Breadcrumb';
|
|
||||||
import { themeManager } from '$features/ChangeAppTheme';
|
import { themeManager } from '$features/ChangeAppTheme';
|
||||||
import GD from '$shared/assets/GD.svg';
|
import G from '$shared/assets/G.svg';
|
||||||
import { ResponsiveProvider } from '$shared/lib';
|
import { ResponsiveProvider } from '$shared/lib';
|
||||||
import { Provider as TooltipProvider } from '$shared/shadcn/ui/tooltip';
|
import { Footer } from '$widgets/Footer';
|
||||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
import clsx from 'clsx';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type Snippet,
|
type Snippet,
|
||||||
onDestroy,
|
onDestroy,
|
||||||
@@ -42,7 +31,7 @@ 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" />
|
<link rel="preconnect" href="https://api.fontshare.com" />
|
||||||
<link
|
<link
|
||||||
@@ -75,29 +64,24 @@ onDestroy(() => themeManager.destroy());
|
|||||||
/>
|
/>
|
||||||
</noscript>
|
</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={clsx(
|
||||||
'min-h-screen w-auto flex flex-col bg-surface dark:bg-dark-bg',
|
'min-h-screen w-auto flex flex-col bg-surface dark:bg-dark-bg relative',
|
||||||
theme === 'dark' ? 'dark' : '',
|
theme === 'dark' ? 'dark' : '',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<header>
|
{#if fontsReady}
|
||||||
<BreadcrumbHeader />
|
{@render children?.()}
|
||||||
</header>
|
{/if}
|
||||||
|
|
||||||
<!-- <ScrollArea class="h-screen w-screen"> -->
|
<Footer />
|
||||||
<!-- <main class="flex-1 w-full mx-auto relative"> -->
|
|
||||||
<TooltipProvider>
|
|
||||||
{#if fontsReady}
|
|
||||||
{@render children?.()}
|
|
||||||
{/if}
|
|
||||||
</TooltipProvider>
|
|
||||||
<!-- </main> -->
|
|
||||||
<!-- </ScrollArea> -->
|
|
||||||
<footer></footer>
|
|
||||||
</div>
|
</div>
|
||||||
</ResponsiveProvider>
|
</ResponsiveProvider>
|
||||||
|
|||||||
@@ -34,11 +34,17 @@
|
|||||||
* A breadcrumb item representing a tracked section
|
* A breadcrumb item representing a tracked section
|
||||||
*/
|
*/
|
||||||
export interface BreadcrumbItem {
|
export interface BreadcrumbItem {
|
||||||
/** Unique index for ordering */
|
/**
|
||||||
|
* Unique index for ordering
|
||||||
|
*/
|
||||||
index: number;
|
index: number;
|
||||||
/** Display title for the breadcrumb */
|
/**
|
||||||
|
* Display title for the breadcrumb
|
||||||
|
*/
|
||||||
title: string;
|
title: string;
|
||||||
/** DOM element to track */
|
/**
|
||||||
|
* DOM element to track
|
||||||
|
*/
|
||||||
element: HTMLElement;
|
element: HTMLElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,21 +56,37 @@ export interface BreadcrumbItem {
|
|||||||
* past while moving down the page.
|
* past while moving down the page.
|
||||||
*/
|
*/
|
||||||
class ScrollBreadcrumbsStore {
|
class ScrollBreadcrumbsStore {
|
||||||
/** All tracked breadcrumb items */
|
/**
|
||||||
|
* All tracked breadcrumb items
|
||||||
|
*/
|
||||||
#items = $state<BreadcrumbItem[]>([]);
|
#items = $state<BreadcrumbItem[]>([]);
|
||||||
/** Set of indices that have scrolled past (exited viewport while scrolling down) */
|
/**
|
||||||
|
* Set of indices that have scrolled past (exited viewport while scrolling down)
|
||||||
|
*/
|
||||||
#scrolledPast = $state<Set<number>>(new Set());
|
#scrolledPast = $state<Set<number>>(new Set());
|
||||||
/** Intersection Observer instance */
|
/**
|
||||||
|
* Intersection Observer instance
|
||||||
|
*/
|
||||||
#observer: IntersectionObserver | null = null;
|
#observer: IntersectionObserver | null = null;
|
||||||
/** Offset for smooth scrolling (sticky header height) */
|
/**
|
||||||
|
* Offset for smooth scrolling (sticky header height)
|
||||||
|
*/
|
||||||
#scrollOffset = 0;
|
#scrollOffset = 0;
|
||||||
/** Current scroll direction */
|
/**
|
||||||
|
* Current scroll direction
|
||||||
|
*/
|
||||||
#isScrollingDown = $state(false);
|
#isScrollingDown = $state(false);
|
||||||
/** Previous scroll Y position to determine direction */
|
/**
|
||||||
|
* Previous scroll Y position to determine direction
|
||||||
|
*/
|
||||||
#prevScrollY = 0;
|
#prevScrollY = 0;
|
||||||
/** Throttled scroll handler */
|
/**
|
||||||
|
* Throttled scroll handler
|
||||||
|
*/
|
||||||
#handleScroll: (() => void) | null = null;
|
#handleScroll: (() => void) | null = null;
|
||||||
/** Listener count for cleanup */
|
/**
|
||||||
|
* Listener count for cleanup
|
||||||
|
*/
|
||||||
#listenerCount = 0;
|
#listenerCount = 0;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -83,13 +105,17 @@ class ScrollBreadcrumbsStore {
|
|||||||
* (fires as soon as any part of element crosses viewport edge).
|
* (fires as soon as any part of element crosses viewport edge).
|
||||||
*/
|
*/
|
||||||
#initObserver(): void {
|
#initObserver(): void {
|
||||||
if (this.#observer) return;
|
if (this.#observer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.#observer = new IntersectionObserver(
|
this.#observer = new IntersectionObserver(
|
||||||
entries => {
|
entries => {
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const item = this.#items.find(i => i.element === entry.target);
|
const item = this.#items.find(i => i.element === entry.target);
|
||||||
if (!item) continue;
|
if (!item) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (!entry.isIntersecting && this.#isScrollingDown) {
|
if (!entry.isIntersecting && this.#isScrollingDown) {
|
||||||
// Element exited viewport while scrolling DOWN - add to breadcrumbs
|
// Element exited viewport while scrolling DOWN - add to breadcrumbs
|
||||||
@@ -141,17 +167,23 @@ class ScrollBreadcrumbsStore {
|
|||||||
this.#detachScrollListener();
|
this.#detachScrollListener();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** All tracked items sorted by index */
|
/**
|
||||||
|
* All tracked items sorted by index
|
||||||
|
*/
|
||||||
get items(): BreadcrumbItem[] {
|
get items(): BreadcrumbItem[] {
|
||||||
return this.#items.slice().sort((a, b) => a.index - b.index);
|
return this.#items.slice().sort((a, b) => a.index - b.index);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Items that have scrolled past viewport top (visible in breadcrumbs) */
|
/**
|
||||||
|
* Items that have scrolled past viewport top (visible in breadcrumbs)
|
||||||
|
*/
|
||||||
get scrolledPastItems(): BreadcrumbItem[] {
|
get scrolledPastItems(): BreadcrumbItem[] {
|
||||||
return this.items.filter(item => this.#scrolledPast.has(item.index));
|
return this.items.filter(item => this.#scrolledPast.has(item.index));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Index of the most recently scrolled item (active section) */
|
/**
|
||||||
|
* Index of the most recently scrolled item (active section)
|
||||||
|
*/
|
||||||
get activeIndex(): number | null {
|
get activeIndex(): number | null {
|
||||||
const past = this.scrolledPastItems;
|
const past = this.scrolledPastItems;
|
||||||
return past.length > 0 ? past[past.length - 1].index : null;
|
return past.length > 0 ? past[past.length - 1].index : null;
|
||||||
@@ -171,7 +203,9 @@ class ScrollBreadcrumbsStore {
|
|||||||
* @param offset - Scroll offset in pixels (for sticky headers)
|
* @param offset - Scroll offset in pixels (for sticky headers)
|
||||||
*/
|
*/
|
||||||
add(item: BreadcrumbItem, offset = 0): void {
|
add(item: BreadcrumbItem, offset = 0): void {
|
||||||
if (this.#items.find(i => i.index === item.index)) return;
|
if (this.#items.find(i => i.index === item.index)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.#scrollOffset = offset;
|
this.#scrollOffset = offset;
|
||||||
this.#items.push(item);
|
this.#items.push(item);
|
||||||
@@ -188,7 +222,9 @@ class ScrollBreadcrumbsStore {
|
|||||||
*/
|
*/
|
||||||
remove(index: number): void {
|
remove(index: number): void {
|
||||||
const item = this.#items.find(i => i.index === index);
|
const item = this.#items.find(i => i.index === index);
|
||||||
if (!item) return;
|
if (!item) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.#observer?.unobserve(item.element);
|
this.#observer?.unobserve(item.element);
|
||||||
this.#items = this.#items.filter(i => i.index !== index);
|
this.#items = this.#items.filter(i => i.index !== index);
|
||||||
@@ -209,7 +245,9 @@ class ScrollBreadcrumbsStore {
|
|||||||
*/
|
*/
|
||||||
scrollTo(index: number, container: HTMLElement | Window = window): void {
|
scrollTo(index: number, container: HTMLElement | Window = window): void {
|
||||||
const item = this.#items.find(i => i.index === index);
|
const item = this.#items.find(i => i.index === index);
|
||||||
if (!item) return;
|
if (!item) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const rect = item.element.getBoundingClientRect();
|
const rect = item.element.getBoundingClientRect();
|
||||||
const scrollTop = container === window ? window.scrollY : (container as HTMLElement).scrollTop;
|
const scrollTop = container === window ? window.scrollY : (container as HTMLElement).scrollTop;
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
/** @vitest-environment jsdom */
|
/**
|
||||||
|
* @vitest-environment jsdom
|
||||||
|
*/
|
||||||
import {
|
import {
|
||||||
afterEach,
|
afterEach,
|
||||||
beforeEach,
|
beforeEach,
|
||||||
@@ -24,7 +26,9 @@ class MockIntersectionObserver implements IntersectionObserver {
|
|||||||
|
|
||||||
constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) {
|
constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) {
|
||||||
this.callbacks.push(callback);
|
this.callbacks.push(callback);
|
||||||
if (options?.rootMargin) this.rootMargin = options.rootMargin;
|
if (options?.rootMargin) {
|
||||||
|
this.rootMargin = options.rootMargin;
|
||||||
|
}
|
||||||
if (options?.threshold) {
|
if (options?.threshold) {
|
||||||
this.thresholds = Array.isArray(options.threshold) ? options.threshold : [options.threshold];
|
this.thresholds = Array.isArray(options.threshold) ? options.threshold : [options.threshold];
|
||||||
}
|
}
|
||||||
@@ -118,7 +122,9 @@ describe('ScrollBreadcrumbsStore', () => {
|
|||||||
(event: string, listener: EventListenerOrEventListenerObject) => {
|
(event: string, listener: EventListenerOrEventListenerObject) => {
|
||||||
if (event === 'scroll') {
|
if (event === 'scroll') {
|
||||||
const index = scrollListeners.indexOf(listener as () => void);
|
const index = scrollListeners.indexOf(listener as () => void);
|
||||||
if (index > -1) scrollListeners.splice(index, 1);
|
if (index > -1) {
|
||||||
|
scrollListeners.splice(index, 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<script module>
|
||||||
|
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||||
|
import BreadcrumbHeader from './BreadcrumbHeader.svelte';
|
||||||
|
|
||||||
|
const { Story } = defineMeta({
|
||||||
|
title: 'Entities/BreadcrumbHeader',
|
||||||
|
component: BreadcrumbHeader,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
parameters: {
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
component:
|
||||||
|
'Fixed header that slides in when the user scrolls past tracked page sections. Reads `scrollBreadcrumbsStore.scrolledPastItems` — renders nothing when the list is empty. Requires the `responsive` context provided by `Providers`.',
|
||||||
|
},
|
||||||
|
story: { inline: false },
|
||||||
|
},
|
||||||
|
layout: 'fullscreen',
|
||||||
|
},
|
||||||
|
argTypes: {},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import Providers from '$shared/lib/storybook/Providers.svelte';
|
||||||
|
import BreadcrumbHeaderSeeded from './BreadcrumbHeaderSeeded.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Story
|
||||||
|
name="With Breadcrumbs"
|
||||||
|
parameters={{
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
story:
|
||||||
|
'Three sections are registered with the breadcrumb store. The story scrolls the iframe so the IntersectionObserver marks them as scrolled-past, revealing the fixed header.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{#snippet template()}
|
||||||
|
<Providers>
|
||||||
|
<BreadcrumbHeaderSeeded />
|
||||||
|
</Providers>
|
||||||
|
{/snippet}
|
||||||
|
</Story>
|
||||||
|
|
||||||
|
<Story
|
||||||
|
name="Empty"
|
||||||
|
parameters={{
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
story:
|
||||||
|
'No sections registered — BreadcrumbHeader renders nothing. This is the initial state before the user scrolls past any tracked section.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{#snippet template()}
|
||||||
|
<Providers>
|
||||||
|
<div style="padding: 2rem; color: #888; font-size: 0.875rem;">
|
||||||
|
BreadcrumbHeader renders nothing when scrolledPastItems is empty.
|
||||||
|
</div>
|
||||||
|
<BreadcrumbHeader />
|
||||||
|
</Providers>
|
||||||
|
{/snippet}
|
||||||
|
</Story>
|
||||||
@@ -44,7 +44,7 @@ function createButtonText(item: BreadcrumbItem) {
|
|||||||
flex items-center justify-between
|
flex items-center justify-between
|
||||||
z-40
|
z-40
|
||||||
bg-surface/90 dark:bg-dark-bg/90 backdrop-blur-md
|
bg-surface/90 dark:bg-dark-bg/90 backdrop-blur-md
|
||||||
border-b border-black/5 dark:border-white/10
|
border-b border-subtle
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<div class="max-w-8xl px-4 sm:px-6 h-full w-full flex items-center justify-between gap-2 sm:gap-4">
|
<div class="max-w-8xl px-4 sm:px-6 h-full w-full flex items-center justify-between gap-2 sm:gap-4">
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { render } from '@testing-library/svelte';
|
||||||
|
import BreadcrumbHeader from './BreadcrumbHeader.svelte';
|
||||||
|
|
||||||
|
const context = new Map([['responsive', { isMobile: false, isMobileOrTablet: false }]]);
|
||||||
|
|
||||||
|
describe('BreadcrumbHeader', () => {
|
||||||
|
it('renders nothing when no sections have been scrolled past', () => {
|
||||||
|
const { container } = render(BreadcrumbHeader, { context });
|
||||||
|
expect(container.firstElementChild).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<script>
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { scrollBreadcrumbsStore } from '../../model';
|
||||||
|
import BreadcrumbHeader from './BreadcrumbHeader.svelte';
|
||||||
|
|
||||||
|
const sections = [
|
||||||
|
{ index: 100, title: 'Introduction' },
|
||||||
|
{ index: 101, title: 'Typography' },
|
||||||
|
{ index: 102, title: 'Spacing' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** @type {HTMLDivElement} */
|
||||||
|
let container;
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
for (const section of sections) {
|
||||||
|
const el = /** @type {HTMLElement} */ (container.querySelector(`[data-story-index="${section.index}"]`));
|
||||||
|
scrollBreadcrumbsStore.add({ index: section.index, title: section.title, element: el }, 96);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Scroll past the sections so IntersectionObserver marks them as
|
||||||
|
* scrolled-past, making scrolledPastItems non-empty and the header visible.
|
||||||
|
*/
|
||||||
|
setTimeout(() => {
|
||||||
|
window.scrollTo({ top: 2000, behavior: 'instant' });
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
for (const { index } of sections) {
|
||||||
|
scrollBreadcrumbsStore.remove(index);
|
||||||
|
}
|
||||||
|
window.scrollTo({ top: 0, behavior: 'instant' });
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div bind:this={container} style="height: 2400px; padding-top: 900px;">
|
||||||
|
{#each sections as section}
|
||||||
|
<div
|
||||||
|
data-story-index={section.index}
|
||||||
|
style="height: 400px; padding: 2rem; background: #f5f5f5; margin-bottom: 1rem;"
|
||||||
|
>
|
||||||
|
{section.title} — scroll up to see the breadcrumb header
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BreadcrumbHeader />
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
<script module>
|
||||||
|
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||||
|
import NavigationWrapper from './NavigationWrapper.svelte';
|
||||||
|
|
||||||
|
const { Story } = defineMeta({
|
||||||
|
title: 'Entities/NavigationWrapper',
|
||||||
|
component: NavigationWrapper,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
parameters: {
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
component:
|
||||||
|
'Thin wrapper that registers an HTML section with `scrollBreadcrumbsStore` via a Svelte use-directive action. Has no visual output of its own — renders `{@render content(registerBreadcrumb)}` where `registerBreadcrumb` is the action to attach with `use:`. On destroy the section is automatically removed from the store.',
|
||||||
|
},
|
||||||
|
story: { inline: false },
|
||||||
|
},
|
||||||
|
layout: 'fullscreen',
|
||||||
|
},
|
||||||
|
argTypes: {
|
||||||
|
index: {
|
||||||
|
control: { type: 'number', min: 0 },
|
||||||
|
description: 'Unique index used for ordering in the breadcrumb trail',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
control: 'text',
|
||||||
|
description: 'Display title shown in the breadcrumb header',
|
||||||
|
},
|
||||||
|
offset: {
|
||||||
|
control: { type: 'number', min: 0 },
|
||||||
|
description: 'Scroll offset in pixels to account for sticky headers',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import type { ComponentProps } from 'svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Story
|
||||||
|
name="Single Section"
|
||||||
|
args={{ index: 0, title: 'Introduction', offset: 96 }}
|
||||||
|
parameters={{
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
story:
|
||||||
|
'A single section registered with NavigationWrapper. The `content` snippet receives the `register` action and applies it via `use:register`.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{#snippet template(args: ComponentProps<typeof NavigationWrapper>)}
|
||||||
|
<NavigationWrapper {...args}>
|
||||||
|
{#snippet content(register)}
|
||||||
|
<section use:register style="padding: 2rem; background: #f5f5f5; min-height: 200px;">
|
||||||
|
<p style="font-size: 0.875rem; color: #555;">
|
||||||
|
Section registered as <strong>{args.title}</strong> at index {args.index}. Scroll past this
|
||||||
|
section to see it appear in the breadcrumb header.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
{/snippet}
|
||||||
|
</NavigationWrapper>
|
||||||
|
{/snippet}
|
||||||
|
</Story>
|
||||||
|
|
||||||
|
<Story
|
||||||
|
name="Multiple Sections"
|
||||||
|
parameters={{
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
story:
|
||||||
|
'Three sequential sections each wrapped in NavigationWrapper with distinct indices and titles. Demonstrates how the breadcrumb trail builds as the user scrolls.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{#snippet template()}
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 0;">
|
||||||
|
<NavigationWrapper index={0} title="Introduction" offset={96}>
|
||||||
|
{#snippet content(register)}
|
||||||
|
<section use:register style="padding: 2rem; background: #f5f5f5; min-height: 300px;">
|
||||||
|
<h2 style="margin: 0 0 0.5rem;">Introduction</h2>
|
||||||
|
<p style="font-size: 0.875rem; color: #555;">
|
||||||
|
Registered as section 0. Scroll down to build the breadcrumb trail.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
{/snippet}
|
||||||
|
</NavigationWrapper>
|
||||||
|
|
||||||
|
<NavigationWrapper index={1} title="Typography" offset={96}>
|
||||||
|
{#snippet content(register)}
|
||||||
|
<section use:register style="padding: 2rem; background: #ebebeb; min-height: 300px;">
|
||||||
|
<h2 style="margin: 0 0 0.5rem;">Typography</h2>
|
||||||
|
<p style="font-size: 0.875rem; color: #555;">Registered as section 1.</p>
|
||||||
|
</section>
|
||||||
|
{/snippet}
|
||||||
|
</NavigationWrapper>
|
||||||
|
|
||||||
|
<NavigationWrapper index={2} title="Spacing" offset={96}>
|
||||||
|
{#snippet content(register)}
|
||||||
|
<section use:register style="padding: 2rem; background: #e0e0e0; min-height: 300px;">
|
||||||
|
<h2 style="margin: 0 0 0.5rem;">Spacing</h2>
|
||||||
|
<p style="font-size: 0.875rem; color: #555;">Registered as section 2.</p>
|
||||||
|
</section>
|
||||||
|
{/snippet}
|
||||||
|
</NavigationWrapper>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</Story>
|
||||||
@@ -19,10 +19,13 @@ vi.mock('$shared/api/api', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import { api } from '$shared/api/api';
|
import { api } from '$shared/api/api';
|
||||||
|
import { queryClient } from '$shared/api/queryClient';
|
||||||
|
import { fontKeys } from '$shared/api/queryKeys';
|
||||||
import {
|
import {
|
||||||
fetchFontsByIds,
|
fetchFontsByIds,
|
||||||
fetchProxyFontById,
|
fetchProxyFontById,
|
||||||
fetchProxyFonts,
|
fetchProxyFonts,
|
||||||
|
seedFontCache,
|
||||||
} from './proxyFonts';
|
} from './proxyFonts';
|
||||||
|
|
||||||
const PROXY_API_URL = 'https://api.glyphdiff.com/api/v1/fonts';
|
const PROXY_API_URL = 'https://api.glyphdiff.com/api/v1/fonts';
|
||||||
@@ -46,6 +49,7 @@ function mockApiGet<T>(data: T) {
|
|||||||
describe('proxyFonts', () => {
|
describe('proxyFonts', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.mocked(api.get).mockReset();
|
vi.mocked(api.get).mockReset();
|
||||||
|
queryClient.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('fetchProxyFonts', () => {
|
describe('fetchProxyFonts', () => {
|
||||||
@@ -168,4 +172,33 @@ describe('proxyFonts', () => {
|
|||||||
expect(result).toEqual([]);
|
expect(result).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('seedFontCache', () => {
|
||||||
|
test('should populate cache with multiple fonts', () => {
|
||||||
|
const fonts = [
|
||||||
|
createMockFont({ id: '1', name: 'A' }),
|
||||||
|
createMockFont({ id: '2', name: 'B' }),
|
||||||
|
];
|
||||||
|
seedFontCache(fonts);
|
||||||
|
expect(queryClient.getQueryData(fontKeys.detail('1'))).toEqual(fonts[0]);
|
||||||
|
expect(queryClient.getQueryData(fontKeys.detail('2'))).toEqual(fonts[1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should update existing cached fonts with new data', () => {
|
||||||
|
const id = 'update-me';
|
||||||
|
queryClient.setQueryData(fontKeys.detail(id), createMockFont({ id, name: 'Old' }));
|
||||||
|
|
||||||
|
const updated = createMockFont({ id, name: 'New' });
|
||||||
|
seedFontCache([updated]);
|
||||||
|
|
||||||
|
expect(queryClient.getQueryData(fontKeys.detail(id))).toEqual(updated);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle empty input arrays gracefully', () => {
|
||||||
|
const spy = vi.spyOn(queryClient, 'setQueryData');
|
||||||
|
seedFontCache([]);
|
||||||
|
expect(spy).not.toHaveBeenCalled();
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,13 +11,23 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { api } from '$shared/api/api';
|
import { api } from '$shared/api/api';
|
||||||
|
import { queryClient } from '$shared/api/queryClient';
|
||||||
|
import { fontKeys } from '$shared/api/queryKeys';
|
||||||
import { buildQueryString } from '$shared/lib/utils';
|
import { buildQueryString } from '$shared/lib/utils';
|
||||||
import type { QueryParams } from '$shared/lib/utils';
|
import type { QueryParams } from '$shared/lib/utils';
|
||||||
import type { UnifiedFont } from '../../model/types';
|
import type { UnifiedFont } from '../../model/types';
|
||||||
import type {
|
|
||||||
FontCategory,
|
/**
|
||||||
FontSubset,
|
* Normalizes cache by seeding individual font entries from collection responses.
|
||||||
} from '../../model/types';
|
* This ensures that a font fetched in a list or batch is available via its detail key.
|
||||||
|
*
|
||||||
|
* @param fonts - Array of fonts to cache
|
||||||
|
*/
|
||||||
|
export function seedFontCache(fonts: UnifiedFont[]): void {
|
||||||
|
fonts.forEach(font => {
|
||||||
|
queryClient.setQueryData(fontKeys.detail(font.id), font);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Proxy API base URL
|
* Proxy API base URL
|
||||||
@@ -87,16 +97,24 @@ export interface ProxyFontsParams extends QueryParams {
|
|||||||
* Includes pagination metadata alongside font data
|
* Includes pagination metadata alongside font data
|
||||||
*/
|
*/
|
||||||
export interface ProxyFontsResponse {
|
export interface ProxyFontsResponse {
|
||||||
/** Array of unified font objects */
|
/**
|
||||||
|
* List of font objects returned by the proxy
|
||||||
|
*/
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,7 +197,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}`;
|
||||||
|
|||||||
+4
-110
@@ -1,110 +1,4 @@
|
|||||||
// Proxy API (primary)
|
export * from './api';
|
||||||
export {
|
export * from './lib';
|
||||||
fetchFontsByIds,
|
export * from './model';
|
||||||
fetchProxyFontById,
|
export * from './ui';
|
||||||
fetchProxyFonts,
|
|
||||||
} from './api/proxy/proxyFonts';
|
|
||||||
export type {
|
|
||||||
ProxyFontsParams,
|
|
||||||
ProxyFontsResponse,
|
|
||||||
} from './api/proxy/proxyFonts';
|
|
||||||
|
|
||||||
export {
|
|
||||||
normalizeFontshareFont,
|
|
||||||
normalizeFontshareFonts,
|
|
||||||
} from './lib/normalize/normalize';
|
|
||||||
export type {
|
|
||||||
// Domain types
|
|
||||||
FontCategory,
|
|
||||||
FontCollectionFilters,
|
|
||||||
FontCollectionSort,
|
|
||||||
// Store types
|
|
||||||
FontCollectionState,
|
|
||||||
FontFeatures,
|
|
||||||
FontFiles,
|
|
||||||
FontItem,
|
|
||||||
FontMetadata,
|
|
||||||
FontProvider,
|
|
||||||
// Fontshare API types
|
|
||||||
FontshareApiModel,
|
|
||||||
FontshareAxis,
|
|
||||||
FontshareDesigner,
|
|
||||||
FontshareFeature,
|
|
||||||
FontshareFont,
|
|
||||||
FontshareLink,
|
|
||||||
FontsharePublisher,
|
|
||||||
FontshareStyle,
|
|
||||||
FontshareStyleProperties,
|
|
||||||
FontshareTag,
|
|
||||||
FontshareWeight,
|
|
||||||
FontStyleUrls,
|
|
||||||
FontSubset,
|
|
||||||
FontVariant,
|
|
||||||
FontWeight,
|
|
||||||
FontWeightItalic,
|
|
||||||
// Normalization types
|
|
||||||
UnifiedFont,
|
|
||||||
UnifiedFontVariant,
|
|
||||||
} from './model';
|
|
||||||
|
|
||||||
export {
|
|
||||||
appliedFontsManager,
|
|
||||||
createUnifiedFontStore,
|
|
||||||
unifiedFontStore,
|
|
||||||
} from './model';
|
|
||||||
|
|
||||||
// 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,
|
|
||||||
FONTHARE_FONTS,
|
|
||||||
generateMixedCategoryFonts,
|
|
||||||
generateMockFonts,
|
|
||||||
generatePaginatedFonts,
|
|
||||||
generateSequentialFilter,
|
|
||||||
GENERIC_FILTERS,
|
|
||||||
getAllMockFonts,
|
|
||||||
getFontsByCategory,
|
|
||||||
getFontsByProvider,
|
|
||||||
GOOGLE_FONTS,
|
|
||||||
MOCK_FILTERS,
|
|
||||||
MOCK_FILTERS_ALL_SELECTED,
|
|
||||||
MOCK_FILTERS_EMPTY,
|
|
||||||
MOCK_FILTERS_SELECTED,
|
|
||||||
MOCK_FONT_STORE_STATES,
|
|
||||||
MOCK_STORES,
|
|
||||||
type MockFilterOptions,
|
|
||||||
type MockFilters,
|
|
||||||
mockFontshareFont,
|
|
||||||
type MockFontshareFontOptions,
|
|
||||||
type MockFontStoreState,
|
|
||||||
// Font mocks
|
|
||||||
mockGoogleFont,
|
|
||||||
// Types
|
|
||||||
type MockGoogleFontOptions,
|
|
||||||
type MockQueryObserverResult,
|
|
||||||
type MockQueryState,
|
|
||||||
mockUnifiedFont,
|
|
||||||
type MockUnifiedFontOptions,
|
|
||||||
UNIFIED_FONTS,
|
|
||||||
} from './lib/mocks';
|
|
||||||
|
|
||||||
// UI elements
|
|
||||||
export {
|
|
||||||
FontApplicator,
|
|
||||||
FontVirtualList,
|
|
||||||
} from './ui';
|
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import {
|
||||||
|
FontNetworkError,
|
||||||
|
FontResponseError,
|
||||||
|
} from './errors';
|
||||||
|
|
||||||
|
describe('FontNetworkError', () => {
|
||||||
|
it('has correct name', () => {
|
||||||
|
const err = new FontNetworkError();
|
||||||
|
expect(err.name).toBe('FontNetworkError');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is instance of Error', () => {
|
||||||
|
expect(new FontNetworkError()).toBeInstanceOf(Error);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores cause', () => {
|
||||||
|
const cause = new Error('network down');
|
||||||
|
const err = new FontNetworkError(cause);
|
||||||
|
expect(err.cause).toBe(cause);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has default message', () => {
|
||||||
|
expect(new FontNetworkError().message).toBe('Failed to fetch fonts from proxy API');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('FontResponseError', () => {
|
||||||
|
it('has correct name', () => {
|
||||||
|
const err = new FontResponseError('response', undefined);
|
||||||
|
expect(err.name).toBe('FontResponseError');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is instance of Error', () => {
|
||||||
|
expect(new FontResponseError('response.fonts', null)).toBeInstanceOf(Error);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores field', () => {
|
||||||
|
const err = new FontResponseError('response.fonts', 42);
|
||||||
|
expect(err.field).toBe('response.fonts');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores received value', () => {
|
||||||
|
const err = new FontResponseError('response.fonts', 42);
|
||||||
|
expect(err.received).toBe(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('message includes field name', () => {
|
||||||
|
const err = new FontResponseError('response.fonts', null);
|
||||||
|
expect(err.message).toContain('response.fonts');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* Thrown when the network request to the proxy API fails.
|
||||||
|
* Wraps the underlying fetch error (timeout, DNS failure, connection refused, etc.).
|
||||||
|
*/
|
||||||
|
export class FontNetworkError extends Error {
|
||||||
|
readonly name = 'FontNetworkError';
|
||||||
|
|
||||||
|
constructor(public readonly cause?: unknown) {
|
||||||
|
super('Failed to fetch fonts from proxy API');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown when the proxy API returns a response with an unexpected shape.
|
||||||
|
*
|
||||||
|
* @property field - The name of the field that failed validation (e.g. `'response'`, `'response.fonts'`).
|
||||||
|
* @property received - The actual value received at that field, for debugging.
|
||||||
|
*/
|
||||||
|
export class FontResponseError extends Error {
|
||||||
|
readonly name = 'FontResponseError';
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public readonly field: string,
|
||||||
|
public readonly received: unknown,
|
||||||
|
) {
|
||||||
|
super(`Invalid proxy API response: ${field}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,10 +1,3 @@
|
|||||||
export {
|
|
||||||
normalizeFontshareFont,
|
|
||||||
normalizeFontshareFonts,
|
|
||||||
normalizeGoogleFont,
|
|
||||||
normalizeGoogleFonts,
|
|
||||||
} from './normalize/normalize';
|
|
||||||
|
|
||||||
export { getFontUrl } from './getFontUrl/getFontUrl';
|
export { getFontUrl } from './getFontUrl/getFontUrl';
|
||||||
|
|
||||||
// Mock data helpers for Storybook and testing
|
// Mock data helpers for Storybook and testing
|
||||||
@@ -25,7 +18,6 @@ export {
|
|||||||
createProvidersFilter,
|
createProvidersFilter,
|
||||||
createSubsetsFilter,
|
createSubsetsFilter,
|
||||||
createSuccessState,
|
createSuccessState,
|
||||||
FONTHARE_FONTS,
|
|
||||||
generateMixedCategoryFonts,
|
generateMixedCategoryFonts,
|
||||||
generateMockFonts,
|
generateMockFonts,
|
||||||
generatePaginatedFonts,
|
generatePaginatedFonts,
|
||||||
@@ -34,7 +26,6 @@ export {
|
|||||||
getAllMockFonts,
|
getAllMockFonts,
|
||||||
getFontsByCategory,
|
getFontsByCategory,
|
||||||
getFontsByProvider,
|
getFontsByProvider,
|
||||||
GOOGLE_FONTS,
|
|
||||||
MOCK_FILTERS,
|
MOCK_FILTERS,
|
||||||
MOCK_FILTERS_ALL_SELECTED,
|
MOCK_FILTERS_ALL_SELECTED,
|
||||||
MOCK_FILTERS_EMPTY,
|
MOCK_FILTERS_EMPTY,
|
||||||
@@ -43,16 +34,20 @@ export {
|
|||||||
MOCK_STORES,
|
MOCK_STORES,
|
||||||
type MockFilterOptions,
|
type MockFilterOptions,
|
||||||
type MockFilters,
|
type MockFilters,
|
||||||
mockFontshareFont,
|
|
||||||
type MockFontshareFontOptions,
|
|
||||||
type MockFontStoreState,
|
type MockFontStoreState,
|
||||||
// Font mocks
|
// Font mocks
|
||||||
mockGoogleFont,
|
|
||||||
// Types
|
// Types
|
||||||
type MockGoogleFontOptions,
|
|
||||||
type MockQueryObserverResult,
|
type MockQueryObserverResult,
|
||||||
type MockQueryState,
|
type MockQueryState,
|
||||||
mockUnifiedFont,
|
mockUnifiedFont,
|
||||||
type MockUnifiedFontOptions,
|
type MockUnifiedFontOptions,
|
||||||
UNIFIED_FONTS,
|
UNIFIED_FONTS,
|
||||||
} from './mocks';
|
} from './mocks';
|
||||||
|
|
||||||
|
export {
|
||||||
|
FontNetworkError,
|
||||||
|
FontResponseError,
|
||||||
|
} from './errors/errors';
|
||||||
|
|
||||||
|
export { createFontRowSizeResolver } from './sizeResolver/createFontRowSizeResolver';
|
||||||
|
export type { FontRowSizeResolverOptions } from './sizeResolver/createFontRowSizeResolver';
|
||||||
|
|||||||
@@ -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,39 +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
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Google Fonts categories
|
|
||||||
*/
|
|
||||||
export const GOOGLE_CATEGORIES: Property<'sans-serif' | 'serif' | 'display' | 'handwriting' | 'monospace'>[] = [
|
|
||||||
{ id: 'sans-serif', name: 'Sans Serif', value: 'sans-serif' },
|
|
||||||
{ id: 'serif', name: 'Serif', value: 'serif' },
|
|
||||||
{ id: 'display', name: 'Display', value: 'display' },
|
|
||||||
{ id: 'handwriting', name: 'Handwriting', value: 'handwriting' },
|
|
||||||
{ id: 'monospace', name: 'Monospace', value: 'monospace' },
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fontshare categories (mapped to common naming)
|
|
||||||
*/
|
|
||||||
export const FONTHARE_CATEGORIES: Property<'sans' | 'serif' | 'slab' | 'display' | 'handwritten' | 'script'>[] = [
|
|
||||||
{ id: 'sans', name: 'Sans', value: 'sans' },
|
|
||||||
{ id: 'serif', name: 'Serif', value: 'serif' },
|
|
||||||
{ id: 'slab', name: 'Slab', value: 'slab' },
|
|
||||||
{ id: 'display', name: 'Display', value: 'display' },
|
|
||||||
{ id: 'handwritten', name: 'Handwritten', value: 'handwritten' },
|
|
||||||
{ id: 'script', name: 'Script', value: 'script' },
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unified categories (combines both providers)
|
* Unified categories (combines both providers)
|
||||||
*/
|
*/
|
||||||
@@ -90,10 +43,10 @@ export const UNIFIED_CATEGORIES: Property<FontCategory>[] = [
|
|||||||
{ id: 'display', name: 'Display', value: 'display' },
|
{ id: 'display', name: 'Display', value: 'display' },
|
||||||
{ id: 'handwriting', name: 'Handwriting', value: 'handwriting' },
|
{ id: 'handwriting', name: 'Handwriting', value: 'handwriting' },
|
||||||
{ id: 'monospace', name: 'Monospace', value: 'monospace' },
|
{ id: 'monospace', name: 'Monospace', value: 'monospace' },
|
||||||
|
{ id: 'slab', name: 'Slab', value: 'slab' },
|
||||||
|
{ id: 'script', name: 'Script', value: 'script' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// FONT SUBSETS
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Common font subsets
|
* Common font subsets
|
||||||
*/
|
*/
|
||||||
@@ -106,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
|
||||||
*/
|
*/
|
||||||
@@ -116,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
|
||||||
*/
|
*/
|
||||||
@@ -160,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
|
||||||
*/
|
*/
|
||||||
@@ -237,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
|
||||||
@@ -260,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' },
|
||||||
@@ -268,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' },
|
||||||
@@ -279,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' },
|
||||||
@@ -296,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 },
|
||||||
@@ -305,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 },
|
||||||
@@ -313,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: [],
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -38,11 +38,6 @@ import type {
|
|||||||
FontSubset,
|
FontSubset,
|
||||||
FontVariant,
|
FontVariant,
|
||||||
} from '$entities/Font/model/types';
|
} from '$entities/Font/model/types';
|
||||||
import type {
|
|
||||||
FontItem,
|
|
||||||
FontshareFont,
|
|
||||||
GoogleFontItem,
|
|
||||||
} from '$entities/Font/model/types';
|
|
||||||
import type {
|
import type {
|
||||||
FontFeatures,
|
FontFeatures,
|
||||||
FontMetadata,
|
FontMetadata,
|
||||||
@@ -50,374 +45,47 @@ import type {
|
|||||||
UnifiedFont,
|
UnifiedFont,
|
||||||
} from '$entities/Font/model/types';
|
} from '$entities/Font/model/types';
|
||||||
|
|
||||||
// GOOGLE FONTS MOCKS
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Options for creating a mock Google Font
|
|
||||||
*/
|
|
||||||
export interface MockGoogleFontOptions {
|
|
||||||
/** Font family name (default: 'Mock Font') */
|
|
||||||
family?: string;
|
|
||||||
/** Font category (default: 'sans-serif') */
|
|
||||||
category?: 'sans-serif' | 'serif' | 'display' | 'handwriting' | 'monospace';
|
|
||||||
/** Font variants (default: ['regular', '700', 'italic', '700italic']) */
|
|
||||||
variants?: FontVariant[];
|
|
||||||
/** Font subsets (default: ['latin']) */
|
|
||||||
subsets?: string[];
|
|
||||||
/** Font version (default: 'v30') */
|
|
||||||
version?: string;
|
|
||||||
/** Last modified date (default: current ISO date) */
|
|
||||||
lastModified?: string;
|
|
||||||
/** Custom file URLs (if not provided, mock URLs are generated) */
|
|
||||||
files?: Partial<Record<FontVariant, string>>;
|
|
||||||
/** Popularity rank (1 = most popular) */
|
|
||||||
popularity?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Default mock Google Font
|
|
||||||
*/
|
|
||||||
export function mockGoogleFont(options: MockGoogleFontOptions = {}): GoogleFontItem {
|
|
||||||
const {
|
|
||||||
family = 'Mock Font',
|
|
||||||
category = 'sans-serif',
|
|
||||||
variants = ['regular', '700', 'italic', '700italic'],
|
|
||||||
subsets = ['latin'],
|
|
||||||
version = 'v30',
|
|
||||||
lastModified = new Date().toISOString().split('T')[0],
|
|
||||||
files,
|
|
||||||
popularity = 1,
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
const baseUrl = `https://fonts.gstatic.com/s/${family.toLowerCase().replace(/\s+/g, '')}/${version}`;
|
|
||||||
|
|
||||||
return {
|
|
||||||
family,
|
|
||||||
category,
|
|
||||||
variants: variants as FontVariant[],
|
|
||||||
subsets,
|
|
||||||
version,
|
|
||||||
lastModified,
|
|
||||||
files: files ?? {
|
|
||||||
regular: `${baseUrl}/KFOmCnqEu92Fr1Me4W.woff2`,
|
|
||||||
'700': `${baseUrl}/KFOlCnqEu92Fr1MmWUlfBBc9.woff2`,
|
|
||||||
italic: `${baseUrl}/KFOkCnqEu92Fr1Mu51xIIzI.woff2`,
|
|
||||||
'700italic': `${baseUrl}/KFOjCnqEu92Fr1Mu51TzBic6CsQ.woff2`,
|
|
||||||
},
|
|
||||||
menu: `https://fonts.googleapis.com/css2?family=${encodeURIComponent(family)}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Preset Google Font mocks
|
|
||||||
*/
|
|
||||||
export const GOOGLE_FONTS: Record<string, GoogleFontItem> = {
|
|
||||||
roboto: mockGoogleFont({
|
|
||||||
family: 'Roboto',
|
|
||||||
category: 'sans-serif',
|
|
||||||
variants: ['100', '300', '400', '500', '700', '900', 'italic', '700italic'],
|
|
||||||
subsets: ['latin', 'latin-ext', 'cyrillic', 'greek'],
|
|
||||||
popularity: 1,
|
|
||||||
}),
|
|
||||||
openSans: mockGoogleFont({
|
|
||||||
family: 'Open Sans',
|
|
||||||
category: 'sans-serif',
|
|
||||||
variants: ['300', '400', '500', '600', '700', '800', 'italic', '700italic'],
|
|
||||||
subsets: ['latin', 'latin-ext', 'cyrillic', 'greek'],
|
|
||||||
popularity: 2,
|
|
||||||
}),
|
|
||||||
lato: mockGoogleFont({
|
|
||||||
family: 'Lato',
|
|
||||||
category: 'sans-serif',
|
|
||||||
variants: ['100', '300', '400', '700', '900', 'italic', '700italic'],
|
|
||||||
subsets: ['latin', 'latin-ext'],
|
|
||||||
popularity: 3,
|
|
||||||
}),
|
|
||||||
playfairDisplay: mockGoogleFont({
|
|
||||||
family: 'Playfair Display',
|
|
||||||
category: 'serif',
|
|
||||||
variants: ['400', '500', '600', '700', '800', '900', 'italic', '700italic'],
|
|
||||||
subsets: ['latin', 'latin-ext', 'cyrillic'],
|
|
||||||
popularity: 10,
|
|
||||||
}),
|
|
||||||
montserrat: mockGoogleFont({
|
|
||||||
family: 'Montserrat',
|
|
||||||
category: 'sans-serif',
|
|
||||||
variants: ['100', '200', '300', '400', '500', '600', '700', '800', '900', 'italic', '700italic'],
|
|
||||||
subsets: ['latin', 'latin-ext', 'cyrillic', 'vietnamese'],
|
|
||||||
popularity: 4,
|
|
||||||
}),
|
|
||||||
sourceSansPro: mockGoogleFont({
|
|
||||||
family: 'Source Sans Pro',
|
|
||||||
category: 'sans-serif',
|
|
||||||
variants: ['200', '300', '400', '600', '700', '900', 'italic', '700italic'],
|
|
||||||
subsets: ['latin', 'latin-ext', 'cyrillic', 'greek', 'vietnamese'],
|
|
||||||
popularity: 5,
|
|
||||||
}),
|
|
||||||
merriweather: mockGoogleFont({
|
|
||||||
family: 'Merriweather',
|
|
||||||
category: 'serif',
|
|
||||||
variants: ['300', '400', '700', '900', 'italic', '700italic'],
|
|
||||||
subsets: ['latin', 'latin-ext', 'cyrillic', 'vietnamese'],
|
|
||||||
popularity: 15,
|
|
||||||
}),
|
|
||||||
robotoSlab: mockGoogleFont({
|
|
||||||
family: 'Roboto Slab',
|
|
||||||
category: 'serif',
|
|
||||||
variants: ['100', '300', '400', '500', '700', '900'],
|
|
||||||
subsets: ['latin', 'latin-ext', 'cyrillic', 'greek', 'vietnamese'],
|
|
||||||
popularity: 8,
|
|
||||||
}),
|
|
||||||
oswald: mockGoogleFont({
|
|
||||||
family: 'Oswald',
|
|
||||||
category: 'sans-serif',
|
|
||||||
variants: ['200', '300', '400', '500', '600', '700'],
|
|
||||||
subsets: ['latin', 'latin-ext', 'vietnamese'],
|
|
||||||
popularity: 6,
|
|
||||||
}),
|
|
||||||
raleway: mockGoogleFont({
|
|
||||||
family: 'Raleway',
|
|
||||||
category: 'sans-serif',
|
|
||||||
variants: ['100', '200', '300', '400', '500', '600', '700', '800', '900', 'italic'],
|
|
||||||
subsets: ['latin', 'latin-ext', 'cyrillic', 'vietnamese'],
|
|
||||||
popularity: 7,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
// FONTHARE MOCKS
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Options for creating a mock Fontshare font
|
|
||||||
*/
|
|
||||||
export interface MockFontshareFontOptions {
|
|
||||||
/** Font name (default: 'Mock Font') */
|
|
||||||
name?: string;
|
|
||||||
/** URL-friendly slug (default: derived from name) */
|
|
||||||
slug?: string;
|
|
||||||
/** Font category (default: 'sans') */
|
|
||||||
category?: 'sans' | 'serif' | 'slab' | 'display' | 'handwritten' | 'script' | 'mono';
|
|
||||||
/** Script (default: 'latin') */
|
|
||||||
script?: string;
|
|
||||||
/** Whether this is a variable font (default: false) */
|
|
||||||
isVariable?: boolean;
|
|
||||||
/** Font version (default: '1.0') */
|
|
||||||
version?: string;
|
|
||||||
/** Popularity/views count (default: 1000) */
|
|
||||||
views?: number;
|
|
||||||
/** Usage tags */
|
|
||||||
tags?: string[];
|
|
||||||
/** Font weights available */
|
|
||||||
weights?: number[];
|
|
||||||
/** Publisher name */
|
|
||||||
publisher?: string;
|
|
||||||
/** Designer name */
|
|
||||||
designer?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a mock Fontshare style
|
|
||||||
*/
|
|
||||||
function mockFontshareStyle(
|
|
||||||
weight: number,
|
|
||||||
isItalic: boolean,
|
|
||||||
isVariable: boolean,
|
|
||||||
slug: string,
|
|
||||||
): FontshareFont['styles'][number] {
|
|
||||||
const weightLabel = weight === 400 ? 'Regular' : weight === 700 ? 'Bold' : weight.toString();
|
|
||||||
const suffix = isItalic ? 'italic' : '';
|
|
||||||
const variablePrefix = isVariable ? 'variable-' : '';
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: `style-${weight}${isItalic ? '-italic' : ''}`,
|
|
||||||
default: weight === 400 && !isItalic,
|
|
||||||
file: `//cdn.fontshare.com/wf/${slug}-${variablePrefix}${weight}${suffix}.woff2`,
|
|
||||||
is_italic: isItalic,
|
|
||||||
is_variable: isVariable,
|
|
||||||
properties: {},
|
|
||||||
weight: {
|
|
||||||
label: isVariable ? 'Variable' + (isItalic ? ' Italic' : '') : weightLabel,
|
|
||||||
name: isVariable ? 'Variable' + (isItalic ? 'Italic' : '') : weightLabel,
|
|
||||||
native_name: null,
|
|
||||||
number: isVariable ? 0 : weight,
|
|
||||||
weight: isVariable ? 0 : weight,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Default mock Fontshare font
|
|
||||||
*/
|
|
||||||
export function mockFontshareFont(options: MockFontshareFontOptions = {}): FontshareFont {
|
|
||||||
const {
|
|
||||||
name = 'Mock Font',
|
|
||||||
slug = name.toLowerCase().replace(/\s+/g, '-'),
|
|
||||||
category = 'sans',
|
|
||||||
script = 'latin',
|
|
||||||
isVariable = false,
|
|
||||||
version = '1.0',
|
|
||||||
views = 1000,
|
|
||||||
tags = [],
|
|
||||||
weights = [400, 700],
|
|
||||||
publisher = 'Mock Foundry',
|
|
||||||
designer = 'Mock Designer',
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
// Generate styles based on weights and variable setting
|
|
||||||
const styles: FontshareFont['styles'] = isVariable
|
|
||||||
? [
|
|
||||||
mockFontshareStyle(0, false, true, slug),
|
|
||||||
mockFontshareStyle(0, true, true, slug),
|
|
||||||
]
|
|
||||||
: weights.flatMap(weight => [
|
|
||||||
mockFontshareStyle(weight, false, false, slug),
|
|
||||||
mockFontshareStyle(weight, true, false, slug),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: `mock-${slug}`,
|
|
||||||
name,
|
|
||||||
native_name: null,
|
|
||||||
slug,
|
|
||||||
category,
|
|
||||||
script,
|
|
||||||
publisher: {
|
|
||||||
bio: `Mock publisher bio for ${publisher}`,
|
|
||||||
email: null,
|
|
||||||
id: `pub-${slug}`,
|
|
||||||
links: [],
|
|
||||||
name: publisher,
|
|
||||||
},
|
|
||||||
designers: [
|
|
||||||
{
|
|
||||||
bio: `Mock designer bio for ${designer}`,
|
|
||||||
links: [],
|
|
||||||
name: designer,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
related_families: null,
|
|
||||||
display_publisher_as_designer: false,
|
|
||||||
trials_enabled: true,
|
|
||||||
show_latin_metrics: false,
|
|
||||||
license_type: 'ofl',
|
|
||||||
languages: 'English, Spanish, French, German',
|
|
||||||
inserted_at: '2021-03-12T20:49:05Z',
|
|
||||||
story: `<p>A mock font story for ${name}.</p>`,
|
|
||||||
version,
|
|
||||||
views,
|
|
||||||
views_recent: Math.floor(views * 0.1),
|
|
||||||
is_hot: views > 5000,
|
|
||||||
is_new: views < 500,
|
|
||||||
is_shortlisted: null,
|
|
||||||
is_top: views > 10000,
|
|
||||||
axes: isVariable
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
name: 'Weight',
|
|
||||||
property: 'wght',
|
|
||||||
range_default: 400,
|
|
||||||
range_left: 300,
|
|
||||||
range_right: 700,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: [],
|
|
||||||
font_tags: tags.map(name => ({ name })),
|
|
||||||
features: [],
|
|
||||||
styles,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Preset Fontshare font mocks
|
|
||||||
*/
|
|
||||||
export const FONTHARE_FONTS: Record<string, FontshareFont> = {
|
|
||||||
satoshi: mockFontshareFont({
|
|
||||||
name: 'Satoshi',
|
|
||||||
slug: 'satoshi',
|
|
||||||
category: 'sans',
|
|
||||||
isVariable: true,
|
|
||||||
views: 15000,
|
|
||||||
tags: ['Branding', 'Logos', 'Editorial'],
|
|
||||||
publisher: 'Indian Type Foundry',
|
|
||||||
designer: 'Denis Shelabovets',
|
|
||||||
}),
|
|
||||||
generalSans: mockFontshareFont({
|
|
||||||
name: 'General Sans',
|
|
||||||
slug: 'general-sans',
|
|
||||||
category: 'sans',
|
|
||||||
isVariable: true,
|
|
||||||
views: 12000,
|
|
||||||
tags: ['UI', 'Branding', 'Display'],
|
|
||||||
publisher: 'Indestructible Type',
|
|
||||||
designer: 'Eugene Tantsur',
|
|
||||||
}),
|
|
||||||
clashDisplay: mockFontshareFont({
|
|
||||||
name: 'Clash Display',
|
|
||||||
slug: 'clash-display',
|
|
||||||
category: 'display',
|
|
||||||
isVariable: false,
|
|
||||||
views: 8000,
|
|
||||||
tags: ['Headlines', 'Posters', 'Branding'],
|
|
||||||
weights: [400, 500, 600, 700],
|
|
||||||
publisher: 'Letterogika',
|
|
||||||
designer: 'Matěj Trnka',
|
|
||||||
}),
|
|
||||||
fonta: mockFontshareFont({
|
|
||||||
name: 'Fonta',
|
|
||||||
slug: 'fonta',
|
|
||||||
category: 'serif',
|
|
||||||
isVariable: false,
|
|
||||||
views: 5000,
|
|
||||||
tags: ['Editorial', 'Books', 'Magazines'],
|
|
||||||
weights: [300, 400, 500, 600, 700],
|
|
||||||
publisher: 'Fonta',
|
|
||||||
designer: 'Alexei Vanyashin',
|
|
||||||
}),
|
|
||||||
aileron: mockFontshareFont({
|
|
||||||
name: 'Aileron',
|
|
||||||
slug: 'aileron',
|
|
||||||
category: 'sans',
|
|
||||||
isVariable: false,
|
|
||||||
views: 3000,
|
|
||||||
tags: ['Display', 'Headlines'],
|
|
||||||
weights: [100, 200, 300, 400, 500, 600, 700, 800, 900],
|
|
||||||
publisher: 'Sorkin Type',
|
|
||||||
designer: 'Sorkin Type',
|
|
||||||
}),
|
|
||||||
beVietnamPro: mockFontshareFont({
|
|
||||||
name: 'Be Vietnam Pro',
|
|
||||||
slug: 'be-vietnam-pro',
|
|
||||||
category: 'sans',
|
|
||||||
isVariable: true,
|
|
||||||
views: 20000,
|
|
||||||
tags: ['UI', 'App', 'Web'],
|
|
||||||
publisher: 'ildefox',
|
|
||||||
designer: 'Manh Nguyen',
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
// UNIFIED FONT MOCKS
|
// UNIFIED FONT MOCKS
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,17 +26,11 @@
|
|||||||
|
|
||||||
// Font mocks
|
// Font mocks
|
||||||
export {
|
export {
|
||||||
FONTHARE_FONTS,
|
|
||||||
generateMixedCategoryFonts,
|
generateMixedCategoryFonts,
|
||||||
generateMockFonts,
|
generateMockFonts,
|
||||||
getAllMockFonts,
|
getAllMockFonts,
|
||||||
getFontsByCategory,
|
getFontsByCategory,
|
||||||
getFontsByProvider,
|
getFontsByProvider,
|
||||||
GOOGLE_FONTS,
|
|
||||||
mockFontshareFont,
|
|
||||||
type MockFontshareFontOptions,
|
|
||||||
mockGoogleFont,
|
|
||||||
type MockGoogleFontOptions,
|
|
||||||
mockUnifiedFont,
|
mockUnifiedFont,
|
||||||
type MockUnifiedFontOptions,
|
type MockUnifiedFontOptions,
|
||||||
UNIFIED_FONTS,
|
UNIFIED_FONTS,
|
||||||
@@ -51,10 +45,8 @@ export {
|
|||||||
createSubsetsFilter,
|
createSubsetsFilter,
|
||||||
FONT_PROVIDERS,
|
FONT_PROVIDERS,
|
||||||
FONT_SUBSETS,
|
FONT_SUBSETS,
|
||||||
FONTHARE_CATEGORIES,
|
|
||||||
generateSequentialFilter,
|
generateSequentialFilter,
|
||||||
GENERIC_FILTERS,
|
GENERIC_FILTERS,
|
||||||
GOOGLE_CATEGORIES,
|
|
||||||
MOCK_FILTERS,
|
MOCK_FILTERS,
|
||||||
MOCK_FILTERS_ALL_SELECTED,
|
MOCK_FILTERS_ALL_SELECTED,
|
||||||
MOCK_FILTERS_EMPTY,
|
MOCK_FILTERS_EMPTY,
|
||||||
|
|||||||
@@ -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.
|
||||||
*
|
*
|
||||||
@@ -20,7 +16,7 @@
|
|||||||
* const successState = createMockQueryState({ status: 'success', data: mockFonts });
|
* const successState = createMockQueryState({ status: 'success', data: mockFonts });
|
||||||
*
|
*
|
||||||
* // Use preset stores
|
* // Use preset stores
|
||||||
* const mockFontStore = MOCK_STORES.unifiedFontStore();
|
* const mockFontStore = createMockFontStore();
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -35,27 +31,73 @@ import {
|
|||||||
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 +105,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 +226,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 +318,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 +331,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 +341,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 +355,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 +369,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 +383,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 +394,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 +407,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 +423,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 +439,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 +454,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 +489,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 +571,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 +588,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;
|
||||||
},
|
},
|
||||||
@@ -459,6 +666,186 @@ export const MOCK_STORES = {
|
|||||||
resetFilters: () => {},
|
resetFilters: () => {},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* Create a mock FontStore object
|
||||||
|
* Matches FontStore's public API for Storybook use
|
||||||
|
*/
|
||||||
|
fontStore: (config: {
|
||||||
|
/**
|
||||||
|
* Preset font list
|
||||||
|
*/
|
||||||
|
fonts?: UnifiedFont[];
|
||||||
|
/**
|
||||||
|
* Total item count
|
||||||
|
*/
|
||||||
|
total?: number;
|
||||||
|
/**
|
||||||
|
* Items per page
|
||||||
|
*/
|
||||||
|
limit?: number;
|
||||||
|
/**
|
||||||
|
* Pagination offset
|
||||||
|
*/
|
||||||
|
offset?: number;
|
||||||
|
/**
|
||||||
|
* Loading flag
|
||||||
|
*/
|
||||||
|
isLoading?: boolean;
|
||||||
|
/**
|
||||||
|
* Fetching flag
|
||||||
|
*/
|
||||||
|
isFetching?: boolean;
|
||||||
|
/**
|
||||||
|
* Error flag
|
||||||
|
*/
|
||||||
|
isError?: boolean;
|
||||||
|
/**
|
||||||
|
* Catch-all error object
|
||||||
|
*/
|
||||||
|
error?: Error | null;
|
||||||
|
/**
|
||||||
|
* Has more pages flag
|
||||||
|
*/
|
||||||
|
hasMore?: boolean;
|
||||||
|
/**
|
||||||
|
* Current page number
|
||||||
|
*/
|
||||||
|
page?: number;
|
||||||
|
} = {}) => {
|
||||||
|
const {
|
||||||
|
fonts: mockFonts = Object.values(UNIFIED_FONTS).slice(0, 5),
|
||||||
|
total: mockTotal = mockFonts.length,
|
||||||
|
limit = 50,
|
||||||
|
offset = 0,
|
||||||
|
isLoading = false,
|
||||||
|
isFetching = false,
|
||||||
|
isError = false,
|
||||||
|
error = null,
|
||||||
|
hasMore = false,
|
||||||
|
page = 1,
|
||||||
|
} = config;
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(mockTotal / limit);
|
||||||
|
const state = {
|
||||||
|
params: { limit },
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State getters
|
||||||
|
/**
|
||||||
|
* Current mock parameters
|
||||||
|
*/
|
||||||
|
get params() {
|
||||||
|
return state.params;
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Mock font list
|
||||||
|
*/
|
||||||
|
get fonts() {
|
||||||
|
return mockFonts;
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Mock loading state
|
||||||
|
*/
|
||||||
|
get isLoading() {
|
||||||
|
return isLoading;
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Mock fetching state
|
||||||
|
*/
|
||||||
|
get isFetching() {
|
||||||
|
return isFetching;
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Mock error state
|
||||||
|
*/
|
||||||
|
get isError() {
|
||||||
|
return isError;
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Mock error object
|
||||||
|
*/
|
||||||
|
get error() {
|
||||||
|
return error;
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Mock empty state check
|
||||||
|
*/
|
||||||
|
get isEmpty() {
|
||||||
|
return !isLoading && !isFetching && mockFonts.length === 0;
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Mock pagination metadata
|
||||||
|
*/
|
||||||
|
get pagination() {
|
||||||
|
return {
|
||||||
|
total: mockTotal,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
hasMore,
|
||||||
|
page,
|
||||||
|
totalPages,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
// Category getters
|
||||||
|
/**
|
||||||
|
* Derived sans-serif filter
|
||||||
|
*/
|
||||||
|
get sansSerifFonts() {
|
||||||
|
return mockFonts.filter(f => f.category === 'sans-serif');
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Derived serif filter
|
||||||
|
*/
|
||||||
|
get serifFonts() {
|
||||||
|
return mockFonts.filter(f => f.category === 'serif');
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Derived display filter
|
||||||
|
*/
|
||||||
|
get displayFonts() {
|
||||||
|
return mockFonts.filter(f => f.category === 'display');
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Derived handwriting filter
|
||||||
|
*/
|
||||||
|
get handwritingFonts() {
|
||||||
|
return mockFonts.filter(f => f.category === 'handwriting');
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Derived monospace filter
|
||||||
|
*/
|
||||||
|
get monospaceFonts() {
|
||||||
|
return mockFonts.filter(f => f.category === 'monospace');
|
||||||
|
},
|
||||||
|
// Lifecycle
|
||||||
|
destroy() {},
|
||||||
|
// Param management
|
||||||
|
setParams(_updates: Record<string, unknown>) {},
|
||||||
|
invalidate() {},
|
||||||
|
// Async operations (no-op for Storybook)
|
||||||
|
refetch() {},
|
||||||
|
prefetch() {},
|
||||||
|
cancel() {},
|
||||||
|
getCachedData() {
|
||||||
|
return mockFonts.length > 0 ? mockFonts : undefined;
|
||||||
|
},
|
||||||
|
setQueryData() {},
|
||||||
|
// Filter shortcuts
|
||||||
|
setProviders() {},
|
||||||
|
setCategories() {},
|
||||||
|
setSubsets() {},
|
||||||
|
setSearch() {},
|
||||||
|
setSort() {},
|
||||||
|
// Pagination navigation
|
||||||
|
nextPage() {},
|
||||||
|
prevPage() {},
|
||||||
|
goToPage() {},
|
||||||
|
setLimit(_limit: number) {
|
||||||
|
state.params.limit = _limit;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// REACTIVE STATE MOCKS
|
// REACTIVE STATE MOCKS
|
||||||
|
|||||||
@@ -1,582 +0,0 @@
|
|||||||
import {
|
|
||||||
describe,
|
|
||||||
expect,
|
|
||||||
it,
|
|
||||||
} from 'vitest';
|
|
||||||
import type {
|
|
||||||
FontItem,
|
|
||||||
FontshareFont,
|
|
||||||
GoogleFontItem,
|
|
||||||
} from '../../model/types';
|
|
||||||
import {
|
|
||||||
normalizeFontshareFont,
|
|
||||||
normalizeFontshareFonts,
|
|
||||||
normalizeGoogleFont,
|
|
||||||
normalizeGoogleFonts,
|
|
||||||
} from './normalize';
|
|
||||||
|
|
||||||
describe('Font Normalization', () => {
|
|
||||||
describe('normalizeGoogleFont', () => {
|
|
||||||
const mockGoogleFont: GoogleFontItem = {
|
|
||||||
family: 'Roboto',
|
|
||||||
category: 'sans-serif',
|
|
||||||
variants: ['regular', '700', 'italic', '700italic'],
|
|
||||||
subsets: ['latin', 'latin-ext'],
|
|
||||||
files: {
|
|
||||||
regular: 'https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu72xKOzY.woff2',
|
|
||||||
'700': 'https://fonts.gstatic.com/s/roboto/v30/KFOlCnqEu92Fr1Mu72xWUlvAx05IsDqlA.woff2',
|
|
||||||
italic: 'https://fonts.gstatic.com/s/roboto/v30/KFOkCnqEu92Fr1Mu51xIIzI.woff2',
|
|
||||||
'700italic': 'https://fonts.gstatic.com/s/roboto/v30/KFOjCnqEu92Fr1Mu51TzBic6CsQ.woff2',
|
|
||||||
},
|
|
||||||
version: 'v30',
|
|
||||||
lastModified: '2022-01-01',
|
|
||||||
menu: 'https://fonts.googleapis.com/css2?family=Roboto',
|
|
||||||
};
|
|
||||||
|
|
||||||
it('normalizes Google Font to unified model', () => {
|
|
||||||
const result = normalizeGoogleFont(mockGoogleFont);
|
|
||||||
|
|
||||||
expect(result.id).toBe('Roboto');
|
|
||||||
expect(result.name).toBe('Roboto');
|
|
||||||
expect(result.provider).toBe('google');
|
|
||||||
expect(result.category).toBe('sans-serif');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('maps font variants correctly', () => {
|
|
||||||
const result = normalizeGoogleFont(mockGoogleFont);
|
|
||||||
|
|
||||||
expect(result.variants).toEqual(['regular', '700', 'italic', '700italic']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('maps subsets correctly', () => {
|
|
||||||
const result = normalizeGoogleFont(mockGoogleFont);
|
|
||||||
|
|
||||||
expect(result.subsets).toContain('latin');
|
|
||||||
expect(result.subsets).toContain('latin-ext');
|
|
||||||
expect(result.subsets).toHaveLength(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('maps style URLs correctly', () => {
|
|
||||||
const result = normalizeGoogleFont(mockGoogleFont);
|
|
||||||
|
|
||||||
expect(result.styles.regular).toBeDefined();
|
|
||||||
expect(result.styles.bold).toBeDefined();
|
|
||||||
expect(result.styles.italic).toBeDefined();
|
|
||||||
expect(result.styles.boldItalic).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('includes metadata', () => {
|
|
||||||
const result = normalizeGoogleFont(mockGoogleFont);
|
|
||||||
|
|
||||||
expect(result.metadata.cachedAt).toBeDefined();
|
|
||||||
expect(result.metadata.version).toBe('v30');
|
|
||||||
expect(result.metadata.lastModified).toBe('2022-01-01');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('marks Google Fonts as non-variable', () => {
|
|
||||||
const result = normalizeGoogleFont(mockGoogleFont);
|
|
||||||
|
|
||||||
expect(result.features.isVariable).toBe(false);
|
|
||||||
expect(result.features.tags).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles sans-serif category', () => {
|
|
||||||
const font: FontItem = { ...mockGoogleFont, category: 'sans-serif' };
|
|
||||||
const result = normalizeGoogleFont(font);
|
|
||||||
|
|
||||||
expect(result.category).toBe('sans-serif');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles serif category', () => {
|
|
||||||
const font: FontItem = { ...mockGoogleFont, category: 'serif' };
|
|
||||||
const result = normalizeGoogleFont(font);
|
|
||||||
|
|
||||||
expect(result.category).toBe('serif');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles display category', () => {
|
|
||||||
const font: FontItem = { ...mockGoogleFont, category: 'display' };
|
|
||||||
const result = normalizeGoogleFont(font);
|
|
||||||
|
|
||||||
expect(result.category).toBe('display');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles handwriting category', () => {
|
|
||||||
const font: FontItem = { ...mockGoogleFont, category: 'handwriting' };
|
|
||||||
const result = normalizeGoogleFont(font);
|
|
||||||
|
|
||||||
expect(result.category).toBe('handwriting');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles cursive category (maps to handwriting)', () => {
|
|
||||||
const font: FontItem = { ...mockGoogleFont, category: 'cursive' as any };
|
|
||||||
const result = normalizeGoogleFont(font);
|
|
||||||
|
|
||||||
expect(result.category).toBe('handwriting');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles monospace category', () => {
|
|
||||||
const font: FontItem = { ...mockGoogleFont, category: 'monospace' };
|
|
||||||
const result = normalizeGoogleFont(font);
|
|
||||||
|
|
||||||
expect(result.category).toBe('monospace');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('filters invalid subsets', () => {
|
|
||||||
const font = {
|
|
||||||
...mockGoogleFont,
|
|
||||||
subsets: ['latin', 'latin-ext', 'invalid-subset'],
|
|
||||||
};
|
|
||||||
const result = normalizeGoogleFont(font);
|
|
||||||
|
|
||||||
expect(result.subsets).not.toContain('invalid-subset');
|
|
||||||
expect(result.subsets).toHaveLength(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('maps variant weights correctly', () => {
|
|
||||||
const font: GoogleFontItem = {
|
|
||||||
...mockGoogleFont,
|
|
||||||
variants: ['regular', '100', '400', '700', '900'] as any,
|
|
||||||
};
|
|
||||||
const result = normalizeGoogleFont(font);
|
|
||||||
|
|
||||||
expect(result.variants).toContain('regular');
|
|
||||||
expect(result.variants).toContain('100');
|
|
||||||
expect(result.variants).toContain('400');
|
|
||||||
expect(result.variants).toContain('700');
|
|
||||||
expect(result.variants).toContain('900');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('normalizeFontshareFont', () => {
|
|
||||||
const mockFontshareFont: FontshareFont = {
|
|
||||||
id: '20e9fcdc-1e41-4559-a43d-1ede0adc8896',
|
|
||||||
name: 'Satoshi',
|
|
||||||
native_name: null,
|
|
||||||
slug: 'satoshi',
|
|
||||||
category: 'Sans',
|
|
||||||
script: 'latin',
|
|
||||||
publisher: {
|
|
||||||
bio: 'Indian Type Foundry',
|
|
||||||
email: null,
|
|
||||||
id: 'test-id',
|
|
||||||
links: [],
|
|
||||||
name: 'Indian Type Foundry',
|
|
||||||
},
|
|
||||||
designers: [
|
|
||||||
{
|
|
||||||
bio: 'Designer bio',
|
|
||||||
links: [],
|
|
||||||
name: 'Designer Name',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
related_families: null,
|
|
||||||
display_publisher_as_designer: false,
|
|
||||||
trials_enabled: true,
|
|
||||||
show_latin_metrics: false,
|
|
||||||
license_type: 'itf_ffl',
|
|
||||||
languages: 'Afar, Afrikaans',
|
|
||||||
inserted_at: '2021-03-12T20:49:05Z',
|
|
||||||
story: '<p>Font story</p>',
|
|
||||||
version: '1.0',
|
|
||||||
views: 10000,
|
|
||||||
views_recent: 500,
|
|
||||||
is_hot: true,
|
|
||||||
is_new: false,
|
|
||||||
is_shortlisted: false,
|
|
||||||
is_top: true,
|
|
||||||
axes: [],
|
|
||||||
font_tags: [
|
|
||||||
{ name: 'Branding' },
|
|
||||||
{ name: 'Logos' },
|
|
||||||
],
|
|
||||||
features: [
|
|
||||||
{
|
|
||||||
name: 'Alternate t',
|
|
||||||
on_by_default: false,
|
|
||||||
tag: 'ss01',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
styles: [
|
|
||||||
{
|
|
||||||
id: 'style-id-1',
|
|
||||||
default: true,
|
|
||||||
file: '//cdn.fontshare.com/wf/satoshi.woff2',
|
|
||||||
is_italic: false,
|
|
||||||
is_variable: false,
|
|
||||||
properties: {},
|
|
||||||
weight: {
|
|
||||||
label: 'Regular',
|
|
||||||
name: 'Regular',
|
|
||||||
native_name: null,
|
|
||||||
number: 400,
|
|
||||||
weight: 400,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'style-id-2',
|
|
||||||
default: false,
|
|
||||||
file: '//cdn.fontshare.com/wf/satoshi-bold.woff2',
|
|
||||||
is_italic: false,
|
|
||||||
is_variable: false,
|
|
||||||
properties: {},
|
|
||||||
weight: {
|
|
||||||
label: 'Bold',
|
|
||||||
name: 'Bold',
|
|
||||||
native_name: null,
|
|
||||||
number: 700,
|
|
||||||
weight: 700,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'style-id-3',
|
|
||||||
default: false,
|
|
||||||
file: '//cdn.fontshare.com/wf/satoshi-italic.woff2',
|
|
||||||
is_italic: true,
|
|
||||||
is_variable: false,
|
|
||||||
properties: {},
|
|
||||||
weight: {
|
|
||||||
label: 'Regular',
|
|
||||||
name: 'Regular',
|
|
||||||
native_name: null,
|
|
||||||
number: 400,
|
|
||||||
weight: 400,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'style-id-4',
|
|
||||||
default: false,
|
|
||||||
file: '//cdn.fontshare.com/wf/satoshi-bolditalic.woff2',
|
|
||||||
is_italic: true,
|
|
||||||
is_variable: false,
|
|
||||||
properties: {},
|
|
||||||
weight: {
|
|
||||||
label: 'Bold',
|
|
||||||
name: 'Bold',
|
|
||||||
native_name: null,
|
|
||||||
number: 700,
|
|
||||||
weight: 700,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
it('normalizes Fontshare font to unified model', () => {
|
|
||||||
const result = normalizeFontshareFont(mockFontshareFont);
|
|
||||||
|
|
||||||
expect(result.id).toBe('satoshi');
|
|
||||||
expect(result.name).toBe('Satoshi');
|
|
||||||
expect(result.provider).toBe('fontshare');
|
|
||||||
expect(result.category).toBe('sans-serif');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses slug as unique identifier', () => {
|
|
||||||
const result = normalizeFontshareFont(mockFontshareFont);
|
|
||||||
|
|
||||||
expect(result.id).toBe('satoshi');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('extracts variant names from styles', () => {
|
|
||||||
const result = normalizeFontshareFont(mockFontshareFont);
|
|
||||||
|
|
||||||
expect(result.variants).toContain('Regular');
|
|
||||||
expect(result.variants).toContain('Bold');
|
|
||||||
expect(result.variants).toContain('Regularitalic');
|
|
||||||
expect(result.variants).toContain('Bolditalic');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('maps Fontshare Sans to sans-serif category', () => {
|
|
||||||
const font = { ...mockFontshareFont, category: 'Sans' };
|
|
||||||
const result = normalizeFontshareFont(font);
|
|
||||||
|
|
||||||
expect(result.category).toBe('sans-serif');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('maps Fontshare Serif to serif category', () => {
|
|
||||||
const font = { ...mockFontshareFont, category: 'Serif' };
|
|
||||||
const result = normalizeFontshareFont(font);
|
|
||||||
|
|
||||||
expect(result.category).toBe('serif');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('maps Fontshare Display to display category', () => {
|
|
||||||
const font = { ...mockFontshareFont, category: 'Display' };
|
|
||||||
const result = normalizeFontshareFont(font);
|
|
||||||
|
|
||||||
expect(result.category).toBe('display');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('maps Fontshare Script to handwriting category', () => {
|
|
||||||
const font = { ...mockFontshareFont, category: 'Script' };
|
|
||||||
const result = normalizeFontshareFont(font);
|
|
||||||
|
|
||||||
expect(result.category).toBe('handwriting');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('maps Fontshare Mono to monospace category', () => {
|
|
||||||
const font = { ...mockFontshareFont, category: 'Mono' };
|
|
||||||
const result = normalizeFontshareFont(font);
|
|
||||||
|
|
||||||
expect(result.category).toBe('monospace');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('maps style URLs correctly', () => {
|
|
||||||
const result = normalizeFontshareFont(mockFontshareFont);
|
|
||||||
|
|
||||||
expect(result.styles.regular).toBe('//cdn.fontshare.com/wf/satoshi.woff2');
|
|
||||||
expect(result.styles.bold).toBe('//cdn.fontshare.com/wf/satoshi-bold.woff2');
|
|
||||||
expect(result.styles.italic).toBe('//cdn.fontshare.com/wf/satoshi-italic.woff2');
|
|
||||||
expect(result.styles.boldItalic).toBe(
|
|
||||||
'//cdn.fontshare.com/wf/satoshi-bolditalic.woff2',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles variable fonts', () => {
|
|
||||||
const variableFont: FontshareFont = {
|
|
||||||
...mockFontshareFont,
|
|
||||||
axes: [
|
|
||||||
{
|
|
||||||
name: 'wght',
|
|
||||||
property: 'wght',
|
|
||||||
range_default: 400,
|
|
||||||
range_left: 300,
|
|
||||||
range_right: 900,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
styles: [
|
|
||||||
{
|
|
||||||
id: 'var-style',
|
|
||||||
default: true,
|
|
||||||
file: '//cdn.fontshare.com/wf/satoshi-variable.woff2',
|
|
||||||
is_italic: false,
|
|
||||||
is_variable: true,
|
|
||||||
properties: {},
|
|
||||||
weight: {
|
|
||||||
label: 'Variable',
|
|
||||||
name: 'Variable',
|
|
||||||
native_name: null,
|
|
||||||
number: 0,
|
|
||||||
weight: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = normalizeFontshareFont(variableFont);
|
|
||||||
|
|
||||||
expect(result.features.isVariable).toBe(true);
|
|
||||||
expect(result.features.axes).toHaveLength(1);
|
|
||||||
expect(result.features.axes?.[0].name).toBe('wght');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('extracts font tags', () => {
|
|
||||||
const result = normalizeFontshareFont(mockFontshareFont);
|
|
||||||
|
|
||||||
expect(result.features.tags).toContain('Branding');
|
|
||||||
expect(result.features.tags).toContain('Logos');
|
|
||||||
expect(result.features.tags).toHaveLength(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('includes popularity from views', () => {
|
|
||||||
const result = normalizeFontshareFont(mockFontshareFont);
|
|
||||||
|
|
||||||
expect(result.metadata.popularity).toBe(10000);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('includes metadata', () => {
|
|
||||||
const result = normalizeFontshareFont(mockFontshareFont);
|
|
||||||
|
|
||||||
expect(result.metadata.cachedAt).toBeDefined();
|
|
||||||
expect(result.metadata.version).toBe('1.0');
|
|
||||||
expect(result.metadata.lastModified).toBe('2021-03-12T20:49:05Z');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles missing subsets gracefully', () => {
|
|
||||||
const font = {
|
|
||||||
...mockFontshareFont,
|
|
||||||
script: 'invalid-script',
|
|
||||||
};
|
|
||||||
const result = normalizeFontshareFont(font);
|
|
||||||
|
|
||||||
expect(result.subsets).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles empty tags', () => {
|
|
||||||
const font = {
|
|
||||||
...mockFontshareFont,
|
|
||||||
font_tags: [],
|
|
||||||
};
|
|
||||||
const result = normalizeFontshareFont(font);
|
|
||||||
|
|
||||||
expect(result.features.tags).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles empty axes', () => {
|
|
||||||
const font = {
|
|
||||||
...mockFontshareFont,
|
|
||||||
axes: [],
|
|
||||||
};
|
|
||||||
const result = normalizeFontshareFont(font);
|
|
||||||
|
|
||||||
expect(result.features.isVariable).toBe(false);
|
|
||||||
expect(result.features.axes).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('normalizeGoogleFonts', () => {
|
|
||||||
it('normalizes array of Google Fonts', () => {
|
|
||||||
const fonts: GoogleFontItem[] = [
|
|
||||||
{
|
|
||||||
family: 'Roboto',
|
|
||||||
category: 'sans-serif',
|
|
||||||
variants: ['regular'],
|
|
||||||
subsets: ['latin'],
|
|
||||||
files: { regular: 'url' },
|
|
||||||
version: 'v1',
|
|
||||||
lastModified: '2022-01-01',
|
|
||||||
menu: 'https://fonts.googleapis.com/css2?family=Roboto',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
family: 'Open Sans',
|
|
||||||
category: 'sans-serif',
|
|
||||||
variants: ['regular'],
|
|
||||||
subsets: ['latin'],
|
|
||||||
files: { regular: 'url' },
|
|
||||||
version: 'v1',
|
|
||||||
lastModified: '2022-01-01',
|
|
||||||
menu: 'https://fonts.googleapis.com/css2?family=Open+Sans',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = normalizeGoogleFonts(fonts);
|
|
||||||
|
|
||||||
expect(result).toHaveLength(2);
|
|
||||||
expect(result[0].name).toBe('Roboto');
|
|
||||||
expect(result[1].name).toBe('Open Sans');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns empty array for empty input', () => {
|
|
||||||
const result = normalizeGoogleFonts([]);
|
|
||||||
|
|
||||||
expect(result).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('normalizeFontshareFonts', () => {
|
|
||||||
it('normalizes array of Fontshare fonts', () => {
|
|
||||||
const fonts: FontshareFont[] = [
|
|
||||||
{
|
|
||||||
...mockMinimalFontshareFont('font1', 'Font 1'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
...mockMinimalFontshareFont('font2', 'Font 2'),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = normalizeFontshareFonts(fonts);
|
|
||||||
|
|
||||||
expect(result).toHaveLength(2);
|
|
||||||
expect(result[0].name).toBe('Font 1');
|
|
||||||
expect(result[1].name).toBe('Font 2');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns empty array for empty input', () => {
|
|
||||||
const result = normalizeFontshareFonts([]);
|
|
||||||
|
|
||||||
expect(result).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('edge cases', () => {
|
|
||||||
it('handles Google Font with missing optional fields', () => {
|
|
||||||
const font: Partial<GoogleFontItem> = {
|
|
||||||
family: 'Test Font',
|
|
||||||
category: 'sans-serif',
|
|
||||||
variants: ['regular'],
|
|
||||||
subsets: ['latin'],
|
|
||||||
files: { regular: 'url' },
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = normalizeGoogleFont(font as GoogleFontItem);
|
|
||||||
|
|
||||||
expect(result.id).toBe('Test Font');
|
|
||||||
expect(result.metadata.version).toBeUndefined();
|
|
||||||
expect(result.metadata.lastModified).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles Fontshare font with minimal data', () => {
|
|
||||||
const result = normalizeFontshareFont(mockMinimalFontshareFont('slug', 'Name'));
|
|
||||||
|
|
||||||
expect(result.id).toBe('slug');
|
|
||||||
expect(result.name).toBe('Name');
|
|
||||||
expect(result.provider).toBe('fontshare');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles unknown Fontshare category', () => {
|
|
||||||
const font = {
|
|
||||||
...mockMinimalFontshareFont('slug', 'Name'),
|
|
||||||
category: 'Unknown Category',
|
|
||||||
};
|
|
||||||
const result = normalizeFontshareFont(font);
|
|
||||||
|
|
||||||
expect(result.category).toBe('sans-serif'); // fallback
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper function to create minimal Fontshare font mock
|
|
||||||
*/
|
|
||||||
function mockMinimalFontshareFont(slug: string, name: string): FontshareFont {
|
|
||||||
return {
|
|
||||||
id: 'test-id',
|
|
||||||
name,
|
|
||||||
native_name: null,
|
|
||||||
slug,
|
|
||||||
category: 'Sans',
|
|
||||||
script: 'latin',
|
|
||||||
publisher: {
|
|
||||||
bio: '',
|
|
||||||
email: null,
|
|
||||||
id: '',
|
|
||||||
links: [],
|
|
||||||
name: '',
|
|
||||||
},
|
|
||||||
designers: [],
|
|
||||||
related_families: null,
|
|
||||||
display_publisher_as_designer: false,
|
|
||||||
trials_enabled: false,
|
|
||||||
show_latin_metrics: false,
|
|
||||||
license_type: '',
|
|
||||||
languages: '',
|
|
||||||
inserted_at: '',
|
|
||||||
story: '',
|
|
||||||
version: '1.0',
|
|
||||||
views: 0,
|
|
||||||
views_recent: 0,
|
|
||||||
is_hot: false,
|
|
||||||
is_new: false,
|
|
||||||
is_shortlisted: null,
|
|
||||||
is_top: false,
|
|
||||||
axes: [],
|
|
||||||
font_tags: [],
|
|
||||||
features: [],
|
|
||||||
styles: [
|
|
||||||
{
|
|
||||||
id: 'style-id',
|
|
||||||
default: true,
|
|
||||||
file: '//cdn.fontshare.com/wf/test.woff2',
|
|
||||||
is_italic: false,
|
|
||||||
is_variable: false,
|
|
||||||
properties: {},
|
|
||||||
weight: {
|
|
||||||
label: 'Regular',
|
|
||||||
name: 'Regular',
|
|
||||||
native_name: null,
|
|
||||||
number: 400,
|
|
||||||
weight: 400,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,275 +0,0 @@
|
|||||||
/**
|
|
||||||
* Normalize fonts from Google Fonts and Fontshare to unified model
|
|
||||||
*
|
|
||||||
* Transforms provider-specific font data into a common interface
|
|
||||||
* for consistent handling across the application.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type {
|
|
||||||
FontCategory,
|
|
||||||
FontStyleUrls,
|
|
||||||
FontSubset,
|
|
||||||
FontshareFont,
|
|
||||||
GoogleFontItem,
|
|
||||||
UnifiedFont,
|
|
||||||
UnifiedFontVariant,
|
|
||||||
} from '../../model/types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Map Google Fonts category to unified FontCategory
|
|
||||||
*/
|
|
||||||
function mapGoogleCategory(category: string): FontCategory {
|
|
||||||
const normalized = category.toLowerCase();
|
|
||||||
if (normalized.includes('sans-serif')) {
|
|
||||||
return 'sans-serif';
|
|
||||||
}
|
|
||||||
if (normalized.includes('serif')) {
|
|
||||||
return 'serif';
|
|
||||||
}
|
|
||||||
if (normalized.includes('display')) {
|
|
||||||
return 'display';
|
|
||||||
}
|
|
||||||
if (normalized.includes('handwriting') || normalized.includes('cursive')) {
|
|
||||||
return 'handwriting';
|
|
||||||
}
|
|
||||||
if (normalized.includes('monospace')) {
|
|
||||||
return 'monospace';
|
|
||||||
}
|
|
||||||
// Default fallback
|
|
||||||
return 'sans-serif';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Map Fontshare category to unified FontCategory
|
|
||||||
*/
|
|
||||||
function mapFontshareCategory(category: string): FontCategory {
|
|
||||||
const normalized = category.toLowerCase();
|
|
||||||
if (normalized === 'sans' || normalized === 'sans-serif') {
|
|
||||||
return 'sans-serif';
|
|
||||||
}
|
|
||||||
if (normalized === 'serif') {
|
|
||||||
return 'serif';
|
|
||||||
}
|
|
||||||
if (normalized === 'display') {
|
|
||||||
return 'display';
|
|
||||||
}
|
|
||||||
if (normalized === 'script') {
|
|
||||||
return 'handwriting';
|
|
||||||
}
|
|
||||||
if (normalized === 'mono' || normalized === 'monospace') {
|
|
||||||
return 'monospace';
|
|
||||||
}
|
|
||||||
// Default fallback
|
|
||||||
return 'sans-serif';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Map Google subset to unified FontSubset
|
|
||||||
*/
|
|
||||||
function mapGoogleSubset(subset: string): FontSubset | null {
|
|
||||||
const validSubsets: FontSubset[] = [
|
|
||||||
'latin',
|
|
||||||
'latin-ext',
|
|
||||||
'cyrillic',
|
|
||||||
'greek',
|
|
||||||
'arabic',
|
|
||||||
'devanagari',
|
|
||||||
];
|
|
||||||
return validSubsets.includes(subset as FontSubset)
|
|
||||||
? (subset as FontSubset)
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Map Fontshare script to unified FontSubset
|
|
||||||
*/
|
|
||||||
function mapFontshareScript(script: string): FontSubset | null {
|
|
||||||
const normalized = script.toLowerCase();
|
|
||||||
const mapping: Record<string, FontSubset | null> = {
|
|
||||||
latin: 'latin',
|
|
||||||
'latin-ext': 'latin-ext',
|
|
||||||
cyrillic: 'cyrillic',
|
|
||||||
greek: 'greek',
|
|
||||||
arabic: 'arabic',
|
|
||||||
devanagari: 'devanagari',
|
|
||||||
};
|
|
||||||
return mapping[normalized] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalize Google Font to unified model
|
|
||||||
*
|
|
||||||
* @param apiFont - Font item from Google Fonts API
|
|
||||||
* @returns Unified font model
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```ts
|
|
||||||
* const roboto = normalizeGoogleFont({
|
|
||||||
* family: 'Roboto',
|
|
||||||
* category: 'sans-serif',
|
|
||||||
* variants: ['regular', '700'],
|
|
||||||
* subsets: ['latin', 'latin-ext'],
|
|
||||||
* files: { regular: '...', '700': '...' }
|
|
||||||
* });
|
|
||||||
*
|
|
||||||
* console.log(roboto.id); // 'Roboto'
|
|
||||||
* console.log(roboto.provider); // 'google'
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function normalizeGoogleFont(apiFont: GoogleFontItem): UnifiedFont {
|
|
||||||
const category = mapGoogleCategory(apiFont.category);
|
|
||||||
const subsets = apiFont.subsets
|
|
||||||
.map(mapGoogleSubset)
|
|
||||||
.filter((subset): subset is FontSubset => subset !== null);
|
|
||||||
|
|
||||||
// Map variant files to style URLs
|
|
||||||
const styles: FontStyleUrls = {};
|
|
||||||
for (const [variant, url] of Object.entries(apiFont.files)) {
|
|
||||||
const urlString = url as string; // Type assertion for Record<string, string>
|
|
||||||
if (variant === 'regular' || variant === '400') {
|
|
||||||
styles.regular = urlString;
|
|
||||||
} else if (variant === 'italic' || variant === '400italic') {
|
|
||||||
styles.italic = urlString;
|
|
||||||
} else if (variant === 'bold' || variant === '700') {
|
|
||||||
styles.bold = urlString;
|
|
||||||
} else if (variant === 'bolditalic' || variant === '700italic') {
|
|
||||||
styles.boldItalic = urlString;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: apiFont.family,
|
|
||||||
name: apiFont.family,
|
|
||||||
provider: 'google',
|
|
||||||
category,
|
|
||||||
subsets,
|
|
||||||
variants: apiFont.variants,
|
|
||||||
styles,
|
|
||||||
metadata: {
|
|
||||||
cachedAt: Date.now(),
|
|
||||||
version: apiFont.version,
|
|
||||||
lastModified: apiFont.lastModified,
|
|
||||||
},
|
|
||||||
features: {
|
|
||||||
isVariable: false, // Google Fonts doesn't expose variable font info
|
|
||||||
tags: [],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalize Fontshare font to unified model
|
|
||||||
*
|
|
||||||
* @param apiFont - Font item from Fontshare API
|
|
||||||
* @returns Unified font model
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```ts
|
|
||||||
* const satoshi = normalizeFontshareFont({
|
|
||||||
* id: 'uuid',
|
|
||||||
* name: 'Satoshi',
|
|
||||||
* slug: 'satoshi',
|
|
||||||
* category: 'Sans',
|
|
||||||
* script: 'latin',
|
|
||||||
* styles: [ ... ]
|
|
||||||
* });
|
|
||||||
*
|
|
||||||
* console.log(satoshi.id); // 'satoshi'
|
|
||||||
* console.log(satoshi.provider); // 'fontshare'
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function normalizeFontshareFont(apiFont: FontshareFont): UnifiedFont {
|
|
||||||
const category = mapFontshareCategory(apiFont.category);
|
|
||||||
const subset = mapFontshareScript(apiFont.script);
|
|
||||||
const subsets = subset ? [subset] : [];
|
|
||||||
|
|
||||||
// Extract variant names from styles
|
|
||||||
const variants = apiFont.styles.map(style => {
|
|
||||||
const weightLabel = style.weight.label;
|
|
||||||
const isItalic = style.is_italic;
|
|
||||||
return (isItalic ? `${weightLabel}italic` : weightLabel) as UnifiedFontVariant;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Map styles to URLs
|
|
||||||
const styles: FontStyleUrls = {};
|
|
||||||
for (const style of apiFont.styles) {
|
|
||||||
if (style.is_variable) {
|
|
||||||
// Variable font - store as primary variant
|
|
||||||
styles.regular = style.file;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const weight = style.weight.number;
|
|
||||||
const isItalic = style.is_italic;
|
|
||||||
|
|
||||||
if (weight === 400 && !isItalic) {
|
|
||||||
styles.regular = style.file;
|
|
||||||
} else if (weight === 400 && isItalic) {
|
|
||||||
styles.italic = style.file;
|
|
||||||
} else if (weight >= 700 && !isItalic) {
|
|
||||||
styles.bold = style.file;
|
|
||||||
} else if (weight >= 700 && isItalic) {
|
|
||||||
styles.boldItalic = style.file;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract variable font axes
|
|
||||||
const axes = apiFont.axes.map(axis => ({
|
|
||||||
name: axis.name,
|
|
||||||
property: axis.property,
|
|
||||||
default: axis.range_default,
|
|
||||||
min: axis.range_left,
|
|
||||||
max: axis.range_right,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Extract tags
|
|
||||||
const tags = apiFont.font_tags.map(tag => tag.name);
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: apiFont.slug,
|
|
||||||
name: apiFont.name,
|
|
||||||
provider: 'fontshare',
|
|
||||||
category,
|
|
||||||
subsets,
|
|
||||||
variants,
|
|
||||||
styles,
|
|
||||||
metadata: {
|
|
||||||
cachedAt: Date.now(),
|
|
||||||
version: apiFont.version,
|
|
||||||
lastModified: apiFont.inserted_at,
|
|
||||||
popularity: apiFont.views,
|
|
||||||
},
|
|
||||||
features: {
|
|
||||||
isVariable: apiFont.axes.length > 0,
|
|
||||||
axes: axes.length > 0 ? axes : undefined,
|
|
||||||
tags: tags.length > 0 ? tags : undefined,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalize multiple Google Fonts to unified model
|
|
||||||
*
|
|
||||||
* @param apiFonts - Array of Google Font items
|
|
||||||
* @returns Array of unified fonts
|
|
||||||
*/
|
|
||||||
export function normalizeGoogleFonts(
|
|
||||||
apiFonts: GoogleFontItem[],
|
|
||||||
): UnifiedFont[] {
|
|
||||||
return apiFonts.map(normalizeGoogleFont);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalize multiple Fontshare fonts to unified model
|
|
||||||
*
|
|
||||||
* @param apiFonts - Array of Fontshare font items
|
|
||||||
* @returns Array of unified fonts
|
|
||||||
*/
|
|
||||||
export function normalizeFontshareFonts(
|
|
||||||
apiFonts: FontshareFont[],
|
|
||||||
): UnifiedFont[] {
|
|
||||||
return apiFonts.map(normalizeFontshareFont);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-export UnifiedFont for backward compatibility
|
|
||||||
export type { UnifiedFont } from '../../model/types/normalize';
|
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { TextLayoutEngine } from '$shared/lib';
|
||||||
|
import { installCanvasMock } from '$shared/lib/helpers/__mocks__/canvas';
|
||||||
|
import { clearCache } from '@chenglou/pretext';
|
||||||
|
import {
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
vi,
|
||||||
|
} from 'vitest';
|
||||||
|
import type { FontLoadStatus } from '../../model/types';
|
||||||
|
import { mockUnifiedFont } from '../mocks';
|
||||||
|
import { createFontRowSizeResolver } from './createFontRowSizeResolver';
|
||||||
|
|
||||||
|
// Fixed-width canvas mock: every character is 10px wide regardless of font.
|
||||||
|
// This makes wrapping math predictable: N chars × 10px = N×10 total width.
|
||||||
|
const CHAR_WIDTH = 10;
|
||||||
|
const LINE_HEIGHT = 20;
|
||||||
|
const CONTAINER_WIDTH = 200;
|
||||||
|
const CONTENT_PADDING_X = 32; // p-4 × 2 sides = 32px
|
||||||
|
const CHROME_HEIGHT = 56;
|
||||||
|
const FALLBACK_HEIGHT = 220;
|
||||||
|
const FONT_SIZE_PX = 16;
|
||||||
|
|
||||||
|
describe('createFontRowSizeResolver', () => {
|
||||||
|
let statusMap: Map<string, FontLoadStatus>;
|
||||||
|
let getStatus: (key: string) => FontLoadStatus | undefined;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
installCanvasMock((_font, text) => text.length * CHAR_WIDTH);
|
||||||
|
clearCache();
|
||||||
|
statusMap = new Map();
|
||||||
|
getStatus = key => statusMap.get(key);
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeResolver(overrides?: Partial<Parameters<typeof createFontRowSizeResolver>[0]>) {
|
||||||
|
const font = mockUnifiedFont({ id: 'inter', name: 'Inter' });
|
||||||
|
return {
|
||||||
|
font,
|
||||||
|
resolver: createFontRowSizeResolver({
|
||||||
|
getFonts: () => [font],
|
||||||
|
getWeight: () => 400,
|
||||||
|
getPreviewText: () => 'Hello',
|
||||||
|
getContainerWidth: () => CONTAINER_WIDTH,
|
||||||
|
getFontSizePx: () => FONT_SIZE_PX,
|
||||||
|
getLineHeightPx: () => LINE_HEIGHT,
|
||||||
|
getStatus,
|
||||||
|
contentHorizontalPadding: CONTENT_PADDING_X,
|
||||||
|
chromeHeight: CHROME_HEIGHT,
|
||||||
|
fallbackHeight: FALLBACK_HEIGHT,
|
||||||
|
...overrides,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it('returns fallbackHeight when font status is undefined', () => {
|
||||||
|
const { resolver } = makeResolver();
|
||||||
|
expect(resolver(0)).toBe(FALLBACK_HEIGHT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns fallbackHeight when font status is "loading"', () => {
|
||||||
|
const { resolver } = makeResolver();
|
||||||
|
statusMap.set('inter@400', 'loading');
|
||||||
|
expect(resolver(0)).toBe(FALLBACK_HEIGHT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns fallbackHeight when font status is "error"', () => {
|
||||||
|
const { resolver } = makeResolver();
|
||||||
|
statusMap.set('inter@400', 'error');
|
||||||
|
expect(resolver(0)).toBe(FALLBACK_HEIGHT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns fallbackHeight when containerWidth is 0', () => {
|
||||||
|
const { resolver } = makeResolver({ getContainerWidth: () => 0 });
|
||||||
|
statusMap.set('inter@400', 'loaded');
|
||||||
|
expect(resolver(0)).toBe(FALLBACK_HEIGHT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns fallbackHeight when previewText is empty', () => {
|
||||||
|
const { resolver } = makeResolver({ getPreviewText: () => '' });
|
||||||
|
statusMap.set('inter@400', 'loaded');
|
||||||
|
expect(resolver(0)).toBe(FALLBACK_HEIGHT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns fallbackHeight for out-of-bounds rowIndex', () => {
|
||||||
|
const { resolver } = makeResolver();
|
||||||
|
statusMap.set('inter@400', 'loaded');
|
||||||
|
expect(resolver(99)).toBe(FALLBACK_HEIGHT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns computed height (totalHeight + chromeHeight) when font is loaded', () => {
|
||||||
|
const { resolver } = makeResolver();
|
||||||
|
statusMap.set('inter@400', 'loaded');
|
||||||
|
|
||||||
|
// 'Hello' = 5 chars × 10px = 50px. contentWidth = 200 - 32 = 168px. Fits on one line.
|
||||||
|
// totalHeight = 1 × LINE_HEIGHT = 20. result = 20 + CHROME_HEIGHT = 76.
|
||||||
|
const result = resolver(0);
|
||||||
|
expect(result).toBe(LINE_HEIGHT + CHROME_HEIGHT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns increased height when text wraps due to narrow container', () => {
|
||||||
|
// contentWidth = 40 - 32 = 8px — 'Hello' (50px) forces wrapping onto many lines
|
||||||
|
const { resolver } = makeResolver({ getContainerWidth: () => 40 });
|
||||||
|
statusMap.set('inter@400', 'loaded');
|
||||||
|
|
||||||
|
const result = resolver(0);
|
||||||
|
expect(result).toBeGreaterThan(LINE_HEIGHT + CHROME_HEIGHT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not call layout() again on second call with same arguments', () => {
|
||||||
|
const { resolver } = makeResolver();
|
||||||
|
statusMap.set('inter@400', 'loaded');
|
||||||
|
|
||||||
|
const layoutSpy = vi.spyOn(TextLayoutEngine.prototype, 'layout');
|
||||||
|
|
||||||
|
resolver(0);
|
||||||
|
resolver(0);
|
||||||
|
|
||||||
|
expect(layoutSpy).toHaveBeenCalledTimes(1);
|
||||||
|
layoutSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls layout() again when containerWidth changes (cache miss)', () => {
|
||||||
|
let width = CONTAINER_WIDTH;
|
||||||
|
const { resolver } = makeResolver({ getContainerWidth: () => width });
|
||||||
|
statusMap.set('inter@400', 'loaded');
|
||||||
|
|
||||||
|
const layoutSpy = vi.spyOn(TextLayoutEngine.prototype, 'layout');
|
||||||
|
|
||||||
|
resolver(0);
|
||||||
|
width = 100;
|
||||||
|
resolver(0);
|
||||||
|
|
||||||
|
expect(layoutSpy).toHaveBeenCalledTimes(2);
|
||||||
|
layoutSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns greater height when container narrows (more wrapping)', () => {
|
||||||
|
let width = CONTAINER_WIDTH;
|
||||||
|
const { resolver } = makeResolver({ getContainerWidth: () => width });
|
||||||
|
statusMap.set('inter@400', 'loaded');
|
||||||
|
|
||||||
|
const h1 = resolver(0);
|
||||||
|
width = 100; // narrower → more wrapping
|
||||||
|
const h2 = resolver(0);
|
||||||
|
|
||||||
|
expect(h2).toBeGreaterThanOrEqual(h1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses variable font key for variable fonts', () => {
|
||||||
|
const vfFont = mockUnifiedFont({ id: 'roboto', name: 'Roboto', features: { isVariable: true } });
|
||||||
|
const { resolver } = makeResolver({ getFonts: () => [vfFont] });
|
||||||
|
// Variable fonts use '{id}@vf' key, not '{id}@{weight}'
|
||||||
|
statusMap.set('roboto@vf', 'loaded');
|
||||||
|
const result = resolver(0);
|
||||||
|
expect(result).not.toBe(FALLBACK_HEIGHT);
|
||||||
|
expect(result).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns fallbackHeight for variable font when static key is set instead', () => {
|
||||||
|
const vfFont = mockUnifiedFont({ id: 'roboto', name: 'Roboto', features: { isVariable: true } });
|
||||||
|
const { resolver } = makeResolver({ getFonts: () => [vfFont] });
|
||||||
|
// Setting the static key should NOT unlock computed height for variable fonts
|
||||||
|
statusMap.set('roboto@400', 'loaded');
|
||||||
|
expect(resolver(0)).toBe(FALLBACK_HEIGHT);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { TextLayoutEngine } from '$shared/lib';
|
||||||
|
import { generateFontKey } from '../../model/store/appliedFontsStore/utils/generateFontKey/generateFontKey';
|
||||||
|
import type {
|
||||||
|
FontLoadStatus,
|
||||||
|
UnifiedFont,
|
||||||
|
} from '../../model/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for {@link createFontRowSizeResolver}.
|
||||||
|
*
|
||||||
|
* All getter functions are called on every resolver invocation. When called
|
||||||
|
* inside a Svelte `$derived.by` block, any reactive state read within them
|
||||||
|
* (e.g. `SvelteMap.get()`) is automatically tracked as a dependency.
|
||||||
|
*/
|
||||||
|
export interface FontRowSizeResolverOptions {
|
||||||
|
/**
|
||||||
|
* Returns the current fonts array. Index `i` corresponds to row `i`.
|
||||||
|
*/
|
||||||
|
getFonts: () => UnifiedFont[];
|
||||||
|
/**
|
||||||
|
* Returns the active font weight (e.g. 400).
|
||||||
|
*/
|
||||||
|
getWeight: () => number;
|
||||||
|
/**
|
||||||
|
* Returns the preview text string.
|
||||||
|
*/
|
||||||
|
getPreviewText: () => string;
|
||||||
|
/**
|
||||||
|
* Returns the scroll container's inner width in pixels. Returns 0 before mount.
|
||||||
|
*/
|
||||||
|
getContainerWidth: () => number;
|
||||||
|
/**
|
||||||
|
* Returns the font size in pixels (e.g. `controlManager.renderedSize`).
|
||||||
|
*/
|
||||||
|
getFontSizePx: () => number;
|
||||||
|
/**
|
||||||
|
* Returns the computed line height in pixels.
|
||||||
|
* Typically `controlManager.height * controlManager.renderedSize`.
|
||||||
|
*/
|
||||||
|
getLineHeightPx: () => number;
|
||||||
|
/**
|
||||||
|
* Returns the font load status for a given font key (`'{id}@{weight}'` or `'{id}@vf'`).
|
||||||
|
*
|
||||||
|
* In production: `(key) => appliedFontsManager.statuses.get(key)`.
|
||||||
|
* Injected for testability — avoids a module-level singleton dependency in tests.
|
||||||
|
* The call to `.get()` on a `SvelteMap` must happen inside a `$derived.by` context
|
||||||
|
* for reactivity to work. This is satisfied when `itemHeight` is called by
|
||||||
|
* `createVirtualizer`'s `estimateSize`.
|
||||||
|
*/
|
||||||
|
getStatus: (fontKey: string) => FontLoadStatus | undefined;
|
||||||
|
/**
|
||||||
|
* Total horizontal padding of the text content area in pixels.
|
||||||
|
* Use the smallest breakpoint value (mobile `p-4` = 32px) to guarantee
|
||||||
|
* the content width is never over-estimated, keeping the height estimate safe.
|
||||||
|
*/
|
||||||
|
contentHorizontalPadding: number;
|
||||||
|
/**
|
||||||
|
* Fixed height in pixels of chrome that is not text content (header bar, etc.).
|
||||||
|
*/
|
||||||
|
chromeHeight: number;
|
||||||
|
/**
|
||||||
|
* Height in pixels to return when the font is not loaded or container width is 0.
|
||||||
|
*/
|
||||||
|
fallbackHeight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a row-height resolver for `FontSampler` rows in `VirtualList`.
|
||||||
|
*
|
||||||
|
* The returned function is suitable as the `itemHeight` prop of `VirtualList`.
|
||||||
|
* Pass it from the widget layer (`SampleList`) so that typography values from
|
||||||
|
* `controlManager` are injected as getter functions rather than imported directly,
|
||||||
|
* keeping `$entities/Font` free of `$features` dependencies.
|
||||||
|
*
|
||||||
|
* **Reactivity:** When the returned function reads `getStatus()` inside a
|
||||||
|
* `$derived.by` block (as `estimateSize` does in `createVirtualizer`), any
|
||||||
|
* `SvelteMap.get()` call within `getStatus` registers a Svelte 5 dependency.
|
||||||
|
* When a font's status changes to `'loaded'`, `offsets` recomputes automatically —
|
||||||
|
* no DOM snap occurs.
|
||||||
|
*
|
||||||
|
* **Caching:** A `Map` keyed by `fontCssString|text|contentWidth|lineHeightPx`
|
||||||
|
* prevents redundant `TextLayoutEngine.layout()` calls. The cache is invalidated
|
||||||
|
* naturally because a change in any input produces a different cache key.
|
||||||
|
*
|
||||||
|
* @param options - Configuration and getter functions (all injected for testability).
|
||||||
|
* @returns A function `(rowIndex: number) => number` for use as `VirtualList.itemHeight`.
|
||||||
|
*/
|
||||||
|
export function createFontRowSizeResolver(options: FontRowSizeResolverOptions): (rowIndex: number) => number {
|
||||||
|
const engine = new TextLayoutEngine();
|
||||||
|
// Key: `${fontCssString}|${text}|${contentWidth}|${lineHeightPx}`
|
||||||
|
const cache = new Map<string, number>();
|
||||||
|
|
||||||
|
return function resolveRowHeight(rowIndex: number): number {
|
||||||
|
const fonts = options.getFonts();
|
||||||
|
const font = fonts[rowIndex];
|
||||||
|
if (!font) {
|
||||||
|
return options.fallbackHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
const containerWidth = options.getContainerWidth();
|
||||||
|
const previewText = options.getPreviewText();
|
||||||
|
|
||||||
|
if (containerWidth <= 0 || !previewText) {
|
||||||
|
return options.fallbackHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
const weight = options.getWeight();
|
||||||
|
// generateFontKey: '{id}@{weight}' for static fonts, '{id}@vf' for variable fonts.
|
||||||
|
const fontKey = generateFontKey({ id: font.id, weight, isVariable: font.features?.isVariable });
|
||||||
|
|
||||||
|
// Reading via getStatus() allows the caller to pass appliedFontsManager.statuses.get(),
|
||||||
|
// which creates a Svelte 5 reactive dependency when called inside $derived.by.
|
||||||
|
const status = options.getStatus(fontKey);
|
||||||
|
if (status !== 'loaded') {
|
||||||
|
return options.fallbackHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fontSizePx = options.getFontSizePx();
|
||||||
|
const lineHeightPx = options.getLineHeightPx();
|
||||||
|
const contentWidth = containerWidth - options.contentHorizontalPadding;
|
||||||
|
const fontCssString = `${weight} ${fontSizePx}px "${font.name}"`;
|
||||||
|
|
||||||
|
const cacheKey = `${fontCssString}|${previewText}|${contentWidth}|${lineHeightPx}`;
|
||||||
|
const cached = cache.get(cacheKey);
|
||||||
|
if (cached !== undefined) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { totalHeight } = engine.layout(previewText, fontCssString, contentWidth, lineHeightPx);
|
||||||
|
const result = totalHeight + options.chromeHeight;
|
||||||
|
cache.set(cacheKey, result);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
}
|
||||||
+7
-1
@@ -1,5 +1,5 @@
|
|||||||
import type { ControlModel } from '$shared/lib';
|
import type { ControlModel } from '$shared/lib';
|
||||||
import type { ControlId } from '..';
|
import type { ControlId } from '../types/typography';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Font size constants
|
* Font size constants
|
||||||
@@ -86,3 +86,9 @@ export const DEFAULT_TYPOGRAPHY_CONTROLS_DATA: ControlModel<ControlId>[] = [
|
|||||||
export const MULTIPLIER_S = 0.5;
|
export const MULTIPLIER_S = 0.5;
|
||||||
export const MULTIPLIER_M = 0.75;
|
export const MULTIPLIER_M = 0.75;
|
||||||
export const MULTIPLIER_L = 1;
|
export const MULTIPLIER_L = 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Index value for items not yet loaded in a virtualized list.
|
||||||
|
* Treated as being at the very bottom of the infinite scroll.
|
||||||
|
*/
|
||||||
|
export const VIRTUAL_INDEX_NOT_LOADED = Infinity;
|
||||||
@@ -1,44 +1,3 @@
|
|||||||
export type {
|
export * from './const/const';
|
||||||
// Domain types
|
export * from './store';
|
||||||
FontCategory,
|
export * from './types';
|
||||||
FontCollectionFilters,
|
|
||||||
FontCollectionSort,
|
|
||||||
// Store types
|
|
||||||
FontCollectionState,
|
|
||||||
FontFeatures,
|
|
||||||
FontFiles,
|
|
||||||
FontItem,
|
|
||||||
FontLoadRequestConfig,
|
|
||||||
FontLoadStatus,
|
|
||||||
FontMetadata,
|
|
||||||
FontProvider,
|
|
||||||
// Fontshare API types
|
|
||||||
FontshareApiModel,
|
|
||||||
FontshareAxis,
|
|
||||||
FontshareDesigner,
|
|
||||||
FontshareFeature,
|
|
||||||
FontshareFont,
|
|
||||||
FontshareLink,
|
|
||||||
FontsharePublisher,
|
|
||||||
FontshareStyle,
|
|
||||||
FontshareStyleProperties,
|
|
||||||
FontshareTag,
|
|
||||||
FontshareWeight,
|
|
||||||
FontStyleUrls,
|
|
||||||
FontSubset,
|
|
||||||
FontVariant,
|
|
||||||
FontWeight,
|
|
||||||
FontWeightItalic,
|
|
||||||
// Google Fonts API types
|
|
||||||
GoogleFontsApiModel,
|
|
||||||
// Normalization types
|
|
||||||
UnifiedFont,
|
|
||||||
UnifiedFontVariant,
|
|
||||||
} from './types';
|
|
||||||
|
|
||||||
export {
|
|
||||||
appliedFontsManager,
|
|
||||||
createUnifiedFontStore,
|
|
||||||
type UnifiedFontStore,
|
|
||||||
unifiedFontStore,
|
|
||||||
} from './store';
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
/** @vitest-environment jsdom */
|
/**
|
||||||
|
* @vitest-environment jsdom
|
||||||
|
*/
|
||||||
import { AppliedFontsManager } from './appliedFontsStore.svelte';
|
import { AppliedFontsManager } from './appliedFontsStore.svelte';
|
||||||
import { FontFetchError } from './errors';
|
import { FontFetchError } from './errors';
|
||||||
import { FontEvictionPolicy } from './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> {
|
||||||
@@ -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,8 +32,6 @@ const makeConfig = (id: string, overrides: Partial<{ weight: number; isVariable:
|
|||||||
...overrides,
|
...overrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Suite ─────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('AppliedFontsManager', () => {
|
describe('AppliedFontsManager', () => {
|
||||||
let manager: AppliedFontsManager;
|
let manager: AppliedFontsManager;
|
||||||
let eviction: FontEvictionPolicy;
|
let eviction: FontEvictionPolicy;
|
||||||
@@ -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')]);
|
||||||
@@ -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,8 +157,6 @@ 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(() => {});
|
||||||
@@ -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')]);
|
||||||
|
|||||||
@@ -7,15 +7,15 @@ import {
|
|||||||
FontFetchError,
|
FontFetchError,
|
||||||
FontParseError,
|
FontParseError,
|
||||||
} from './errors';
|
} from './errors';
|
||||||
import { FontBufferCache } from './fontBufferCache/FontBufferCache';
|
|
||||||
import { FontEvictionPolicy } from './fontEvictionPolicy/FontEvictionPolicy';
|
|
||||||
import { FontLoadQueue } from './fontLoadQueue/FontLoadQueue';
|
|
||||||
import {
|
import {
|
||||||
generateFontKey,
|
generateFontKey,
|
||||||
getEffectiveConcurrency,
|
getEffectiveConcurrency,
|
||||||
loadFont,
|
loadFont,
|
||||||
yieldToMainThread,
|
yieldToMainThread,
|
||||||
} from './utils';
|
} from './utils';
|
||||||
|
import { FontBufferCache } from './utils/fontBufferCache/FontBufferCache';
|
||||||
|
import { FontEvictionPolicy } from './utils/fontEvictionPolicy/FontEvictionPolicy';
|
||||||
|
import { FontLoadQueue } from './utils/fontLoadQueue/FontLoadQueue';
|
||||||
|
|
||||||
interface AppliedFontsManagerDeps {
|
interface AppliedFontsManagerDeps {
|
||||||
cache?: FontBufferCache;
|
cache?: FontBufferCache;
|
||||||
@@ -156,7 +156,9 @@ export class AppliedFontsManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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;
|
||||||
}
|
}
|
||||||
@@ -188,13 +190,11 @@ export class AppliedFontsManager {
|
|||||||
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();
|
||||||
@@ -246,12 +246,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 +283,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 +297,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 +315,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 +326,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 +352,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 +393,7 @@ export class AppliedFontsManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Singleton instance — use throughout the application for unified font loading state. */
|
/**
|
||||||
|
* Singleton instance — use throughout the application for unified font loading state.
|
||||||
|
*/
|
||||||
export const appliedFontsManager = new AppliedFontsManager();
|
export const appliedFontsManager = new AppliedFontsManager();
|
||||||
|
|||||||
+4
-2
@@ -1,5 +1,7 @@
|
|||||||
/** @vitest-environment jsdom */
|
/**
|
||||||
import { FontFetchError } from '../errors';
|
* @vitest-environment jsdom
|
||||||
|
*/
|
||||||
|
import { FontFetchError } from '../../errors';
|
||||||
import { FontBufferCache } from './FontBufferCache';
|
import { FontBufferCache } from './FontBufferCache';
|
||||||
|
|
||||||
const makeBuffer = () => new ArrayBuffer(8);
|
const makeBuffer = () => new ArrayBuffer(8);
|
||||||
+16
-6
@@ -1,11 +1,15 @@
|
|||||||
import { FontFetchError } from '../errors';
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,7 +44,9 @@ export class FontBufferCache {
|
|||||||
async get(url: string, signal?: AbortSignal): Promise<ArrayBuffer> {
|
async get(url: string, signal?: AbortSignal): Promise<ArrayBuffer> {
|
||||||
// Tier 1: in-memory (fastest, no I/O)
|
// Tier 1: in-memory (fastest, no I/O)
|
||||||
const inMemory = this.#buffersByUrl.get(url);
|
const inMemory = this.#buffersByUrl.get(url);
|
||||||
if (inMemory) return inMemory;
|
if (inMemory) {
|
||||||
|
return inMemory;
|
||||||
|
}
|
||||||
|
|
||||||
// Tier 2: Cache API
|
// Tier 2: Cache API
|
||||||
try {
|
try {
|
||||||
@@ -83,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
-8
@@ -1,5 +1,7 @@
|
|||||||
interface FontEvictionPolicyOptions {
|
interface FontEvictionPolicyOptions {
|
||||||
/** TTL in milliseconds. Defaults to 5 minutes. */
|
/**
|
||||||
|
* TTL in milliseconds. Defaults to 5 minutes.
|
||||||
|
*/
|
||||||
ttl?: number;
|
ttl?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,12 +30,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);
|
||||||
}
|
}
|
||||||
@@ -48,23 +54,33 @@ export class FontEvictionPolicy {
|
|||||||
*/
|
*/
|
||||||
shouldEvict(key: string, now: number): boolean {
|
shouldEvict(key: string, now: number): boolean {
|
||||||
const lastUsed = this.#usageTracker.get(key);
|
const lastUsed = this.#usageTracker.get(key);
|
||||||
if (lastUsed === undefined) return false;
|
if (lastUsed === undefined) {
|
||||||
if (this.#pinnedFonts.has(key)) return false;
|
return false;
|
||||||
|
}
|
||||||
|
if (this.#pinnedFonts.has(key)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
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();
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import type { FontLoadRequestConfig } from '../../../types';
|
import type { FontLoadRequestConfig } from '../../../../types';
|
||||||
import { FontLoadQueue } from './FontLoadQueue';
|
import { FontLoadQueue } from './FontLoadQueue';
|
||||||
|
|
||||||
const config = (id: string): FontLoadRequestConfig => ({
|
const config = (id: string): FontLoadRequestConfig => ({
|
||||||
+16
-6
@@ -1,4 +1,4 @@
|
|||||||
import type { FontLoadRequestConfig } from '../../../types';
|
import type { FontLoadRequestConfig } from '../../../../types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages the font load queue and per-font retry counts.
|
* Manages the font load queue and per-font retry counts.
|
||||||
@@ -17,7 +17,9 @@ export class FontLoadQueue {
|
|||||||
* @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.
|
||||||
*/
|
*/
|
||||||
enqueue(key: string, config: FontLoadRequestConfig): boolean {
|
enqueue(key: string, config: FontLoadRequestConfig): boolean {
|
||||||
if (this.#queue.has(key)) return false;
|
if (this.#queue.has(key)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
this.#queue.set(key, config);
|
this.#queue.set(key, config);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -32,22 +34,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) >= this.#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();
|
||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -1,210 +0,0 @@
|
|||||||
import { queryClient } from '$shared/api/queryClient';
|
|
||||||
import {
|
|
||||||
type QueryKey,
|
|
||||||
QueryObserver,
|
|
||||||
type QueryObserverOptions,
|
|
||||||
type QueryObserverResult,
|
|
||||||
} from '@tanstack/query-core';
|
|
||||||
import type { UnifiedFont } from '../types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Base class for font stores using TanStack Query
|
|
||||||
*
|
|
||||||
* Provides reactive font data fetching with caching, automatic refetching,
|
|
||||||
* and parameter binding. Extended by UnifiedFontStore for provider-agnostic
|
|
||||||
* font fetching.
|
|
||||||
*
|
|
||||||
* @template TParams - Type of query parameters
|
|
||||||
*/
|
|
||||||
export abstract class BaseFontStore<TParams extends Record<string, any>> {
|
|
||||||
/**
|
|
||||||
* Cleanup function for effects
|
|
||||||
* Call destroy() to remove effects and prevent memory leaks
|
|
||||||
*/
|
|
||||||
cleanup: () => void;
|
|
||||||
|
|
||||||
/** Reactive parameter bindings from external sources */
|
|
||||||
#bindings = $state<(() => Partial<TParams>)[]>([]);
|
|
||||||
/** Internal parameter state */
|
|
||||||
#internalParams = $state<TParams>({} as TParams);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Merged params from internal state and all bindings
|
|
||||||
* Automatically updates when bindings or internal params change
|
|
||||||
*/
|
|
||||||
params = $derived.by(() => {
|
|
||||||
let merged = { ...this.#internalParams };
|
|
||||||
|
|
||||||
// Merge all binding results into params
|
|
||||||
for (const getter of this.#bindings) {
|
|
||||||
const bindingResult = getter();
|
|
||||||
merged = { ...merged, ...bindingResult };
|
|
||||||
}
|
|
||||||
return merged as TParams;
|
|
||||||
});
|
|
||||||
|
|
||||||
/** TanStack Query result state */
|
|
||||||
protected result = $state<QueryObserverResult<UnifiedFont[], Error>>({} as any);
|
|
||||||
/** TanStack Query observer instance */
|
|
||||||
protected observer: QueryObserver<UnifiedFont[], Error>;
|
|
||||||
/** Shared query client */
|
|
||||||
protected qc = queryClient;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a new base font store
|
|
||||||
* @param initialParams - Initial query parameters
|
|
||||||
*/
|
|
||||||
constructor(initialParams: TParams) {
|
|
||||||
this.#internalParams = initialParams;
|
|
||||||
|
|
||||||
this.observer = new QueryObserver(this.qc, this.getOptions());
|
|
||||||
|
|
||||||
// Sync TanStack Query state -> Svelte state
|
|
||||||
this.observer.subscribe(r => {
|
|
||||||
this.result = r;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sync Svelte state changes -> TanStack Query options
|
|
||||||
this.cleanup = $effect.root(() => {
|
|
||||||
$effect(() => {
|
|
||||||
this.observer.setOptions(this.getOptions());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Must be implemented by child class
|
|
||||||
* Returns the query key for TanStack Query caching
|
|
||||||
*/
|
|
||||||
protected abstract getQueryKey(params: TParams): QueryKey;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Must be implemented by child class
|
|
||||||
* Fetches font data from API
|
|
||||||
*/
|
|
||||||
protected abstract fetchFn(params: TParams): Promise<UnifiedFont[]>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets TanStack Query options
|
|
||||||
* @param params - Query parameters (defaults to current params)
|
|
||||||
*/
|
|
||||||
protected getOptions(params = this.params): QueryObserverOptions<UnifiedFont[], Error> {
|
|
||||||
return {
|
|
||||||
queryKey: this.getQueryKey(params),
|
|
||||||
queryFn: () => this.fetchFn(params),
|
|
||||||
staleTime: 5 * 60 * 1000,
|
|
||||||
gcTime: 10 * 60 * 1000,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Array of fonts (empty array if loading/error) */
|
|
||||||
get fonts() {
|
|
||||||
return this.result.data ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether currently fetching initial data */
|
|
||||||
get isLoading() {
|
|
||||||
return this.result.isLoading;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether any fetch is in progress (including refetches) */
|
|
||||||
get isFetching() {
|
|
||||||
return this.result.isFetching;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether last fetch resulted in an error */
|
|
||||||
get isError() {
|
|
||||||
return this.result.isError;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether no fonts are loaded (not loading and empty array) */
|
|
||||||
get isEmpty() {
|
|
||||||
return !this.isLoading && this.fonts.length === 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add a reactive parameter binding
|
|
||||||
* @param getter - Function that returns partial params to merge
|
|
||||||
* @returns Unbind function to remove the binding
|
|
||||||
*/
|
|
||||||
addBinding(getter: () => Partial<TParams>) {
|
|
||||||
this.#bindings.push(getter);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
this.#bindings = this.#bindings.filter(b => b !== getter);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update query parameters
|
|
||||||
* @param newParams - Partial params to merge with existing
|
|
||||||
*/
|
|
||||||
setParams(newParams: Partial<TParams>) {
|
|
||||||
this.#internalParams = { ...this.params, ...newParams };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Invalidate cache and refetch
|
|
||||||
*/
|
|
||||||
invalidate() {
|
|
||||||
this.qc.invalidateQueries({ queryKey: this.getQueryKey(this.params) });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clean up effects and observers
|
|
||||||
*/
|
|
||||||
destroy() {
|
|
||||||
this.cleanup();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Manually trigger a refetch
|
|
||||||
*/
|
|
||||||
async refetch() {
|
|
||||||
await this.observer.refetch();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Prefetch data with different parameters
|
|
||||||
*/
|
|
||||||
async prefetch(params: TParams) {
|
|
||||||
await this.qc.prefetchQuery(this.getOptions(params));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cancel ongoing queries
|
|
||||||
*/
|
|
||||||
cancel() {
|
|
||||||
this.qc.cancelQueries({
|
|
||||||
queryKey: this.getQueryKey(this.params),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear cache for current params
|
|
||||||
*/
|
|
||||||
clearCache() {
|
|
||||||
this.qc.removeQueries({
|
|
||||||
queryKey: this.getQueryKey(this.params),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get cached data without triggering fetch
|
|
||||||
*/
|
|
||||||
getCachedData() {
|
|
||||||
return this.qc.getQueryData<UnifiedFont[]>(
|
|
||||||
this.getQueryKey(this.params),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set data manually (optimistic updates)
|
|
||||||
*/
|
|
||||||
setQueryData(updater: (old: UnifiedFont[] | undefined) => UnifiedFont[]) {
|
|
||||||
this.qc.setQueryData(
|
|
||||||
this.getQueryKey(this.params),
|
|
||||||
updater,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { fontKeys } from '$shared/api/queryKeys';
|
||||||
|
import { BaseQueryStore } from '$shared/lib/helpers/BaseQueryStore.svelte';
|
||||||
|
import {
|
||||||
|
fetchFontsByIds,
|
||||||
|
seedFontCache,
|
||||||
|
} from '../../api/proxy/proxyFonts';
|
||||||
|
import {
|
||||||
|
FontNetworkError,
|
||||||
|
FontResponseError,
|
||||||
|
} from '../../lib/errors/errors';
|
||||||
|
import type { UnifiedFont } from '../../model/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal fetcher that seeds the cache and handles error wrapping.
|
||||||
|
* Standalone function to avoid 'this' issues during construction.
|
||||||
|
*/
|
||||||
|
async function fetchAndSeed(ids: string[]): Promise<UnifiedFont[]> {
|
||||||
|
if (ids.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
let response: UnifiedFont[];
|
||||||
|
try {
|
||||||
|
response = await fetchFontsByIds(ids);
|
||||||
|
} catch (cause) {
|
||||||
|
throw new FontNetworkError(cause);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response || !Array.isArray(response)) {
|
||||||
|
throw new FontResponseError('batchResponse', response);
|
||||||
|
}
|
||||||
|
|
||||||
|
seedFontCache(response);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reactive store for fetching and caching batches of fonts by ID.
|
||||||
|
* Integrates with TanStack Query via BaseQueryStore and handles
|
||||||
|
* normalized cache seeding.
|
||||||
|
*/
|
||||||
|
export class BatchFontStore extends BaseQueryStore<UnifiedFont[]> {
|
||||||
|
constructor(initialIds: string[] = []) {
|
||||||
|
super({
|
||||||
|
queryKey: fontKeys.batch(initialIds),
|
||||||
|
queryFn: () => fetchAndSeed(initialIds),
|
||||||
|
enabled: initialIds.length > 0,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the IDs to fetch. Triggers a new query.
|
||||||
|
*
|
||||||
|
* @param ids - Array of font IDs
|
||||||
|
*/
|
||||||
|
setIds(ids: string[]): void {
|
||||||
|
this.updateOptions({
|
||||||
|
queryKey: fontKeys.batch(ids),
|
||||||
|
queryFn: () => fetchAndSeed(ids),
|
||||||
|
enabled: ids.length > 0,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Array of fetched fonts
|
||||||
|
*/
|
||||||
|
get fonts(): UnifiedFont[] {
|
||||||
|
return this.result.data ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the query is currently loading
|
||||||
|
*/
|
||||||
|
get isLoading(): boolean {
|
||||||
|
return this.result.isLoading;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the query encountered an error
|
||||||
|
*/
|
||||||
|
get isError(): boolean {
|
||||||
|
return this.result.isError;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The error object if the query failed
|
||||||
|
*/
|
||||||
|
get error(): Error | null {
|
||||||
|
return (this.result.error as Error) ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { queryClient } from '$shared/api/queryClient';
|
||||||
|
import { fontKeys } from '$shared/api/queryKeys';
|
||||||
|
import {
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
vi,
|
||||||
|
} from 'vitest';
|
||||||
|
import * as api from '../../api/proxy/proxyFonts';
|
||||||
|
import {
|
||||||
|
FontNetworkError,
|
||||||
|
FontResponseError,
|
||||||
|
} from '../../lib/errors/errors';
|
||||||
|
import { BatchFontStore } from './batchFontStore.svelte';
|
||||||
|
|
||||||
|
describe('BatchFontStore', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
queryClient.clear();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Fetch Behavior', () => {
|
||||||
|
it('should skip fetch when initialized with empty IDs', async () => {
|
||||||
|
const spy = vi.spyOn(api, 'fetchFontsByIds');
|
||||||
|
const store = new BatchFontStore([]);
|
||||||
|
expect(spy).not.toHaveBeenCalled();
|
||||||
|
expect(store.fonts).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fetch and seed cache for valid IDs', async () => {
|
||||||
|
const fonts = [{ id: 'a', name: 'A' }] as any[];
|
||||||
|
vi.spyOn(api, 'fetchFontsByIds').mockResolvedValue(fonts);
|
||||||
|
const store = new BatchFontStore(['a']);
|
||||||
|
await vi.waitFor(() => expect(store.fonts).toEqual(fonts), { timeout: 1000 });
|
||||||
|
expect(queryClient.getQueryData(fontKeys.detail('a'))).toEqual(fonts[0]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Loading States', () => {
|
||||||
|
it('should transition through loading state', async () => {
|
||||||
|
vi.spyOn(api, 'fetchFontsByIds').mockImplementation(() =>
|
||||||
|
new Promise(r => setTimeout(() => r([{ id: 'a' }] as any), 50))
|
||||||
|
);
|
||||||
|
const store = new BatchFontStore(['a']);
|
||||||
|
expect(store.isLoading).toBe(true);
|
||||||
|
await vi.waitFor(() => expect(store.isLoading).toBe(false), { timeout: 1000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Error Handling', () => {
|
||||||
|
it('should wrap network failures in FontNetworkError', async () => {
|
||||||
|
vi.spyOn(api, 'fetchFontsByIds').mockRejectedValue(new Error('Network fail'));
|
||||||
|
const store = new BatchFontStore(['a']);
|
||||||
|
await vi.waitFor(() => expect(store.isError).toBe(true), { timeout: 1000 });
|
||||||
|
expect(store.error).toBeInstanceOf(FontNetworkError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle malformed API responses with FontResponseError', async () => {
|
||||||
|
// Mocking a malformed response that the store should validate
|
||||||
|
vi.spyOn(api, 'fetchFontsByIds').mockResolvedValue(null as any);
|
||||||
|
const store = new BatchFontStore(['a']);
|
||||||
|
await vi.waitFor(() => expect(store.isError).toBe(true), { timeout: 1000 });
|
||||||
|
expect(store.error).toBeInstanceOf(FontResponseError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have null error in success state', async () => {
|
||||||
|
const fonts = [{ id: 'a' }] as any[];
|
||||||
|
vi.spyOn(api, 'fetchFontsByIds').mockResolvedValue(fonts);
|
||||||
|
const store = new BatchFontStore(['a']);
|
||||||
|
await vi.waitFor(() => expect(store.fonts).toEqual(fonts), { timeout: 1000 });
|
||||||
|
expect(store.error).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Disable Behavior', () => {
|
||||||
|
it('should return empty fonts and not fetch when setIds is called with empty array', async () => {
|
||||||
|
const fonts1 = [{ id: 'a' }] as any[];
|
||||||
|
const spy = vi.spyOn(api, 'fetchFontsByIds').mockResolvedValueOnce(fonts1);
|
||||||
|
|
||||||
|
const store = new BatchFontStore(['a']);
|
||||||
|
await vi.waitFor(() => expect(store.fonts).toEqual(fonts1), { timeout: 1000 });
|
||||||
|
|
||||||
|
spy.mockClear();
|
||||||
|
store.setIds([]);
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(store.fonts).toEqual([]), { timeout: 1000 });
|
||||||
|
expect(spy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Reactivity', () => {
|
||||||
|
it('should refetch when setIds is called', async () => {
|
||||||
|
const fonts1 = [{ id: 'a' }] as any[];
|
||||||
|
const fonts2 = [{ id: 'b' }] as any[];
|
||||||
|
vi.spyOn(api, 'fetchFontsByIds')
|
||||||
|
.mockResolvedValueOnce(fonts1)
|
||||||
|
.mockResolvedValueOnce(fonts2);
|
||||||
|
|
||||||
|
const store = new BatchFontStore(['a']);
|
||||||
|
await vi.waitFor(() => expect(store.fonts).toEqual(fonts1), { timeout: 1000 });
|
||||||
|
|
||||||
|
store.setIds(['b']);
|
||||||
|
await vi.waitFor(() => expect(store.fonts).toEqual(fonts2), { timeout: 1000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,627 @@
|
|||||||
|
import { QueryClient } from '@tanstack/query-core';
|
||||||
|
import { flushSync } from 'svelte';
|
||||||
|
import {
|
||||||
|
afterEach,
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
vi,
|
||||||
|
} from 'vitest';
|
||||||
|
import {
|
||||||
|
FontNetworkError,
|
||||||
|
FontResponseError,
|
||||||
|
} from '../../../lib/errors/errors';
|
||||||
|
import {
|
||||||
|
generateMixedCategoryFonts,
|
||||||
|
generateMockFonts,
|
||||||
|
} from '../../../lib/mocks/fonts.mock';
|
||||||
|
import type { UnifiedFont } from '../../types';
|
||||||
|
import { FontStore } from './fontStore.svelte';
|
||||||
|
|
||||||
|
vi.mock('$shared/api/queryClient', () => ({
|
||||||
|
queryClient: new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: 0, gcTime: 0 } },
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
vi.mock('../../../api', () => ({ fetchProxyFonts: vi.fn() }));
|
||||||
|
|
||||||
|
import { queryClient } from '$shared/api/queryClient';
|
||||||
|
import { fetchProxyFonts } from '../../../api';
|
||||||
|
|
||||||
|
const fetch = fetchProxyFonts as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
type FontPage = { fonts: UnifiedFont[]; total: number; limit: number; offset: number };
|
||||||
|
|
||||||
|
const makeResponse = (
|
||||||
|
fonts: UnifiedFont[],
|
||||||
|
meta: { total?: number; limit?: number; offset?: number } = {},
|
||||||
|
): FontPage => ({
|
||||||
|
fonts,
|
||||||
|
total: meta.total ?? fonts.length,
|
||||||
|
limit: meta.limit ?? 10,
|
||||||
|
offset: meta.offset ?? 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeStore(params = {}) {
|
||||||
|
return new FontStore({ limit: 10, ...params });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchedStore(params = {}, fonts = generateMockFonts(5), meta: Parameters<typeof makeResponse>[1] = {}) {
|
||||||
|
fetch.mockResolvedValue(makeResponse(fonts, meta));
|
||||||
|
const store = makeStore(params);
|
||||||
|
await store.refetch();
|
||||||
|
flushSync();
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('FontStore', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
queryClient.clear();
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('construction', () => {
|
||||||
|
it('stores initial params', () => {
|
||||||
|
const store = makeStore({ limit: 20 });
|
||||||
|
expect(store.params.limit).toBe(20);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults limit to 50 when not provided', () => {
|
||||||
|
const store = new FontStore();
|
||||||
|
expect(store.params.limit).toBe(50);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts with empty fonts', () => {
|
||||||
|
const store = makeStore();
|
||||||
|
expect(store.fonts).toEqual([]);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts with isEmpty false — initial fetch is in progress', () => {
|
||||||
|
// The observer starts fetching immediately on construction.
|
||||||
|
// isEmpty must be false so the UI shows a loader, not "no results".
|
||||||
|
const store = makeStore();
|
||||||
|
expect(store.isEmpty).toBe(false);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('state after fetch', () => {
|
||||||
|
it('exposes loaded fonts', async () => {
|
||||||
|
const store = await fetchedStore({}, generateMockFonts(7));
|
||||||
|
expect(store.fonts).toHaveLength(7);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isEmpty is false when fonts are present', async () => {
|
||||||
|
const store = await fetchedStore();
|
||||||
|
expect(store.isEmpty).toBe(false);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isLoading is false after fetch', async () => {
|
||||||
|
const store = await fetchedStore();
|
||||||
|
expect(store.isLoading).toBe(false);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isFetching is false after fetch', async () => {
|
||||||
|
const store = await fetchedStore();
|
||||||
|
expect(store.isFetching).toBe(false);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isError is false on success', async () => {
|
||||||
|
const store = await fetchedStore();
|
||||||
|
expect(store.isError).toBe(false);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('error is null on success', async () => {
|
||||||
|
const store = await fetchedStore();
|
||||||
|
expect(store.error).toBeNull();
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('error states', () => {
|
||||||
|
it('isError is false before any fetch', () => {
|
||||||
|
const store = makeStore();
|
||||||
|
expect(store.isError).toBe(false);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wraps network failures in FontNetworkError', async () => {
|
||||||
|
fetch.mockRejectedValue(new Error('network down'));
|
||||||
|
const store = makeStore();
|
||||||
|
await store.refetch().catch(() => {});
|
||||||
|
flushSync();
|
||||||
|
expect(store.error).toBeInstanceOf(FontNetworkError);
|
||||||
|
expect(store.isError).toBe(true);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes FontResponseError for falsy response', async () => {
|
||||||
|
const store = makeStore();
|
||||||
|
fetch.mockResolvedValue(null);
|
||||||
|
await store.refetch().catch(() => {});
|
||||||
|
flushSync();
|
||||||
|
expect(store.error).toBeInstanceOf(FontResponseError);
|
||||||
|
expect((store.error as FontResponseError).field).toBe('response');
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes FontResponseError for missing fonts field', async () => {
|
||||||
|
fetch.mockResolvedValue({ total: 0, limit: 10, offset: 0 });
|
||||||
|
const store = makeStore();
|
||||||
|
await store.refetch().catch(() => {});
|
||||||
|
flushSync();
|
||||||
|
expect(store.error).toBeInstanceOf(FontResponseError);
|
||||||
|
expect((store.error as FontResponseError).field).toBe('response.fonts');
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes FontResponseError for non-array fonts', async () => {
|
||||||
|
fetch.mockResolvedValue({ fonts: 'bad', total: 0, limit: 10, offset: 0 });
|
||||||
|
const store = makeStore();
|
||||||
|
await store.refetch().catch(() => {});
|
||||||
|
flushSync();
|
||||||
|
expect(store.error).toBeInstanceOf(FontResponseError);
|
||||||
|
expect((store.error as FontResponseError).received).toBe('bad');
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('font accumulation', () => {
|
||||||
|
it('replaces fonts when refetching the first page', async () => {
|
||||||
|
const store = makeStore();
|
||||||
|
fetch.mockResolvedValue(makeResponse(generateMockFonts(3)));
|
||||||
|
await store.refetch();
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
const second = generateMockFonts(2);
|
||||||
|
fetch.mockResolvedValue(makeResponse(second));
|
||||||
|
await store.refetch();
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
// refetch at offset=0 re-fetches all pages; only one page loaded → new data replaces old
|
||||||
|
expect(store.fonts).toHaveLength(2);
|
||||||
|
expect(store.fonts[0].id).toBe(second[0].id);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends fonts after nextPage', async () => {
|
||||||
|
const page1 = generateMockFonts(3);
|
||||||
|
const store = await fetchedStore({ limit: 3 }, page1, { total: 6, limit: 3, offset: 0 });
|
||||||
|
const page2 = generateMockFonts(3).map((f, i) => ({ ...f, id: `p2-${i}` }));
|
||||||
|
fetch.mockResolvedValue(makeResponse(page2, { total: 6, limit: 3, offset: 3 }));
|
||||||
|
await store.nextPage();
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
expect(store.fonts).toHaveLength(6);
|
||||||
|
expect(store.fonts.slice(0, 3).map(f => f.id)).toEqual(page1.map(f => f.id));
|
||||||
|
expect(store.fonts.slice(3).map(f => f.id)).toEqual(page2.map(f => f.id));
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('pagination state', () => {
|
||||||
|
it('returns zero-value defaults before any fetch', () => {
|
||||||
|
const store = makeStore();
|
||||||
|
expect(store.pagination).toMatchObject({ total: 0, hasMore: false, page: 1, totalPages: 0 });
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reflects response metadata after fetch', async () => {
|
||||||
|
const store = await fetchedStore({}, generateMockFonts(10), { total: 30, limit: 10, offset: 0 });
|
||||||
|
expect(store.pagination.total).toBe(30);
|
||||||
|
expect(store.pagination.hasMore).toBe(true);
|
||||||
|
expect(store.pagination.page).toBe(1);
|
||||||
|
expect(store.pagination.totalPages).toBe(3);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hasMore is false on the last page', async () => {
|
||||||
|
const store = await fetchedStore({}, generateMockFonts(10), { total: 10, limit: 10, offset: 0 });
|
||||||
|
expect(store.pagination.hasMore).toBe(false);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('page count increments after nextPage', async () => {
|
||||||
|
const store = await fetchedStore({ limit: 10 }, generateMockFonts(10), { total: 30, limit: 10, offset: 0 });
|
||||||
|
expect(store.pagination.page).toBe(1);
|
||||||
|
|
||||||
|
fetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 30, limit: 10, offset: 10 }));
|
||||||
|
await store.nextPage();
|
||||||
|
flushSync();
|
||||||
|
expect(store.pagination.page).toBe(2);
|
||||||
|
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('setParams', () => {
|
||||||
|
it('merges updates into existing params', () => {
|
||||||
|
const store = makeStore({ limit: 10 });
|
||||||
|
store.setParams({ limit: 20 });
|
||||||
|
expect(store.params.limit).toBe(20);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retains unmodified params', () => {
|
||||||
|
const store = makeStore({ limit: 10 });
|
||||||
|
store.setCategories(['serif']);
|
||||||
|
store.setParams({ limit: 25 });
|
||||||
|
expect(store.params.categories).toEqual(['serif']);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('filter change resets', () => {
|
||||||
|
it('clears accumulated fonts when a filter changes', async () => {
|
||||||
|
const store = await fetchedStore({}, generateMockFonts(5));
|
||||||
|
store.setSearch('roboto');
|
||||||
|
flushSync();
|
||||||
|
// TQ switches to a new queryKey → data.pages reset → fonts = []
|
||||||
|
expect(store.fonts).toHaveLength(0);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isEmpty is false immediately after filter change — fetch is in progress', async () => {
|
||||||
|
const store = await fetchedStore({}, generateMockFonts(5));
|
||||||
|
// Hang the next fetch so we can observe the transitioning state
|
||||||
|
fetch.mockReturnValue(new Promise(() => {}));
|
||||||
|
store.setSearch('roboto');
|
||||||
|
flushSync();
|
||||||
|
// fonts = [] AND isFetching = true → isEmpty must be false (no "no results" flash)
|
||||||
|
expect(store.isEmpty).toBe(false);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT reset fonts when the same filter value is set again', async () => {
|
||||||
|
const store = await fetchedStore({}, generateMockFonts(5));
|
||||||
|
store.setCategories(['serif']);
|
||||||
|
flushSync();
|
||||||
|
// First change: clears fonts (expected)
|
||||||
|
store.setCategories(['serif']); // same value — same queryKey — TQ keeps data.pages
|
||||||
|
flushSync();
|
||||||
|
// Because queryKey hasn't changed, TQ returns cached data — fonts restored from cache
|
||||||
|
// (actual font count depends on cache; key assertion is no extra reset)
|
||||||
|
expect(store.isError).toBe(false);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('staleTime in buildOptions', () => {
|
||||||
|
it('is 5 minutes with no active filters', () => {
|
||||||
|
const store = makeStore();
|
||||||
|
expect((store as any).buildOptions().staleTime).toBe(5 * 60 * 1000);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is 0 when a search query is active', () => {
|
||||||
|
const store = makeStore();
|
||||||
|
store.setSearch('roboto');
|
||||||
|
expect((store as any).buildOptions().staleTime).toBe(0);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is 0 when a category filter is active', () => {
|
||||||
|
const store = makeStore();
|
||||||
|
store.setCategories(['serif']);
|
||||||
|
expect((store as any).buildOptions().staleTime).toBe(0);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gcTime is 10 minutes always', () => {
|
||||||
|
const store = makeStore();
|
||||||
|
expect((store as any).buildOptions().gcTime).toBe(10 * 60 * 1000);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildQueryKey', () => {
|
||||||
|
it('omits empty-string params', () => {
|
||||||
|
const store = makeStore();
|
||||||
|
store.setSearch('');
|
||||||
|
const [root, normalized] = (store as any).buildQueryKey(store.params);
|
||||||
|
expect(root).toBe('fonts');
|
||||||
|
expect(normalized).not.toHaveProperty('q');
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits empty-array params', () => {
|
||||||
|
const store = makeStore();
|
||||||
|
store.setProviders([]);
|
||||||
|
const [, normalized] = (store as any).buildQueryKey(store.params);
|
||||||
|
expect(normalized).not.toHaveProperty('providers');
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes non-empty filter values', () => {
|
||||||
|
const store = makeStore();
|
||||||
|
store.setCategories(['serif']);
|
||||||
|
const [, normalized] = (store as any).buildQueryKey(store.params);
|
||||||
|
expect(normalized).toHaveProperty('categories', ['serif']);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not include offset (offset is the TQ page param, not a query key component)', () => {
|
||||||
|
const store = makeStore();
|
||||||
|
const [, normalized] = (store as any).buildQueryKey(store.params);
|
||||||
|
expect(normalized).not.toHaveProperty('offset');
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('destroy', () => {
|
||||||
|
it('does not throw', () => {
|
||||||
|
const store = makeStore();
|
||||||
|
expect(() => store.destroy()).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is idempotent', () => {
|
||||||
|
const store = makeStore();
|
||||||
|
store.destroy();
|
||||||
|
expect(() => store.destroy()).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('refetch', () => {
|
||||||
|
it('triggers a fetch', async () => {
|
||||||
|
const store = makeStore();
|
||||||
|
fetch.mockResolvedValue(makeResponse(generateMockFonts(3)));
|
||||||
|
await store.refetch();
|
||||||
|
expect(fetch).toHaveBeenCalled();
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses params current at call time', async () => {
|
||||||
|
const store = makeStore({ limit: 10 });
|
||||||
|
store.setParams({ limit: 20 });
|
||||||
|
fetch.mockResolvedValue(makeResponse(generateMockFonts(20)));
|
||||||
|
await store.refetch();
|
||||||
|
expect(fetch).toHaveBeenCalledWith(expect.objectContaining({ limit: 20 }));
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('nextPage', () => {
|
||||||
|
let store: FontStore;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
fetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 30, limit: 10, offset: 0 }));
|
||||||
|
store = new FontStore({ limit: 10 });
|
||||||
|
await store.refetch();
|
||||||
|
flushSync();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetches the next page and appends fonts', async () => {
|
||||||
|
fetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 30, limit: 10, offset: 10 }));
|
||||||
|
await store.nextPage();
|
||||||
|
flushSync();
|
||||||
|
expect(store.fonts).toHaveLength(20);
|
||||||
|
expect(store.pagination.offset).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a no-op when hasMore is false', async () => {
|
||||||
|
// Set up a store where all fonts fit in one page (hasMore = false)
|
||||||
|
queryClient.clear();
|
||||||
|
fetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 10, limit: 10, offset: 0 }));
|
||||||
|
store = new FontStore({ limit: 10 });
|
||||||
|
await store.refetch();
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
expect(store.pagination.hasMore).toBe(false);
|
||||||
|
await store.nextPage(); // should not trigger another fetch
|
||||||
|
expect(store.fonts).toHaveLength(10);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('prevPage and goToPage', () => {
|
||||||
|
it('prevPage is a no-op — infinite scroll does not support backward navigation', async () => {
|
||||||
|
const store = await fetchedStore({}, generateMockFonts(5));
|
||||||
|
store.prevPage();
|
||||||
|
expect(store.fonts).toHaveLength(5); // unchanged
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('goToPage is a no-op — infinite scroll does not support arbitrary page jumps', async () => {
|
||||||
|
const store = await fetchedStore({}, generateMockFonts(5));
|
||||||
|
store.goToPage(3);
|
||||||
|
expect(store.fonts).toHaveLength(5); // unchanged
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('prefetch', () => {
|
||||||
|
it('triggers a fetch for the provided params', async () => {
|
||||||
|
const store = makeStore();
|
||||||
|
fetch.mockResolvedValue(makeResponse(generateMockFonts(5)));
|
||||||
|
await store.prefetch({ limit: 5 });
|
||||||
|
expect(fetch).toHaveBeenCalledWith(expect.objectContaining({ limit: 5, offset: 0 }));
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getCachedData / setQueryData', () => {
|
||||||
|
it('getCachedData returns undefined before any fetch', () => {
|
||||||
|
queryClient.clear();
|
||||||
|
const store = new FontStore({ limit: 10 });
|
||||||
|
expect(store.getCachedData()).toBeUndefined();
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getCachedData returns flattened fonts after fetch', async () => {
|
||||||
|
const store = await fetchedStore();
|
||||||
|
expect(store.getCachedData()).toHaveLength(5);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setQueryData writes to cache', () => {
|
||||||
|
const store = makeStore();
|
||||||
|
const font = generateMockFonts(1)[0];
|
||||||
|
store.setQueryData(() => [font]);
|
||||||
|
expect(store.getCachedData()).toHaveLength(1);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setQueryData updater receives existing flattened fonts', async () => {
|
||||||
|
const store = await fetchedStore();
|
||||||
|
const updater = vi.fn((old: UnifiedFont[] | undefined) => old ?? []);
|
||||||
|
store.setQueryData(updater);
|
||||||
|
expect(updater).toHaveBeenCalledWith(expect.any(Array));
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('invalidate', () => {
|
||||||
|
it('calls invalidateQueries', async () => {
|
||||||
|
const store = await fetchedStore();
|
||||||
|
const spy = vi.spyOn(queryClient, 'invalidateQueries');
|
||||||
|
store.invalidate();
|
||||||
|
expect(spy).toHaveBeenCalledOnce();
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('setLimit', () => {
|
||||||
|
it('updates the limit param', () => {
|
||||||
|
const store = makeStore({ limit: 10 });
|
||||||
|
store.setLimit(25);
|
||||||
|
expect(store.params.limit).toBe(25);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('filter shortcut methods', () => {
|
||||||
|
let store: FontStore;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
store = makeStore();
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setProviders updates providers param', () => {
|
||||||
|
store.setProviders(['google']);
|
||||||
|
expect(store.params.providers).toEqual(['google']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setCategories updates categories param', () => {
|
||||||
|
store.setCategories(['serif']);
|
||||||
|
expect(store.params.categories).toEqual(['serif']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setSubsets updates subsets param', () => {
|
||||||
|
store.setSubsets(['cyrillic']);
|
||||||
|
expect(store.params.subsets).toEqual(['cyrillic']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setSearch sets q param', () => {
|
||||||
|
store.setSearch('roboto');
|
||||||
|
expect(store.params.q).toBe('roboto');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setSearch with empty string clears q', () => {
|
||||||
|
store.setSearch('roboto');
|
||||||
|
store.setSearch('');
|
||||||
|
expect(store.params.q).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setSort updates sort param', () => {
|
||||||
|
store.setSort('popularity');
|
||||||
|
expect(store.params.sort).toBe('popularity');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('category getters', () => {
|
||||||
|
it('each getter returns only fonts of that category', async () => {
|
||||||
|
const fonts = generateMixedCategoryFonts(2); // 2 of each category = 10 total
|
||||||
|
fetch.mockResolvedValue(makeResponse(fonts, { total: fonts.length, limit: fonts.length }));
|
||||||
|
const store = makeStore({ limit: 50 });
|
||||||
|
await store.refetch();
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
expect(store.sansSerifFonts.every(f => f.category === 'sans-serif')).toBe(true);
|
||||||
|
expect(store.serifFonts.every(f => f.category === 'serif')).toBe(true);
|
||||||
|
expect(store.displayFonts.every(f => f.category === 'display')).toBe(true);
|
||||||
|
expect(store.handwritingFonts.every(f => f.category === 'handwriting')).toBe(true);
|
||||||
|
expect(store.monospaceFonts.every(f => f.category === 'monospace')).toBe(true);
|
||||||
|
expect(store.sansSerifFonts).toHaveLength(2);
|
||||||
|
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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,466 @@
|
|||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
*/
|
||||||
|
get isEmpty(): boolean {
|
||||||
|
return !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
|
||||||
|
*/
|
||||||
|
setParams(updates: Partial<FontStoreParams>) {
|
||||||
|
this.#params = { ...this.#params, ...updates };
|
||||||
|
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;
|
||||||
|
},
|
||||||
|
staleTime: hasFilters ? 0 : 5 * 60 * 1000,
|
||||||
|
gcTime: 10 * 60 * 1000,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchPage(params: ProxyFontsParams): Promise<ProxyFontsResponse> {
|
||||||
|
let response: ProxyFontsResponse;
|
||||||
|
try {
|
||||||
|
response = await fetchProxyFonts(params);
|
||||||
|
} catch (cause) {
|
||||||
|
throw new FontNetworkError(cause);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response) {
|
||||||
|
throw new FontResponseError('response', response);
|
||||||
|
}
|
||||||
|
if (!response.fonts) {
|
||||||
|
throw new FontResponseError('response.fonts', response.fonts);
|
||||||
|
}
|
||||||
|
if (!Array.isArray(response.fonts)) {
|
||||||
|
throw new FontResponseError('response.fonts', response.fonts);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
fonts: response.fonts,
|
||||||
|
total: response.total ?? 0,
|
||||||
|
limit: response.limit ?? params.limit ?? 50,
|
||||||
|
offset: response.offset ?? params.offset ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createFontStore(params: FontStoreParams = {}): FontStore {
|
||||||
|
return new FontStore(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fontStore = new FontStore({ limit: 50 });
|
||||||
@@ -1,17 +1,12 @@
|
|||||||
/**
|
// Applied fonts manager
|
||||||
* ============================================================================
|
export * from './appliedFontsStore/appliedFontsStore.svelte';
|
||||||
* UNIFIED FONT STORE EXPORTS
|
|
||||||
* ============================================================================
|
|
||||||
*
|
|
||||||
* Single export point for the unified font store infrastructure.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Primary store (unified)
|
// Batch font store
|
||||||
|
export { BatchFontStore } from './batchFontStore.svelte';
|
||||||
|
|
||||||
|
// Single FontStore
|
||||||
export {
|
export {
|
||||||
createUnifiedFontStore,
|
createFontStore,
|
||||||
type UnifiedFontStore,
|
FontStore,
|
||||||
unifiedFontStore,
|
fontStore,
|
||||||
} from './unifiedFontStore.svelte';
|
} from './fontStore/fontStore.svelte';
|
||||||
|
|
||||||
// Applied fonts manager (CSS loading - unchanged)
|
|
||||||
export { appliedFontsManager } from './appliedFontsStore/appliedFontsStore.svelte';
|
|
||||||
|
|||||||
@@ -1,373 +0,0 @@
|
|||||||
/**
|
|
||||||
* Unified font store
|
|
||||||
*
|
|
||||||
* Single source of truth for font data, powered by the proxy API.
|
|
||||||
* Extends BaseFontStore for TanStack Query integration and reactivity.
|
|
||||||
*
|
|
||||||
* Key features:
|
|
||||||
* - Provider-agnostic (proxy API handles provider logic)
|
|
||||||
* - Reactive to filter changes
|
|
||||||
* - Optimistic updates via TanStack Query
|
|
||||||
* - Pagination support
|
|
||||||
* - Provider-specific shortcuts for common operations
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { QueryObserverOptions } from '@tanstack/query-core';
|
|
||||||
import type { ProxyFontsParams } from '../../api';
|
|
||||||
import { fetchProxyFonts } from '../../api';
|
|
||||||
import type { UnifiedFont } from '../types';
|
|
||||||
import { BaseFontStore } from './baseFontStore.svelte';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unified font store wrapping TanStack Query with Svelte 5 runes
|
|
||||||
*
|
|
||||||
* Extends BaseFontStore to provide:
|
|
||||||
* - Reactive state management
|
|
||||||
* - TanStack Query integration for caching
|
|
||||||
* - Dynamic parameter binding for filters
|
|
||||||
* - Pagination support
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```ts
|
|
||||||
* const store = new UnifiedFontStore({
|
|
||||||
* provider: 'google',
|
|
||||||
* category: 'sans-serif',
|
|
||||||
* limit: 50
|
|
||||||
* });
|
|
||||||
*
|
|
||||||
* // Access reactive state
|
|
||||||
* $effect(() => {
|
|
||||||
* console.log(store.fonts);
|
|
||||||
* console.log(store.isLoading);
|
|
||||||
* console.log(store.pagination);
|
|
||||||
* });
|
|
||||||
*
|
|
||||||
* // Update parameters
|
|
||||||
* store.setCategories(['serif']);
|
|
||||||
* store.nextPage();
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export class UnifiedFontStore extends BaseFontStore<ProxyFontsParams> {
|
|
||||||
/**
|
|
||||||
* Store pagination metadata separately from fonts
|
|
||||||
* This is a workaround for TanStack Query's type system
|
|
||||||
*/
|
|
||||||
#paginationMetadata = $state<
|
|
||||||
{
|
|
||||||
total: number;
|
|
||||||
limit: number;
|
|
||||||
offset: number;
|
|
||||||
} | null
|
|
||||||
>(null);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Accumulated fonts from all pages (for infinite scroll)
|
|
||||||
*/
|
|
||||||
#accumulatedFonts = $state<UnifiedFont[]>([]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pagination metadata (derived from proxy API response)
|
|
||||||
*/
|
|
||||||
readonly pagination = $derived.by(() => {
|
|
||||||
if (this.#paginationMetadata) {
|
|
||||||
const { total, limit, offset } = this.#paginationMetadata;
|
|
||||||
return {
|
|
||||||
total,
|
|
||||||
limit,
|
|
||||||
offset,
|
|
||||||
hasMore: offset + limit < total,
|
|
||||||
page: Math.floor(offset / limit) + 1,
|
|
||||||
totalPages: Math.ceil(total / limit),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
total: 0,
|
|
||||||
limit: this.params.limit || 50,
|
|
||||||
offset: this.params.offset || 0,
|
|
||||||
hasMore: false,
|
|
||||||
page: 1,
|
|
||||||
totalPages: 0,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Track previous filter params to detect changes and reset pagination
|
|
||||||
*/
|
|
||||||
#previousFilterParams = $state<string>('');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cleanup function for the filter tracking effect
|
|
||||||
*/
|
|
||||||
#filterCleanup: (() => void) | null = null;
|
|
||||||
|
|
||||||
constructor(initialParams: ProxyFontsParams = {}) {
|
|
||||||
super(initialParams);
|
|
||||||
|
|
||||||
// Track filter params (excluding pagination params)
|
|
||||||
// Wrapped in $effect.root() to prevent effect_orphan error
|
|
||||||
this.#filterCleanup = $effect.root(() => {
|
|
||||||
$effect(() => {
|
|
||||||
const filterParams = JSON.stringify({
|
|
||||||
providers: this.params.providers,
|
|
||||||
categories: this.params.categories,
|
|
||||||
subsets: this.params.subsets,
|
|
||||||
q: this.params.q,
|
|
||||||
});
|
|
||||||
|
|
||||||
// If filters changed, reset offset and invalidate cache
|
|
||||||
if (filterParams !== this.#previousFilterParams) {
|
|
||||||
if (this.#previousFilterParams) {
|
|
||||||
if (this.params.offset !== 0) {
|
|
||||||
this.setParams({ offset: 0 });
|
|
||||||
}
|
|
||||||
this.#accumulatedFonts = [];
|
|
||||||
this.invalidate();
|
|
||||||
}
|
|
||||||
this.#previousFilterParams = filterParams;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Effect: Sync state from Query result (Handles Cache Hits)
|
|
||||||
$effect(() => {
|
|
||||||
const data = this.result.data;
|
|
||||||
const offset = this.params.offset || 0;
|
|
||||||
|
|
||||||
// When we have data and we are at the start (offset 0),
|
|
||||||
// we must ensure accumulatedFonts matches the fresh (or cached) data.
|
|
||||||
// This fixes the issue where cache hits skip fetchFn side-effects.
|
|
||||||
if (offset === 0 && data && data.length > 0) {
|
|
||||||
this.#accumulatedFonts = data;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clean up both parent and child effects
|
|
||||||
*/
|
|
||||||
destroy() {
|
|
||||||
// Call parent cleanup (TanStack observer effect)
|
|
||||||
super.destroy();
|
|
||||||
|
|
||||||
// Call filter tracking effect cleanup
|
|
||||||
if (this.#filterCleanup) {
|
|
||||||
this.#filterCleanup();
|
|
||||||
this.#filterCleanup = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Query key for TanStack Query caching
|
|
||||||
* Normalizes params to treat empty arrays/strings as undefined
|
|
||||||
*/
|
|
||||||
protected getQueryKey(params: ProxyFontsParams) {
|
|
||||||
// Normalize params to treat empty arrays/strings as undefined
|
|
||||||
const normalized = Object.entries(params).reduce((acc, [key, value]) => {
|
|
||||||
if (value === '' || value === undefined || (Array.isArray(value) && value.length === 0)) {
|
|
||||||
return acc;
|
|
||||||
}
|
|
||||||
return { ...acc, [key]: value };
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
// Return a consistent key
|
|
||||||
return ['unifiedFonts', normalized] as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected getOptions(params = this.params): QueryObserverOptions<UnifiedFont[], Error> {
|
|
||||||
const hasFilters = !!(params.q || params.providers || params.categories || params.subsets);
|
|
||||||
return {
|
|
||||||
queryKey: this.getQueryKey(params),
|
|
||||||
queryFn: () => this.fetchFn(params),
|
|
||||||
staleTime: hasFilters ? 0 : 5 * 60 * 1000,
|
|
||||||
gcTime: 10 * 60 * 1000,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch function that calls the proxy API
|
|
||||||
* Returns the full response including pagination metadata
|
|
||||||
*/
|
|
||||||
protected async fetchFn(params: ProxyFontsParams): Promise<UnifiedFont[]> {
|
|
||||||
const response = await fetchProxyFonts(params);
|
|
||||||
|
|
||||||
// Validate response structure
|
|
||||||
if (!response) {
|
|
||||||
console.error('[UnifiedFontStore] fetchProxyFonts returned undefined', { params });
|
|
||||||
throw new Error('Proxy API returned undefined response');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.fonts) {
|
|
||||||
console.error('[UnifiedFontStore] response.fonts is undefined', { response });
|
|
||||||
throw new Error('Proxy API response missing fonts array');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Array.isArray(response.fonts)) {
|
|
||||||
console.error('[UnifiedFontStore] response.fonts is not an array', {
|
|
||||||
fonts: response.fonts,
|
|
||||||
});
|
|
||||||
throw new Error('Proxy API fonts is not an array');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store pagination metadata separately for derived values
|
|
||||||
this.#paginationMetadata = {
|
|
||||||
total: response.total ?? 0,
|
|
||||||
limit: response.limit ?? this.params.limit ?? 50,
|
|
||||||
offset: response.offset ?? this.params.offset ?? 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Accumulate fonts for infinite scroll
|
|
||||||
// Note: For offset === 0, we rely on the $effect above to handle the reset/init
|
|
||||||
// This prevents race conditions and double-setting.
|
|
||||||
if (params.offset !== 0) {
|
|
||||||
this.#accumulatedFonts = [...this.#accumulatedFonts, ...response.fonts];
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.fonts;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all accumulated fonts (for infinite scroll)
|
|
||||||
*/
|
|
||||||
get fonts(): UnifiedFont[] {
|
|
||||||
return this.#accumulatedFonts;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if loading initial data
|
|
||||||
*/
|
|
||||||
get isLoading(): boolean {
|
|
||||||
return this.result.isLoading;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if fetching (including background refetches)
|
|
||||||
*/
|
|
||||||
get isFetching(): boolean {
|
|
||||||
return this.result.isFetching;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if error occurred
|
|
||||||
*/
|
|
||||||
get isError(): boolean {
|
|
||||||
return this.result.isError;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if result is empty (not loading and no fonts)
|
|
||||||
*/
|
|
||||||
get isEmpty(): boolean {
|
|
||||||
return !this.isLoading && this.fonts.length === 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set providers filter
|
|
||||||
*/
|
|
||||||
setProviders(providers: ProxyFontsParams['providers']) {
|
|
||||||
this.setParams({ providers });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set categories filter
|
|
||||||
*/
|
|
||||||
setCategories(categories: ProxyFontsParams['categories']) {
|
|
||||||
this.setParams({ categories });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set subsets filter
|
|
||||||
*/
|
|
||||||
setSubsets(subsets: ProxyFontsParams['subsets']) {
|
|
||||||
this.setParams({ subsets });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set search query
|
|
||||||
*/
|
|
||||||
setSearch(search: string) {
|
|
||||||
this.setParams({ q: search || undefined });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set sort order
|
|
||||||
*/
|
|
||||||
setSort(sort: ProxyFontsParams['sort']) {
|
|
||||||
this.setParams({ sort });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Go to next page
|
|
||||||
*/
|
|
||||||
nextPage() {
|
|
||||||
if (this.pagination.hasMore) {
|
|
||||||
this.setParams({
|
|
||||||
offset: this.pagination.offset + this.pagination.limit,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Go to previous page
|
|
||||||
*/
|
|
||||||
prevPage() {
|
|
||||||
if (this.pagination.page > 1) {
|
|
||||||
this.setParams({
|
|
||||||
offset: this.pagination.offset - this.pagination.limit,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Go to specific page
|
|
||||||
*/
|
|
||||||
goToPage(page: number) {
|
|
||||||
if (page >= 1 && page <= this.pagination.totalPages) {
|
|
||||||
this.setParams({
|
|
||||||
offset: (page - 1) * this.pagination.limit,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set limit (items per page)
|
|
||||||
*/
|
|
||||||
setLimit(limit: number) {
|
|
||||||
this.setParams({ limit });
|
|
||||||
}
|
|
||||||
|
|
||||||
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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Factory function to create unified font store
|
|
||||||
*/
|
|
||||||
export function createUnifiedFontStore(params: ProxyFontsParams = {}) {
|
|
||||||
return new UnifiedFontStore(params);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Singleton instance for global use
|
|
||||||
* Initialized with a default limit to prevent fetching all fonts at once
|
|
||||||
*/
|
|
||||||
export const unifiedFontStore = new UnifiedFontStore({
|
|
||||||
limit: 50,
|
|
||||||
offset: 0,
|
|
||||||
});
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
/**
|
|
||||||
* Common font domain types
|
|
||||||
*
|
|
||||||
* Shared types for font entities across providers (Google, Fontshare).
|
|
||||||
* Includes categories, subsets, weights, and filter types.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { FontCategory as FontshareFontCategory } from './fontshare';
|
|
||||||
import type { FontCategory as GoogleFontCategory } from './google';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unified font category across all providers
|
|
||||||
*/
|
|
||||||
export type FontCategory = GoogleFontCategory | FontshareFontCategory;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Font provider identifier
|
|
||||||
*/
|
|
||||||
export type FontProvider = 'google' | 'fontshare';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Character subset support
|
|
||||||
*/
|
|
||||||
export type FontSubset = 'latin' | 'latin-ext' | 'cyrillic' | 'greek' | 'arabic' | 'devanagari';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Combined filter state for font queries
|
|
||||||
*/
|
|
||||||
export interface FontFilters {
|
|
||||||
/** Selected font providers */
|
|
||||||
providers: FontProvider[];
|
|
||||||
/** Selected font categories */
|
|
||||||
categories: FontCategory[];
|
|
||||||
/** Selected character subsets */
|
|
||||||
subsets: FontSubset[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Filter group identifier */
|
|
||||||
export type FilterGroup = 'providers' | 'categories' | 'subsets';
|
|
||||||
|
|
||||||
/** Filter type including search query */
|
|
||||||
export type FilterType = FilterGroup | 'searchQuery';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Numeric font weights (100-900)
|
|
||||||
*/
|
|
||||||
export type FontWeight = '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Italic variant with weight: "100italic", "400italic", "700italic", etc.
|
|
||||||
*/
|
|
||||||
export type FontWeightItalic = `${FontWeight}italic`;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* All possible font variant identifiers
|
|
||||||
*
|
|
||||||
* Includes:
|
|
||||||
* - Numeric weights: "400", "700", etc.
|
|
||||||
* - Italic variants: "400italic", "700italic", etc.
|
|
||||||
* - Legacy names: "regular", "italic", "bold", "bolditalic"
|
|
||||||
*/
|
|
||||||
export type FontVariant =
|
|
||||||
| FontWeight
|
|
||||||
| FontWeightItalic
|
|
||||||
| 'regular'
|
|
||||||
| 'italic'
|
|
||||||
| 'bold'
|
|
||||||
| 'bolditalic';
|
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
/**
|
||||||
|
* Font domain types
|
||||||
|
*
|
||||||
|
* Shared types for font entities across providers (Google, Fontshare).
|
||||||
|
* Includes categories, subsets, weights, and the unified font model.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unified font category across all providers
|
||||||
|
*/
|
||||||
|
export type FontCategory =
|
||||||
|
| 'sans-serif'
|
||||||
|
| 'serif'
|
||||||
|
| 'display'
|
||||||
|
| 'handwriting'
|
||||||
|
| 'monospace'
|
||||||
|
| 'slab'
|
||||||
|
| 'script';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Font provider identifier
|
||||||
|
*/
|
||||||
|
export type FontProvider = 'google' | 'fontshare';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Character subset support
|
||||||
|
*/
|
||||||
|
export type FontSubset = 'latin' | 'latin-ext' | 'cyrillic' | 'greek' | 'arabic' | 'devanagari';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combined filter state for font queries
|
||||||
|
*/
|
||||||
|
export interface FontFilters {
|
||||||
|
/**
|
||||||
|
* Active font providers to fetch from
|
||||||
|
*/
|
||||||
|
providers: FontProvider[];
|
||||||
|
/**
|
||||||
|
* Visual classifications (sans, serif, etc.)
|
||||||
|
*/
|
||||||
|
categories: FontCategory[];
|
||||||
|
/**
|
||||||
|
* Character sets required for the sample text
|
||||||
|
*/
|
||||||
|
subsets: FontSubset[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter group identifier
|
||||||
|
*/
|
||||||
|
export type FilterGroup = 'providers' | 'categories' | 'subsets';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter type including search query
|
||||||
|
*/
|
||||||
|
export type FilterType = FilterGroup | 'searchQuery';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Numeric font weights (100-900)
|
||||||
|
*/
|
||||||
|
export type FontWeight = '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Italic variant with weight: "100italic", "400italic", "700italic", etc.
|
||||||
|
*/
|
||||||
|
export type FontWeightItalic = `${FontWeight}italic`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All possible font variant identifiers
|
||||||
|
*
|
||||||
|
* Includes:
|
||||||
|
* - Numeric weights: "400", "700", etc.
|
||||||
|
* - Italic variants: "400italic", "700italic", etc.
|
||||||
|
* - Legacy names: "regular", "italic", "bold", "bolditalic"
|
||||||
|
*/
|
||||||
|
export type FontVariant =
|
||||||
|
| FontWeight
|
||||||
|
| FontWeightItalic
|
||||||
|
| 'regular'
|
||||||
|
| 'italic'
|
||||||
|
| 'bold'
|
||||||
|
| 'bolditalic';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standardized font variant alias
|
||||||
|
*/
|
||||||
|
export type UnifiedFontVariant = FontVariant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Font style URLs
|
||||||
|
*/
|
||||||
|
export interface FontStyleUrls {
|
||||||
|
/**
|
||||||
|
* URL for the regular (400) weight
|
||||||
|
*/
|
||||||
|
regular?: string;
|
||||||
|
/**
|
||||||
|
* URL for the italic (400) style
|
||||||
|
*/
|
||||||
|
italic?: string;
|
||||||
|
/**
|
||||||
|
* URL for the bold (700) weight
|
||||||
|
*/
|
||||||
|
bold?: string;
|
||||||
|
/**
|
||||||
|
* URL for the bold-italic (700) style
|
||||||
|
*/
|
||||||
|
boldItalic?: string;
|
||||||
|
/**
|
||||||
|
* Mapping for all other numeric/custom variants
|
||||||
|
*/
|
||||||
|
variants?: Partial<Record<UnifiedFontVariant, string>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Font metadata
|
||||||
|
*/
|
||||||
|
export interface FontMetadata {
|
||||||
|
/**
|
||||||
|
* Epoch timestamp of last successful fetch
|
||||||
|
*/
|
||||||
|
cachedAt: number;
|
||||||
|
/**
|
||||||
|
* Semantic version string from upstream
|
||||||
|
*/
|
||||||
|
version?: string;
|
||||||
|
/**
|
||||||
|
* ISO date string of last remote update
|
||||||
|
*/
|
||||||
|
lastModified?: string;
|
||||||
|
/**
|
||||||
|
* Raw ranking integer from provider
|
||||||
|
*/
|
||||||
|
popularity?: number;
|
||||||
|
/**
|
||||||
|
* Normalized score (0-100) used for global sorting
|
||||||
|
*/
|
||||||
|
popularityScore?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Font features (variable fonts, axes, tags)
|
||||||
|
*/
|
||||||
|
export interface FontFeatures {
|
||||||
|
/**
|
||||||
|
* Whether the font supports fluid weight/width axes
|
||||||
|
*/
|
||||||
|
isVariable?: boolean;
|
||||||
|
/**
|
||||||
|
* Definable axes for variable font interpolation
|
||||||
|
*/
|
||||||
|
axes?: Array<{
|
||||||
|
/**
|
||||||
|
* Human-readable axis name (e.g., 'Weight')
|
||||||
|
*/
|
||||||
|
name: string;
|
||||||
|
/**
|
||||||
|
* CSS property name (e.g., 'wght')
|
||||||
|
*/
|
||||||
|
property: string;
|
||||||
|
/**
|
||||||
|
* Default numeric value for the axis
|
||||||
|
*/
|
||||||
|
default: number;
|
||||||
|
/**
|
||||||
|
* Minimum inclusive bound
|
||||||
|
*/
|
||||||
|
min: number;
|
||||||
|
/**
|
||||||
|
* Maximum inclusive bound
|
||||||
|
*/
|
||||||
|
max: number;
|
||||||
|
}>;
|
||||||
|
/**
|
||||||
|
* Descriptive keywords for search indexing
|
||||||
|
*/
|
||||||
|
tags?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unified font model
|
||||||
|
*
|
||||||
|
* Combines Google Fonts and Fontshare data into a common interface
|
||||||
|
* for consistent font handling across the application.
|
||||||
|
*/
|
||||||
|
export interface UnifiedFont {
|
||||||
|
/**
|
||||||
|
* Unique ID (family name for Google, slug for Fontshare)
|
||||||
|
*/
|
||||||
|
id: string;
|
||||||
|
/**
|
||||||
|
* Canonical family name for CSS font-family
|
||||||
|
*/
|
||||||
|
name: string;
|
||||||
|
/**
|
||||||
|
* Upstream data source
|
||||||
|
*/
|
||||||
|
provider: FontProvider;
|
||||||
|
/**
|
||||||
|
* Display label for provider badges
|
||||||
|
*/
|
||||||
|
providerBadge?: string;
|
||||||
|
/**
|
||||||
|
* Primary typographic category
|
||||||
|
*/
|
||||||
|
category: FontCategory;
|
||||||
|
/**
|
||||||
|
* All supported character sets
|
||||||
|
*/
|
||||||
|
subsets: FontSubset[];
|
||||||
|
/**
|
||||||
|
* List of available weights and styles
|
||||||
|
*/
|
||||||
|
variants: UnifiedFontVariant[];
|
||||||
|
/**
|
||||||
|
* Remote assets for font loading
|
||||||
|
*/
|
||||||
|
styles: FontStyleUrls;
|
||||||
|
/**
|
||||||
|
* Technical metadata and rankings
|
||||||
|
*/
|
||||||
|
metadata: FontMetadata;
|
||||||
|
/**
|
||||||
|
* Variable font details and tags
|
||||||
|
*/
|
||||||
|
features: FontFeatures;
|
||||||
|
}
|
||||||
@@ -1,468 +0,0 @@
|
|||||||
/**
|
|
||||||
* ============================================================================
|
|
||||||
* FONTHARE API TYPES
|
|
||||||
* ============================================================================
|
|
||||||
*/
|
|
||||||
export const FONTSHARE_API_URL = 'https://api.fontshare.com/v2/fonts' as const;
|
|
||||||
|
|
||||||
export type FontCategory = 'sans' | 'serif' | 'slab' | 'display' | 'handwritten' | 'script';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Model of Fontshare API response
|
|
||||||
* @see https://fontshare.com
|
|
||||||
*
|
|
||||||
* Fontshare API uses 'fonts' key instead of 'items' for the array
|
|
||||||
*/
|
|
||||||
export interface FontshareApiModel {
|
|
||||||
/**
|
|
||||||
* Number of items returned in current page/response
|
|
||||||
*/
|
|
||||||
count: number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Total number of items available across all pages
|
|
||||||
*/
|
|
||||||
count_total: number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Indicates if there are more items available beyond this page
|
|
||||||
*/
|
|
||||||
has_more: boolean;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Array of fonts (Fontshare uses 'fonts' key, not 'items')
|
|
||||||
*/
|
|
||||||
fonts: FontshareFont[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Individual font metadata from Fontshare API
|
|
||||||
*/
|
|
||||||
export interface FontshareFont {
|
|
||||||
/**
|
|
||||||
* Unique identifier for the font
|
|
||||||
* UUID v4 format (e.g., "20e9fcdc-1e41-4559-a43d-1ede0adc8896")
|
|
||||||
*/
|
|
||||||
id: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Display name of the font family
|
|
||||||
* Examples: "Satoshi", "General Sans", "Clash Display"
|
|
||||||
*/
|
|
||||||
name: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Native/localized name of the font (if available)
|
|
||||||
* Often null for Latin-script fonts
|
|
||||||
*/
|
|
||||||
native_name: string | null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* URL-friendly identifier for the font
|
|
||||||
* Used in URLs: e.g., "satoshi", "general-sans", "clash-display"
|
|
||||||
*/
|
|
||||||
slug: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Font category classification
|
|
||||||
* Examples: "Sans", "Serif", "Display", "Script"
|
|
||||||
*/
|
|
||||||
category: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Script/writing system supported by the font
|
|
||||||
* Examples: "latin", "arabic", "devanagari"
|
|
||||||
*/
|
|
||||||
script: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Font publisher/foundry information
|
|
||||||
*/
|
|
||||||
publisher: FontsharePublisher;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Array of designers who created this font
|
|
||||||
* Multiple designers may have collaborated on a single font
|
|
||||||
*/
|
|
||||||
designers: FontshareDesigner[];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Related font families (if any)
|
|
||||||
* Often null, as fonts are typically independent
|
|
||||||
*/
|
|
||||||
related_families: string | null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether to display publisher as the designer instead of individual designers
|
|
||||||
*/
|
|
||||||
display_publisher_as_designer: boolean;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether trial downloads are enabled for this font
|
|
||||||
*/
|
|
||||||
trials_enabled: boolean;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether to show Latin-specific metrics
|
|
||||||
*/
|
|
||||||
show_latin_metrics: boolean;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Type of license for this font
|
|
||||||
* Examples: "itf_ffl" (ITF Free Font License)
|
|
||||||
*/
|
|
||||||
license_type: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Comma-separated list of languages supported by this font
|
|
||||||
* Example: "Afar, Afrikaans, Albanian, Aranese, Aromanian, Aymara, ..."
|
|
||||||
*/
|
|
||||||
languages: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ISO 8601 timestamp when the font was added to Fontshare
|
|
||||||
* Format: "2021-03-12T20:49:05Z"
|
|
||||||
*/
|
|
||||||
inserted_at: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* HTML-formatted story/description about the font
|
|
||||||
* Contains marketing text, design philosophy, and usage recommendations
|
|
||||||
*/
|
|
||||||
story: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Version of the font family
|
|
||||||
* Format: "1.0", "1.2", etc.
|
|
||||||
*/
|
|
||||||
version: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Total number of times this font has been viewed
|
|
||||||
*/
|
|
||||||
views: number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Number of views in the recent time period
|
|
||||||
*/
|
|
||||||
views_recent: number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether this font is marked as "hot"/trending
|
|
||||||
*/
|
|
||||||
is_hot: boolean;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether this font is marked as new
|
|
||||||
*/
|
|
||||||
is_new: boolean;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether this font is in the shortlisted collection
|
|
||||||
*/
|
|
||||||
is_shortlisted: boolean | null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether this font is marked as top/popular
|
|
||||||
*/
|
|
||||||
is_top: boolean;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Variable font axes (for variable fonts)
|
|
||||||
* Empty array [] for static fonts
|
|
||||||
*/
|
|
||||||
axes: FontshareAxis[];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tags/categories for this font
|
|
||||||
* Examples: ["Magazines", "Branding", "Logos", "Posters"]
|
|
||||||
*/
|
|
||||||
font_tags: FontshareTag[];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* OpenType features available in this font
|
|
||||||
*/
|
|
||||||
features: FontshareFeature[];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Array of available font styles/variants
|
|
||||||
* Each style represents a different font file (weight, italic, variable)
|
|
||||||
*/
|
|
||||||
styles: FontshareStyle[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Publisher/foundry information
|
|
||||||
*/
|
|
||||||
export interface FontsharePublisher {
|
|
||||||
/**
|
|
||||||
* Description/bio of the publisher
|
|
||||||
* Example: "Indian Type Foundry (ITF) creates retail and custom multilingual fonts..."
|
|
||||||
*/
|
|
||||||
bio: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Publisher email (if available)
|
|
||||||
*/
|
|
||||||
email: string | null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unique publisher identifier
|
|
||||||
* UUID format
|
|
||||||
*/
|
|
||||||
id: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Publisher links (social media, website, etc.)
|
|
||||||
*/
|
|
||||||
links: FontshareLink[];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Publisher name
|
|
||||||
* Example: "Indian Type Foundry"
|
|
||||||
*/
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Designer information
|
|
||||||
*/
|
|
||||||
export interface FontshareDesigner {
|
|
||||||
/**
|
|
||||||
* Designer bio/description
|
|
||||||
*/
|
|
||||||
bio: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Designer links (Twitter, website, etc.)
|
|
||||||
*/
|
|
||||||
links: FontshareLink[];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Designer name
|
|
||||||
*/
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Link information
|
|
||||||
*/
|
|
||||||
export interface FontshareLink {
|
|
||||||
/**
|
|
||||||
* Name of the link platform/site
|
|
||||||
* Examples: "Twitter", "GitHub", "Website"
|
|
||||||
*/
|
|
||||||
name: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* URL of the link (may be null)
|
|
||||||
*/
|
|
||||||
url: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Font tag/category
|
|
||||||
*/
|
|
||||||
export interface FontshareTag {
|
|
||||||
/**
|
|
||||||
* Tag name
|
|
||||||
* Examples: "Magazines", "Branding", "Logos", "Posters"
|
|
||||||
*/
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* OpenType feature
|
|
||||||
*/
|
|
||||||
export interface FontshareFeature {
|
|
||||||
/**
|
|
||||||
* Feature name (descriptive name or null)
|
|
||||||
* Examples: "Alternate t", "All Alternates", or null
|
|
||||||
*/
|
|
||||||
name: string | null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether this feature is on by default
|
|
||||||
*/
|
|
||||||
on_by_default: boolean;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* OpenType feature tag (4-character code)
|
|
||||||
* Examples: "ss01", "frac", "liga", "aalt", "case"
|
|
||||||
*/
|
|
||||||
tag: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Variable font axis (for variable fonts)
|
|
||||||
* Defines the range and properties of a variable font axis (e.g., weight)
|
|
||||||
*/
|
|
||||||
export interface FontshareAxis {
|
|
||||||
/**
|
|
||||||
* Name of the axis
|
|
||||||
* Example: "wght" (weight axis)
|
|
||||||
*/
|
|
||||||
name: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* CSS property name for the axis
|
|
||||||
* Example: "wght"
|
|
||||||
*/
|
|
||||||
property: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Default value for the axis
|
|
||||||
* Example: 420.0, 650.0, 700.0
|
|
||||||
*/
|
|
||||||
range_default: number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Minimum value for the axis
|
|
||||||
* Example: 300.0, 100.0, 200.0
|
|
||||||
*/
|
|
||||||
range_left: number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Maximum value for the axis
|
|
||||||
* Example: 900.0, 700.0, 800.0
|
|
||||||
*/
|
|
||||||
range_right: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Individual font style/variant
|
|
||||||
* Each style represents a single downloadable font file
|
|
||||||
*/
|
|
||||||
export interface FontshareStyle {
|
|
||||||
/**
|
|
||||||
* Unique identifier for this style
|
|
||||||
* UUID format
|
|
||||||
*/
|
|
||||||
id: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether this is the default style for the font family
|
|
||||||
* Typically, one style per font is marked as default
|
|
||||||
*/
|
|
||||||
default: boolean;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* CDN URL to the font file
|
|
||||||
* Protocol-relative URL: "//cdn.fontshare.com/wf/..."
|
|
||||||
* Note: URL starts with "//" (protocol-relative), may need protocol prepended
|
|
||||||
*/
|
|
||||||
file: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether this style is italic
|
|
||||||
* false for upright, true for italic styles
|
|
||||||
*/
|
|
||||||
is_italic: boolean;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether this is a variable font
|
|
||||||
* Variable fonts have adjustable axes (weight, slant, etc.)
|
|
||||||
*/
|
|
||||||
is_variable: boolean;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Typography properties for this style
|
|
||||||
* Contains measurements like cap height, x-height, ascenders/descenders
|
|
||||||
* May be empty object {} for some styles
|
|
||||||
*/
|
|
||||||
properties: FontshareStyleProperties | Record<string, never>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Weight information for this style
|
|
||||||
*/
|
|
||||||
weight: FontshareWeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Typography/measurement properties for a font style
|
|
||||||
*/
|
|
||||||
export interface FontshareStyleProperties {
|
|
||||||
/**
|
|
||||||
* Distance from baseline to the top of ascenders
|
|
||||||
* Example: 1010, 990, 1000
|
|
||||||
*/
|
|
||||||
ascending_leading: number | null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Height of uppercase letters (cap height)
|
|
||||||
* Example: 710, 680, 750
|
|
||||||
*/
|
|
||||||
cap_height: number | null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Distance from baseline to the bottom of descenders (negative value)
|
|
||||||
* Example: -203, -186, -220
|
|
||||||
*/
|
|
||||||
descending_leading: number | null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Body height of the font
|
|
||||||
* Often null in Fontshare data
|
|
||||||
*/
|
|
||||||
body_height: number | null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Maximum character width in the font
|
|
||||||
* Example: 1739, 1739, 1739
|
|
||||||
*/
|
|
||||||
max_char_width: number | null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Height of lowercase x-height
|
|
||||||
* Example: 480, 494, 523
|
|
||||||
*/
|
|
||||||
x_height: number | null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Maximum Y coordinate (top of ascenders)
|
|
||||||
* Example: 1010, 990, 1026
|
|
||||||
*/
|
|
||||||
y_max: number | null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Minimum Y coordinate (bottom of descenders)
|
|
||||||
* Example: -240, -250, -280
|
|
||||||
*/
|
|
||||||
y_min: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Weight information for a font style
|
|
||||||
*/
|
|
||||||
export interface FontshareWeight {
|
|
||||||
/**
|
|
||||||
* Display label for the weight
|
|
||||||
* Examples: "Light", "Regular", "Bold", "Variable", "Variable Italic"
|
|
||||||
*/
|
|
||||||
label: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Internal name for the weight
|
|
||||||
* Examples: "Light", "Regular", "Bold", "Variable", "VariableItalic"
|
|
||||||
*/
|
|
||||||
name: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Native/localized name for the weight (if available)
|
|
||||||
* Often null for Latin-script fonts
|
|
||||||
*/
|
|
||||||
native_name: string | null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Numeric weight value
|
|
||||||
* Examples: 300, 400, 700, 0 (for variable fonts), 1, 2
|
|
||||||
* Note: This matches the `weight` property
|
|
||||||
*/
|
|
||||||
number: number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Numeric weight value (duplicate of `number`)
|
|
||||||
* Appears to be redundant with `number` field
|
|
||||||
*/
|
|
||||||
weight: number;
|
|
||||||
}
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
/**
|
|
||||||
* ============================================================================
|
|
||||||
* GOOGLE FONTS API TYPES
|
|
||||||
* ============================================================================
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { FontVariant } from './common';
|
|
||||||
|
|
||||||
export type FontCategory = 'sans-serif' | 'serif' | 'display' | 'handwriting' | 'monospace';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Model of google fonts api response
|
|
||||||
*/
|
|
||||||
export interface GoogleFontsApiModel {
|
|
||||||
/**
|
|
||||||
* Array of font items returned by the Google Fonts API
|
|
||||||
* Contains all font families matching the requested query parameters
|
|
||||||
*/
|
|
||||||
items: FontItem[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Individual font from Google Fonts API
|
|
||||||
*/
|
|
||||||
export interface FontItem {
|
|
||||||
/**
|
|
||||||
* Font family name (e.g., "Roboto", "Open Sans", "Lato")
|
|
||||||
* This is the name used in CSS font-family declarations
|
|
||||||
*/
|
|
||||||
family: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Font category classification (e.g., "sans-serif", "serif", "display", "handwriting", "monospace")
|
|
||||||
* Useful for grouping and filtering fonts by style
|
|
||||||
*/
|
|
||||||
category: FontCategory;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Available font variants for this font family
|
|
||||||
* Array of strings representing available weights and styles
|
|
||||||
* Examples: ["regular", "italic", "100", "200", "300", "400", "500", "600", "700", "800", "900", "100italic", "900italic"]
|
|
||||||
* The keys in the `files` object correspond to these variant values
|
|
||||||
*/
|
|
||||||
variants: FontVariant[];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Supported character subsets for this font
|
|
||||||
* Examples: ["latin", "latin-ext", "cyrillic", "greek", "arabic", "devanagari", "vietnamese", "hebrew", "thai", etc.]
|
|
||||||
* Determines which character sets are included in the font files
|
|
||||||
*/
|
|
||||||
subsets: string[];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Font version identifier
|
|
||||||
* Format: "v" followed by version number (e.g., "v31", "v20", "v1")
|
|
||||||
* Used to track font updates and cache busting
|
|
||||||
*/
|
|
||||||
version: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Last modification date of the font
|
|
||||||
* Format: ISO 8601 date string (e.g., "2024-01-15", "2023-12-01")
|
|
||||||
* Indicates when the font was last updated by the font foundry
|
|
||||||
*/
|
|
||||||
lastModified: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mapping of font variants to their downloadable URLs
|
|
||||||
* Keys correspond to values in the `variants` array
|
|
||||||
* Examples:
|
|
||||||
* - "regular" → "https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Me4W..."
|
|
||||||
* - "700" → "https://fonts.gstatic.com/s/roboto/v30/KFOlCnqEu92Fr1MmWUlf..."
|
|
||||||
* - "700italic" → "https://fonts.gstatic.com/s/roboto/v30/KFOjCnqEu92Fr1Mu51TzA..."
|
|
||||||
*/
|
|
||||||
files: FontFiles;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* URL to the font menu preview image
|
|
||||||
* Typically a PNG showing the font family name in the font
|
|
||||||
* Example: "https://fonts.gstatic.com/l/font?kit=KFOmCnqEu92Fr1Me4W...&s=i2"
|
|
||||||
*/
|
|
||||||
menu: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Type alias for backward compatibility
|
|
||||||
* Google Fonts API font item
|
|
||||||
*/
|
|
||||||
export type GoogleFontItem = FontItem;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Google Fonts API file mapping
|
|
||||||
* Dynamic keys that match the variants array
|
|
||||||
*
|
|
||||||
* Examples:
|
|
||||||
* - { "regular": "...", "italic": "...", "700": "...", "700italic": "..." }
|
|
||||||
* - { "400": "...", "400italic": "...", "900": "..." }
|
|
||||||
*/
|
|
||||||
export type FontFiles = Partial<Record<FontVariant, string>>;
|
|
||||||
@@ -1,54 +1,20 @@
|
|||||||
/**
|
// Font domain and model types
|
||||||
* ============================================================================
|
|
||||||
* SINGLE EXPORT POINT
|
|
||||||
* ============================================================================
|
|
||||||
*
|
|
||||||
* This is the single export point for all Font types.
|
|
||||||
* All imports should use: `import { X } from '$entities/Font/model/types'`
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Domain types
|
|
||||||
export type {
|
export type {
|
||||||
|
FilterGroup,
|
||||||
|
FilterType,
|
||||||
FontCategory,
|
FontCategory,
|
||||||
|
FontFeatures,
|
||||||
|
FontFilters,
|
||||||
|
FontMetadata,
|
||||||
FontProvider,
|
FontProvider,
|
||||||
|
FontStyleUrls,
|
||||||
FontSubset,
|
FontSubset,
|
||||||
FontVariant,
|
FontVariant,
|
||||||
FontWeight,
|
FontWeight,
|
||||||
FontWeightItalic,
|
FontWeightItalic,
|
||||||
} from './common';
|
|
||||||
|
|
||||||
// Google Fonts API types
|
|
||||||
export type {
|
|
||||||
FontFiles,
|
|
||||||
FontItem,
|
|
||||||
GoogleFontItem,
|
|
||||||
GoogleFontsApiModel,
|
|
||||||
} from './google';
|
|
||||||
|
|
||||||
// Fontshare API types
|
|
||||||
export type {
|
|
||||||
FontshareApiModel,
|
|
||||||
FontshareAxis,
|
|
||||||
FontshareDesigner,
|
|
||||||
FontshareFeature,
|
|
||||||
FontshareFont,
|
|
||||||
FontshareLink,
|
|
||||||
FontsharePublisher,
|
|
||||||
FontshareStyle,
|
|
||||||
FontshareStyleProperties,
|
|
||||||
FontshareTag,
|
|
||||||
FontshareWeight,
|
|
||||||
} from './fontshare';
|
|
||||||
export { FONTSHARE_API_URL } from './fontshare';
|
|
||||||
|
|
||||||
// Normalization types
|
|
||||||
export type {
|
|
||||||
FontFeatures,
|
|
||||||
FontMetadata,
|
|
||||||
FontStyleUrls,
|
|
||||||
UnifiedFont,
|
UnifiedFont,
|
||||||
UnifiedFontVariant,
|
UnifiedFontVariant,
|
||||||
} from './normalize';
|
} from './font';
|
||||||
|
|
||||||
// Store types
|
// Store types
|
||||||
export type {
|
export type {
|
||||||
@@ -58,3 +24,4 @@ export type {
|
|||||||
} from './store';
|
} from './store';
|
||||||
|
|
||||||
export * from './store/appliedFonts';
|
export * from './store/appliedFonts';
|
||||||
|
export * from './typography';
|
||||||
|
|||||||
@@ -1,108 +0,0 @@
|
|||||||
/**
|
|
||||||
* ============================================================================
|
|
||||||
* NORMALIZATION TYPES
|
|
||||||
* ============================================================================
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type {
|
|
||||||
FontCategory,
|
|
||||||
FontProvider,
|
|
||||||
FontSubset,
|
|
||||||
FontVariant,
|
|
||||||
} from './common';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Font variant types (standardized)
|
|
||||||
*/
|
|
||||||
export type UnifiedFontVariant = FontVariant;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Font style URLs
|
|
||||||
*/
|
|
||||||
export interface LegacyFontStyleUrls {
|
|
||||||
/** Regular weight URL */
|
|
||||||
regular?: string;
|
|
||||||
/** Italic URL */
|
|
||||||
italic?: string;
|
|
||||||
/** Bold weight URL */
|
|
||||||
bold?: string;
|
|
||||||
/** Bold italic URL */
|
|
||||||
boldItalic?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FontStyleUrls extends LegacyFontStyleUrls {
|
|
||||||
variants?: Partial<Record<UnifiedFontVariant, string>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Font metadata
|
|
||||||
*/
|
|
||||||
export interface FontMetadata {
|
|
||||||
/** Timestamp when font was cached */
|
|
||||||
cachedAt: number;
|
|
||||||
/** Font version from provider */
|
|
||||||
version?: string;
|
|
||||||
/** Last modified date from provider */
|
|
||||||
lastModified?: string;
|
|
||||||
/** Popularity rank (if available from provider) */
|
|
||||||
popularity?: number;
|
|
||||||
/**
|
|
||||||
* Normalized popularity score (0-100)
|
|
||||||
*
|
|
||||||
* Normalized across all fonts for consistent ranking
|
|
||||||
* Higher values indicate more popular fonts
|
|
||||||
*/
|
|
||||||
popularityScore?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Font features (variable fonts, axes, tags)
|
|
||||||
*/
|
|
||||||
export interface FontFeatures {
|
|
||||||
/** Whether this is a variable font */
|
|
||||||
isVariable?: boolean;
|
|
||||||
/** Variable font axes (for Fontshare) */
|
|
||||||
axes?: Array<{
|
|
||||||
name: string;
|
|
||||||
property: string;
|
|
||||||
default: number;
|
|
||||||
min: number;
|
|
||||||
max: number;
|
|
||||||
}>;
|
|
||||||
/** Usage tags (for Fontshare) */
|
|
||||||
tags?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unified font model
|
|
||||||
*
|
|
||||||
* Combines Google Fonts and Fontshare data into a common interface
|
|
||||||
* for consistent font handling across the application.
|
|
||||||
*/
|
|
||||||
export interface UnifiedFont {
|
|
||||||
/** Unique identifier (Google: family name, Fontshare: slug) */
|
|
||||||
id: string;
|
|
||||||
/** Font display name */
|
|
||||||
name: string;
|
|
||||||
/** Font provider (google | fontshare) */
|
|
||||||
provider: FontProvider;
|
|
||||||
/**
|
|
||||||
* Provider badge display name
|
|
||||||
*
|
|
||||||
* Human-readable provider name for UI display
|
|
||||||
* e.g., "Google Fonts" or "Fontshare"
|
|
||||||
*/
|
|
||||||
providerBadge?: string;
|
|
||||||
/** Font category classification */
|
|
||||||
category: FontCategory;
|
|
||||||
/** Supported character subsets */
|
|
||||||
subsets: FontSubset[];
|
|
||||||
/** Available font variants (weights, styles) */
|
|
||||||
variants: UnifiedFontVariant[];
|
|
||||||
/** URL mapping for font file downloads */
|
|
||||||
styles: FontStyleUrls;
|
|
||||||
/** Additional metadata */
|
|
||||||
metadata: FontMetadata;
|
|
||||||
/** Advanced font features */
|
|
||||||
features: FontFeatures;
|
|
||||||
}
|
|
||||||
@@ -1,48 +1,60 @@
|
|||||||
/**
|
|
||||||
* ============================================================================
|
|
||||||
* STORE TYPES
|
|
||||||
* ============================================================================
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
FontCategory,
|
FontCategory,
|
||||||
FontProvider,
|
FontProvider,
|
||||||
FontSubset,
|
FontSubset,
|
||||||
} from './common';
|
UnifiedFont,
|
||||||
import type { UnifiedFont } from './normalize';
|
} 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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export type ControlId = 'font_size' | 'font_weight' | 'line_height' | 'letter_spacing';
|
||||||
@@ -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:
|
||||||
|
'Loads a font and applies it to children. Shows blur/scale loading state until font is ready, then reveals with a smooth transition.',
|
||||||
|
},
|
||||||
|
story: { inline: false },
|
||||||
|
},
|
||||||
|
layout: 'centered',
|
||||||
|
},
|
||||||
|
argTypes: {
|
||||||
|
weight: { control: 'number' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { mockUnifiedFont } from '$entities/Font/lib/mocks';
|
||||||
|
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:
|
||||||
|
'Font that has never been loaded by appliedFontsManager. The component renders in its pending state: blurred, scaled down, and semi-transparent.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
args={{ font: fontUnknown, weight: 400 }}
|
||||||
|
>
|
||||||
|
{#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:
|
||||||
|
'Uses Arial, a system font available in all browsers. Because appliedFontsManager has not loaded it via FontFace, the manager status may remain pending — meaning the blur/scale state may still show. In a real app the manager would load the font and transition to the revealed state.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
args={{ font: fontArial, weight: 400 }}
|
||||||
|
>
|
||||||
|
{#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="Custom Weight"
|
||||||
|
parameters={{
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
story:
|
||||||
|
'Demonstrates passing a custom weight (700). The weight is forwarded to appliedFontsManager for font resolution; visually identical to the loaded state story until the manager confirms the font.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
args={{ font: fontArialBold, weight: 700 }}
|
||||||
|
>
|
||||||
|
{#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,15 +1,13 @@
|
|||||||
<!--
|
<!--
|
||||||
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 clsx from 'clsx';
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import { prefersReducedMotion } from 'svelte/motion';
|
|
||||||
import {
|
import {
|
||||||
|
DEFAULT_FONT_WEIGHT,
|
||||||
type UnifiedFont,
|
type UnifiedFont,
|
||||||
appliedFontsManager,
|
appliedFontsManager,
|
||||||
} from '../../model';
|
} from '../../model';
|
||||||
@@ -32,13 +30,19 @@ 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 = 400,
|
weight = DEFAULT_FONT_WEIGHT,
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
|
skeleton,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const status = $derived(
|
const status = $derived(
|
||||||
@@ -49,30 +53,16 @@ const status = $derived(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// 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={clsx(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>
|
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
<script module>
|
||||||
|
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||||
|
import FontVirtualList from './FontVirtualList.svelte';
|
||||||
|
|
||||||
|
const { Story } = defineMeta({
|
||||||
|
title: 'Entities/FontVirtualList',
|
||||||
|
component: FontVirtualList,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
parameters: {
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
component:
|
||||||
|
'Virtualized font list backed by the `fontStore` singleton. Handles font loading registration (pin/touch) for visible items and triggers infinite scroll pagination via `fontStore.nextPage()`. Because the component reads directly from the `fontStore` singleton, stories render against a live (but empty/loading) store — no font data will appear unless the API is reachable from the Storybook host.',
|
||||||
|
},
|
||||||
|
story: { inline: false },
|
||||||
|
},
|
||||||
|
layout: 'padded',
|
||||||
|
},
|
||||||
|
argTypes: {
|
||||||
|
weight: { control: 'number', description: 'Font weight applied to visible fonts' },
|
||||||
|
itemHeight: { control: 'number', description: 'Height of each list item in pixels' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import type { ComponentProps } from 'svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Story
|
||||||
|
name="Loading Skeleton"
|
||||||
|
parameters={{
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
story:
|
||||||
|
'Skeleton state shown while `fontStore.fonts` is empty and `fontStore.isLoading` is true. In a real session the skeleton fades out once the first page loads.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
args={{ weight: 400, itemHeight: 72 }}
|
||||||
|
>
|
||||||
|
{#snippet template(args: ComponentProps<typeof FontVirtualList>)}
|
||||||
|
<div class="h-[400px] w-full">
|
||||||
|
<FontVirtualList {...args}>
|
||||||
|
{#snippet skeleton()}
|
||||||
|
<div class="flex flex-col gap-2 p-4">
|
||||||
|
{#each Array(6) as _}
|
||||||
|
<div class="h-16 animate-pulse rounded bg-neutral-200"></div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet children({ item })}
|
||||||
|
<div class="border-b border-neutral-100 p-3">{item.name}</div>
|
||||||
|
{/snippet}
|
||||||
|
</FontVirtualList>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</Story>
|
||||||
|
|
||||||
|
<Story
|
||||||
|
name="Empty State"
|
||||||
|
parameters={{
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
story:
|
||||||
|
'No `skeleton` snippet provided. When `fontStore.fonts` is empty the underlying VirtualList renders its empty state directly.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
args={{ weight: 400, itemHeight: 72 }}
|
||||||
|
>
|
||||||
|
{#snippet template(args: ComponentProps<typeof FontVirtualList>)}
|
||||||
|
<div class="h-[400px] w-full">
|
||||||
|
<FontVirtualList {...args}>
|
||||||
|
{#snippet children({ item })}
|
||||||
|
<div class="border-b border-neutral-100 p-3">{item.name}</div>
|
||||||
|
{/snippet}
|
||||||
|
</FontVirtualList>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</Story>
|
||||||
|
|
||||||
|
<Story
|
||||||
|
name="With Item Renderer"
|
||||||
|
parameters={{
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
story:
|
||||||
|
'Demonstrates how to configure a `children` snippet for item rendering. The list will be empty because `fontStore` is not populated in Storybook, but the template shows the expected slot shape: `{ item: UnifiedFont }`.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
args={{ weight: 400, itemHeight: 80 }}
|
||||||
|
>
|
||||||
|
{#snippet template(args: ComponentProps<typeof FontVirtualList>)}
|
||||||
|
<div class="h-[400px] w-full">
|
||||||
|
<FontVirtualList {...args}>
|
||||||
|
{#snippet skeleton()}
|
||||||
|
<div class="flex flex-col gap-2 p-4">
|
||||||
|
{#each Array(6) as _}
|
||||||
|
<div class="h-16 animate-pulse rounded bg-neutral-200"></div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet children({ item })}
|
||||||
|
<div class="flex items-center justify-between border-b border-neutral-100 px-4 py-3">
|
||||||
|
<span class="text-sm font-medium">{item.name}</span>
|
||||||
|
<span class="text-xs text-neutral-400">{item.category}</span>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</FontVirtualList>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</Story>
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
- Handles font registration with the manager
|
- Handles font registration with the manager
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { debounce } from '$shared/lib/utils';
|
||||||
import {
|
import {
|
||||||
Skeleton,
|
Skeleton,
|
||||||
VirtualList,
|
VirtualList,
|
||||||
@@ -18,7 +19,7 @@ import {
|
|||||||
type FontLoadRequestConfig,
|
type FontLoadRequestConfig,
|
||||||
type UnifiedFont,
|
type UnifiedFont,
|
||||||
appliedFontsManager,
|
appliedFontsManager,
|
||||||
unifiedFontStore,
|
fontStore,
|
||||||
} from '../../model';
|
} from '../../model';
|
||||||
|
|
||||||
interface Props extends
|
interface Props extends
|
||||||
@@ -50,44 +51,86 @@ let {
|
|||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const isLoading = $derived(
|
const isLoading = $derived(
|
||||||
unifiedFontStore.isFetching || unifiedFontStore.isLoading,
|
fontStore.isFetching || fontStore.isLoading,
|
||||||
);
|
);
|
||||||
|
|
||||||
function handleInternalVisibleChange(visibleItems: UnifiedFont[]) {
|
let visibleFonts = $state<UnifiedFont[]>([]);
|
||||||
const configs: FontLoadRequestConfig[] = [];
|
let isCatchingUp = $state(false);
|
||||||
|
|
||||||
visibleItems.forEach(item => {
|
const showInitialSkeleton = $derived(!!skeleton && isLoading && fontStore.fonts.length === 0);
|
||||||
const url = getFontUrl(item, weight);
|
const showCatchupSkeleton = $derived(!!skeleton && isCatchingUp);
|
||||||
|
|
||||||
if (url) {
|
|
||||||
configs.push({
|
|
||||||
id: item.id,
|
|
||||||
name: item.name,
|
|
||||||
weight,
|
|
||||||
url,
|
|
||||||
isVariable: item.features?.isVariable,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Auto-register fonts with the manager
|
|
||||||
appliedFontsManager.touch(configs);
|
|
||||||
|
|
||||||
|
function handleInternalVisibleChange(items: UnifiedFont[]) {
|
||||||
|
visibleFonts = items;
|
||||||
// Forward the call to any external listener
|
// Forward the call to any external listener
|
||||||
// onVisibleItemsChange?.(visibleItems);
|
onVisibleItemsChange?.(items);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle jump scroll — batch-load all missing pages then re-enable font loading.
|
||||||
|
* Suppresses appliedFontsManager.touch() during catch-up to avoid loading
|
||||||
|
* font files for thousands of intermediate fonts.
|
||||||
|
*/
|
||||||
|
async function handleJump(targetIndex: number) {
|
||||||
|
if (isCatchingUp || !fontStore.pagination.hasMore) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isCatchingUp = true;
|
||||||
|
try {
|
||||||
|
await fontStore.fetchAllPagesTo(targetIndex);
|
||||||
|
} finally {
|
||||||
|
isCatchingUp = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const debouncedTouch = debounce((configs: FontLoadRequestConfig[]) => {
|
||||||
|
appliedFontsManager.touch(configs);
|
||||||
|
}, 150);
|
||||||
|
|
||||||
|
// Re-touch whenever visible set or weight changes — fixes weight-change gap
|
||||||
|
$effect(() => {
|
||||||
|
if (isCatchingUp) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const configs: FontLoadRequestConfig[] = visibleFonts.flatMap(item => {
|
||||||
|
const url = getFontUrl(item, weight);
|
||||||
|
if (!url) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return [{ id: item.id, name: item.name, weight, url, isVariable: item.features?.isVariable }];
|
||||||
|
});
|
||||||
|
if (configs.length > 0) {
|
||||||
|
debouncedTouch(configs);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pin visible fonts so the eviction policy never removes on-screen entries.
|
||||||
|
// Cleanup captures the snapshot values, so a weight change unpins the old
|
||||||
|
// weight before pinning the new one.
|
||||||
|
$effect(() => {
|
||||||
|
const w = weight;
|
||||||
|
const fonts = visibleFonts;
|
||||||
|
for (const f of fonts) {
|
||||||
|
appliedFontsManager.pin(f.id, w, f.features?.isVariable);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
for (const f of fonts) {
|
||||||
|
appliedFontsManager.unpin(f.id, w, f.features?.isVariable);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load more fonts by moving to the next page
|
* Load more fonts by moving to the next page
|
||||||
*/
|
*/
|
||||||
function loadMore() {
|
function loadMore() {
|
||||||
if (
|
if (
|
||||||
!unifiedFontStore.pagination.hasMore
|
!fontStore.pagination.hasMore
|
||||||
|| unifiedFontStore.isFetching
|
|| fontStore.isFetching
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
unifiedFontStore.nextPage();
|
fontStore.nextPage();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -97,34 +140,42 @@ function loadMore() {
|
|||||||
* of the loaded items. Only fetches if there are more pages available.
|
* of the loaded items. Only fetches if there are more pages available.
|
||||||
*/
|
*/
|
||||||
function handleNearBottom(_lastVisibleIndex: number) {
|
function handleNearBottom(_lastVisibleIndex: number) {
|
||||||
const { hasMore } = unifiedFontStore.pagination;
|
const { hasMore } = fontStore.pagination;
|
||||||
|
|
||||||
// VirtualList already checks if we're near the bottom of loaded items
|
// VirtualList already checks if we're near the bottom of loaded items.
|
||||||
if (hasMore && !unifiedFontStore.isFetching) {
|
// Guard isCatchingUp: fetchAllPagesTo bypasses TQ so isFetching stays false
|
||||||
|
// during batch catch-up, which would otherwise let nextPage() race with it.
|
||||||
|
if (hasMore && !fontStore.isFetching && !isCatchingUp) {
|
||||||
loadMore();
|
loadMore();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="relative w-full h-full">
|
<div class="relative w-full h-full">
|
||||||
{#if skeleton && isLoading && unifiedFontStore.fonts.length === 0}
|
{#if showInitialSkeleton && skeleton}
|
||||||
<!-- Show skeleton only on initial load when no fonts are loaded yet -->
|
<!-- Show skeleton only on initial load when no fonts are loaded yet -->
|
||||||
<div transition:fade={{ duration: 300 }}>
|
<div class="overflow-hidden h-full" transition:fade={{ duration: 300 }}>
|
||||||
{@render skeleton()}
|
{@render skeleton()}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- VirtualList persists during pagination - no destruction/recreation -->
|
<!-- VirtualList persists during pagination - no destruction/recreation -->
|
||||||
<VirtualList
|
<VirtualList
|
||||||
items={unifiedFontStore.fonts}
|
items={fontStore.fonts}
|
||||||
total={unifiedFontStore.pagination.total}
|
total={fontStore.pagination.total}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading || isCatchingUp}
|
||||||
onVisibleItemsChange={handleInternalVisibleChange}
|
onVisibleItemsChange={handleInternalVisibleChange}
|
||||||
onNearBottom={handleNearBottom}
|
onNearBottom={handleNearBottom}
|
||||||
|
onJump={handleJump}
|
||||||
{...rest}
|
{...rest}
|
||||||
>
|
>
|
||||||
{#snippet children(scope)}
|
{#snippet children(scope)}
|
||||||
{@render children(scope)}
|
{@render children(scope)}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</VirtualList>
|
</VirtualList>
|
||||||
|
{#if showCatchupSkeleton && skeleton}
|
||||||
|
<div class="absolute inset-0 overflow-hidden" transition:fade={{ duration: 150 }}>
|
||||||
|
{@render skeleton()}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -41,15 +41,25 @@ type ThemeSource = 'system' | 'user';
|
|||||||
*/
|
*/
|
||||||
class ThemeManager {
|
class ThemeManager {
|
||||||
// Private reactive state
|
// Private reactive state
|
||||||
/** Current theme value ('light' or 'dark') */
|
/**
|
||||||
|
* Current theme value ('light' or 'dark')
|
||||||
|
*/
|
||||||
#theme = $state<Theme>('light');
|
#theme = $state<Theme>('light');
|
||||||
/** Whether theme is controlled by user or follows system */
|
/**
|
||||||
|
* Whether theme is controlled by user or follows system
|
||||||
|
*/
|
||||||
#source = $state<ThemeSource>('system');
|
#source = $state<ThemeSource>('system');
|
||||||
/** MediaQueryList for detecting system theme changes */
|
/**
|
||||||
|
* MediaQueryList for detecting system theme changes
|
||||||
|
*/
|
||||||
#mediaQuery: MediaQueryList | null = null;
|
#mediaQuery: MediaQueryList | null = null;
|
||||||
/** Persistent storage for user's theme preference */
|
/**
|
||||||
|
* Persistent storage for user's theme preference
|
||||||
|
*/
|
||||||
#store = createPersistentStore<Theme | null>('glyphdiff:theme', null);
|
#store = createPersistentStore<Theme | null>('glyphdiff:theme', null);
|
||||||
/** Bound handler for system theme change events */
|
/**
|
||||||
|
* Bound handler for system theme change events
|
||||||
|
*/
|
||||||
#systemChangeHandler = this.#onSystemChange.bind(this);
|
#systemChangeHandler = this.#onSystemChange.bind(this);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -64,22 +74,30 @@ class ThemeManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Current theme value */
|
/**
|
||||||
|
* Current theme value
|
||||||
|
*/
|
||||||
get value(): Theme {
|
get value(): Theme {
|
||||||
return this.#theme;
|
return this.#theme;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Source of current theme ('system' or 'user') */
|
/**
|
||||||
|
* Source of current theme ('system' or 'user')
|
||||||
|
*/
|
||||||
get source(): ThemeSource {
|
get source(): ThemeSource {
|
||||||
return this.#source;
|
return this.#source;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether dark theme is active */
|
/**
|
||||||
|
* Whether dark theme is active
|
||||||
|
*/
|
||||||
get isDark(): boolean {
|
get isDark(): boolean {
|
||||||
return this.#theme === 'dark';
|
return this.#theme === 'dark';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether theme is controlled by user (not following system) */
|
/**
|
||||||
|
* Whether theme is controlled by user (not following system)
|
||||||
|
*/
|
||||||
get isUserControlled(): boolean {
|
get isUserControlled(): boolean {
|
||||||
return this.#source === 'user';
|
return this.#source === 'user';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
/** @vitest-environment jsdom */
|
/**
|
||||||
|
* @vitest-environment jsdom
|
||||||
|
*/
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// Mock MediaQueryListEvent for system theme change simulations
|
// Mock MediaQueryListEvent for system theme change simulations
|
||||||
// Note: Other mocks (ResizeObserver, localStorage, matchMedia) are set up in vitest.setup.unit.ts
|
// Note: Other mocks (ResizeObserver, localStorage, matchMedia) are set up in vitest.setup.unit.ts
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
class MockMediaQueryListEvent extends Event {
|
class MockMediaQueryListEvent extends Event {
|
||||||
matches: boolean;
|
matches: boolean;
|
||||||
@@ -16,9 +16,7 @@ class MockMediaQueryListEvent extends Event {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// NOW IT'S SAFE TO IMPORT
|
// NOW IT'S SAFE TO IMPORT
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
afterEach,
|
afterEach,
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import {
|
||||||
|
fireEvent,
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
} from '@testing-library/svelte';
|
||||||
|
import { themeManager } from '../../model';
|
||||||
|
import ThemeSwitch from './ThemeSwitch.svelte';
|
||||||
|
|
||||||
|
const context = new Map([['responsive', { isMobile: false }]]);
|
||||||
|
|
||||||
|
describe('ThemeSwitch', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
themeManager.setTheme('light');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Rendering', () => {
|
||||||
|
it('renders an icon button', () => {
|
||||||
|
render(ThemeSwitch, { context });
|
||||||
|
expect(screen.getByRole('button')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has "Toggle theme" title', () => {
|
||||||
|
render(ThemeSwitch, { context });
|
||||||
|
expect(screen.getByTitle('Toggle theme')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders an SVG icon', () => {
|
||||||
|
const { container } = render(ThemeSwitch, { context });
|
||||||
|
expect(container.querySelector('svg')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Interaction', () => {
|
||||||
|
it('toggles theme from light to dark on click', async () => {
|
||||||
|
render(ThemeSwitch, { context });
|
||||||
|
expect(themeManager.value).toBe('light');
|
||||||
|
await fireEvent.click(screen.getByRole('button'));
|
||||||
|
expect(themeManager.value).toBe('dark');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggles theme from dark to light on click', async () => {
|
||||||
|
themeManager.setTheme('dark');
|
||||||
|
render(ThemeSwitch, { context });
|
||||||
|
await fireEvent.click(screen.getByRole('button'));
|
||||||
|
expect(themeManager.value).toBe('light');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('double click returns to original theme', async () => {
|
||||||
|
render(ThemeSwitch, { context });
|
||||||
|
const btn = screen.getByRole('button');
|
||||||
|
await fireEvent.click(btn);
|
||||||
|
await fireEvent.click(btn);
|
||||||
|
expect(themeManager.value).toBe('light');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -35,7 +35,7 @@ const { Story } = defineMeta({
|
|||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { UnifiedFont } from '$entities/Font';
|
import type { UnifiedFont } from '$entities/Font';
|
||||||
import { controlManager } from '$features/SetupFont';
|
import type { ComponentProps } from 'svelte';
|
||||||
|
|
||||||
// Mock fonts for testing
|
// Mock fonts for testing
|
||||||
const mockArial: UnifiedFont = {
|
const mockArial: UnifiedFont = {
|
||||||
@@ -89,7 +89,7 @@ const mockGeorgia: UnifiedFont = {
|
|||||||
index: 0,
|
index: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{#snippet template(args)}
|
{#snippet template(args: ComponentProps<typeof FontSampler>)}
|
||||||
<Providers>
|
<Providers>
|
||||||
<div class="max-w-2xl mx-auto">
|
<div class="max-w-2xl mx-auto">
|
||||||
<FontSampler {...args} />
|
<FontSampler {...args} />
|
||||||
@@ -106,7 +106,7 @@ const mockGeorgia: UnifiedFont = {
|
|||||||
index: 1,
|
index: 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{#snippet template(args)}
|
{#snippet template(args: ComponentProps<typeof FontSampler>)}
|
||||||
<Providers>
|
<Providers>
|
||||||
<div class="max-w-2xl mx-auto">
|
<div class="max-w-2xl mx-auto">
|
||||||
<FontSampler {...args} />
|
<FontSampler {...args} />
|
||||||
|
|||||||
@@ -8,14 +8,13 @@ import {
|
|||||||
FontApplicator,
|
FontApplicator,
|
||||||
type UnifiedFont,
|
type UnifiedFont,
|
||||||
} from '$entities/Font';
|
} from '$entities/Font';
|
||||||
import { controlManager } from '$features/SetupFont';
|
import { typographySettingsStore } from '$features/SetupFont/model';
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
ContentEditable,
|
ContentEditable,
|
||||||
Divider,
|
Divider,
|
||||||
Footnote,
|
Footnote,
|
||||||
Stat,
|
Stat,
|
||||||
StatGroup,
|
|
||||||
} from '$shared/ui';
|
} from '$shared/ui';
|
||||||
import { fly } from 'svelte/transition';
|
import { fly } from 'svelte/transition';
|
||||||
|
|
||||||
@@ -37,11 +36,6 @@ interface Props {
|
|||||||
|
|
||||||
let { font, text = $bindable(), index = 0 }: Props = $props();
|
let { font, text = $bindable(), index = 0 }: Props = $props();
|
||||||
|
|
||||||
const fontWeight = $derived(controlManager.weight);
|
|
||||||
const fontSize = $derived(controlManager.renderedSize);
|
|
||||||
const lineHeight = $derived(controlManager.height);
|
|
||||||
const letterSpacing = $derived(controlManager.spacing);
|
|
||||||
|
|
||||||
// Adjust the property name to match your UnifiedFont type
|
// Adjust the property name to match your UnifiedFont type
|
||||||
const fontType = $derived((font as any).type ?? (font as any).category ?? '');
|
const fontType = $derived((font as any).type ?? (font as any).category ?? '');
|
||||||
|
|
||||||
@@ -52,10 +46,10 @@ const providerBadge = $derived(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const stats = $derived([
|
const stats = $derived([
|
||||||
{ label: 'SZ', value: `${fontSize}PX` },
|
{ label: 'SZ', value: `${typographySettingsStore.renderedSize}PX` },
|
||||||
{ label: 'WGT', value: `${fontWeight}` },
|
{ label: 'WGT', value: `${typographySettingsStore.weight}` },
|
||||||
{ label: 'LH', value: lineHeight?.toFixed(2) },
|
{ label: 'LH', value: typographySettingsStore.height?.toFixed(2) },
|
||||||
{ label: 'LTR', value: `${letterSpacing}` },
|
{ label: 'LTR', value: `${typographySettingsStore.spacing}` },
|
||||||
]);
|
]);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -65,7 +59,7 @@ const stats = $derived([
|
|||||||
group relative
|
group relative
|
||||||
w-full h-full
|
w-full h-full
|
||||||
bg-paper dark:bg-dark-card
|
bg-paper dark:bg-dark-card
|
||||||
border border-black/5 dark:border-white/10
|
border border-subtle
|
||||||
hover:border-brand dark:hover:border-brand
|
hover:border-brand dark:hover:border-brand
|
||||||
hover:shadow-brand/10
|
hover:shadow-brand/10
|
||||||
hover:shadow-[5px_5px_0px_0px]
|
hover:shadow-[5px_5px_0px_0px]
|
||||||
@@ -75,20 +69,20 @@ const stats = $derived([
|
|||||||
min-h-60
|
min-h-60
|
||||||
rounded-none
|
rounded-none
|
||||||
"
|
"
|
||||||
style:font-weight={fontWeight}
|
style:font-weight={typographySettingsStore.weight}
|
||||||
>
|
>
|
||||||
<!-- ── Header bar ─────────────────────────────────────────────────── -->
|
<!-- ── Header bar ─────────────────────────────────────────────────── -->
|
||||||
<div
|
<div
|
||||||
class="
|
class="
|
||||||
flex items-center justify-between
|
flex items-center justify-between
|
||||||
px-4 sm:px-5 md:px-6 py-3 sm:py-4
|
px-4 sm:px-5 md:px-6 py-3 sm:py-4
|
||||||
border-b border-black/5 dark:border-white/10
|
border-b border-subtle
|
||||||
bg-paper dark:bg-dark-card
|
bg-paper dark:bg-dark-card
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<!-- Left: index · name · type badge · provider badge -->
|
<!-- Left: index · name · type badge · provider badge -->
|
||||||
<div class="flex items-center gap-2 sm:gap-4 min-w-0 shrink-0">
|
<div class="flex items-center gap-2 sm:gap-4 min-w-0 shrink-0">
|
||||||
<span class="font-mono text-[0.625rem] tracking-widest text-neutral-400 uppercase leading-none shrink-0">
|
<span class="font-mono text-2xs tracking-widest text-neutral-400 uppercase leading-none shrink-0">
|
||||||
{String(index + 1).padStart(2, '0')}
|
{String(index + 1).padStart(2, '0')}
|
||||||
</span>
|
</span>
|
||||||
<Divider orientation="vertical" class="h-3 shrink-0" />
|
<Divider orientation="vertical" class="h-3 shrink-0" />
|
||||||
@@ -100,14 +94,14 @@ const stats = $derived([
|
|||||||
</span>
|
</span>
|
||||||
|
|
||||||
{#if fontType}
|
{#if fontType}
|
||||||
<Badge size="xs" variant="default" class="text-nowrap font-mono">
|
<Badge size="xs" variant="default" nowrap>
|
||||||
{fontType}
|
{fontType}
|
||||||
</Badge>
|
</Badge>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Provider badge -->
|
<!-- Provider badge -->
|
||||||
{#if providerBadge}
|
{#if providerBadge}
|
||||||
<Badge size="xs" variant="default" class="text-nowrap font-mono" data-provider={font.provider}>
|
<Badge size="xs" variant="default" nowrap data-provider={font.provider}>
|
||||||
{providerBadge}
|
{providerBadge}
|
||||||
</Badge>
|
</Badge>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -140,20 +134,20 @@ const stats = $derived([
|
|||||||
|
|
||||||
<!-- ── Main content area ──────────────────────────────────────────── -->
|
<!-- ── Main content area ──────────────────────────────────────────── -->
|
||||||
<div class="flex-1 p-4 sm:p-5 md:p-8 flex items-center overflow-hidden bg-paper dark:bg-dark-card relative z-10">
|
<div class="flex-1 p-4 sm:p-5 md:p-8 flex items-center overflow-hidden bg-paper dark:bg-dark-card relative z-10">
|
||||||
<FontApplicator {font} weight={fontWeight}>
|
<FontApplicator {font} weight={typographySettingsStore.weight}>
|
||||||
<ContentEditable
|
<ContentEditable
|
||||||
bind:text
|
bind:text
|
||||||
{fontSize}
|
fontSize={typographySettingsStore.renderedSize}
|
||||||
{lineHeight}
|
lineHeight={typographySettingsStore.height}
|
||||||
{letterSpacing}
|
letterSpacing={typographySettingsStore.spacing}
|
||||||
/>
|
/>
|
||||||
</FontApplicator>
|
</FontApplicator>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Mobile stats footer (md:hidden — header stats take over above) -->
|
<!-- ── Mobile stats footer (md:hidden — header stats take over above) -->
|
||||||
<div class="md:hidden px-4 sm:px-5 py-1.5 sm:py-2 border-t border-black/5 dark:border-white/10 flex gap-2 sm:gap-4 bg-paper dark:bg-dark-card mt-auto">
|
<div class="md:hidden px-4 sm:px-5 py-1.5 sm:py-2 border-t border-subtle flex gap-2 sm:gap-4 bg-paper dark:bg-dark-card mt-auto">
|
||||||
{#each stats as stat, i}
|
{#each stats as stat, i}
|
||||||
<Footnote class="text-[0.4375rem] sm:text-[0.5rem] tracking-wider {i === 0 ? 'ml-auto' : ''}">
|
<Footnote class="text-5xs sm:text-4xs tracking-wider {i === 0 ? 'ml-auto' : ''}">
|
||||||
{stat.label}:{stat.value}
|
{stat.label}:{stat.value}
|
||||||
</Footnote>
|
</Footnote>
|
||||||
{#if i < stats.length - 1}
|
{#if i < stats.length - 1}
|
||||||
|
|||||||
@@ -15,19 +15,29 @@ const PROXY_API_URL = 'https://api.glyphdiff.com/api/v1/filters' as const;
|
|||||||
* Filter metadata type from backend
|
* Filter metadata type from backend
|
||||||
*/
|
*/
|
||||||
export interface FilterMetadata {
|
export interface FilterMetadata {
|
||||||
/** Filter ID (e.g., "providers", "categories", "subsets") */
|
/**
|
||||||
|
* Filter ID (e.g., "providers", "categories", "subsets")
|
||||||
|
*/
|
||||||
id: string;
|
id: string;
|
||||||
|
|
||||||
/** Display name (e.g., "Font Providers", "Categories", "Character Subsets") */
|
/**
|
||||||
|
* Display name (e.g., "Font Providers", "Categories", "Character Subsets")
|
||||||
|
*/
|
||||||
name: string;
|
name: string;
|
||||||
|
|
||||||
/** Filter description */
|
/**
|
||||||
|
* Filter description
|
||||||
|
*/
|
||||||
description: string;
|
description: string;
|
||||||
|
|
||||||
/** Filter type */
|
/**
|
||||||
|
* Filter type
|
||||||
|
*/
|
||||||
type: 'enum' | 'string' | 'array';
|
type: 'enum' | 'string' | 'array';
|
||||||
|
|
||||||
/** Available filter options */
|
/**
|
||||||
|
* Available filter options
|
||||||
|
*/
|
||||||
options: FilterOption[];
|
options: FilterOption[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,16 +45,24 @@ export interface FilterMetadata {
|
|||||||
* Filter option type
|
* Filter option type
|
||||||
*/
|
*/
|
||||||
export interface FilterOption {
|
export interface FilterOption {
|
||||||
/** Option ID (e.g., "google", "serif", "latin") */
|
/**
|
||||||
|
* Option ID (e.g., "google", "serif", "latin")
|
||||||
|
*/
|
||||||
id: string;
|
id: string;
|
||||||
|
|
||||||
/** Display name (e.g., "Google Fonts", "Serif", "Latin") */
|
/**
|
||||||
|
* Display name (e.g., "Google Fonts", "Serif", "Latin")
|
||||||
|
*/
|
||||||
name: string;
|
name: string;
|
||||||
|
|
||||||
/** Option value (e.g., "google", "serif", "latin") */
|
/**
|
||||||
|
* Option value (e.g., "google", "serif", "latin")
|
||||||
|
*/
|
||||||
value: string;
|
value: string;
|
||||||
|
|
||||||
/** Number of fonts with this value */
|
/**
|
||||||
|
* Number of fonts with this value
|
||||||
|
*/
|
||||||
count: number;
|
count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,7 +70,9 @@ export interface FilterOption {
|
|||||||
* Proxy filters API response
|
* Proxy filters API response
|
||||||
*/
|
*/
|
||||||
export interface ProxyFiltersResponse {
|
export interface ProxyFiltersResponse {
|
||||||
/** Array of filter metadata */
|
/**
|
||||||
|
* Array of filter metadata
|
||||||
|
*/
|
||||||
filters: FilterMetadata[];
|
filters: FilterMetadata[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export {
|
|||||||
mapManagerToParams,
|
mapManagerToParams,
|
||||||
} from './lib';
|
} from './lib';
|
||||||
|
|
||||||
|
export { filtersStore } from './model/state/filters.svelte';
|
||||||
export { filterManager } from './model/state/manager.svelte';
|
export { filterManager } from './model/state/manager.svelte';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -1,15 +1,56 @@
|
|||||||
export type {
|
export type {
|
||||||
|
/**
|
||||||
|
* Top-level configuration for all filters
|
||||||
|
*/
|
||||||
FilterConfig,
|
FilterConfig,
|
||||||
|
/**
|
||||||
|
* Configuration for a single grouping of filter properties
|
||||||
|
*/
|
||||||
FilterGroupConfig,
|
FilterGroupConfig,
|
||||||
} from './types/filter';
|
} from './types/filter';
|
||||||
|
|
||||||
export { filtersStore } from './state/filters.svelte';
|
/**
|
||||||
export { filterManager } from './state/manager.svelte';
|
* Global reactive filter state
|
||||||
|
*/
|
||||||
export {
|
export {
|
||||||
|
/**
|
||||||
|
* Low-level property selection store
|
||||||
|
*/
|
||||||
|
filtersStore,
|
||||||
|
} from './state/filters.svelte';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main filter controller
|
||||||
|
*/
|
||||||
|
export {
|
||||||
|
/**
|
||||||
|
* High-level manager for syncing search and filters
|
||||||
|
*/
|
||||||
|
filterManager,
|
||||||
|
} from './state/manager.svelte';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sorting logic
|
||||||
|
*/
|
||||||
|
export {
|
||||||
|
/**
|
||||||
|
* Map of human-readable labels to API sort keys
|
||||||
|
*/
|
||||||
SORT_MAP,
|
SORT_MAP,
|
||||||
|
/**
|
||||||
|
* List of all available sort options for the UI
|
||||||
|
*/
|
||||||
SORT_OPTIONS,
|
SORT_OPTIONS,
|
||||||
|
/**
|
||||||
|
* Valid sort key values
|
||||||
|
*/
|
||||||
type SortApiValue,
|
type SortApiValue,
|
||||||
|
/**
|
||||||
|
* UI model for a single sort option
|
||||||
|
*/
|
||||||
type SortOption,
|
type SortOption,
|
||||||
|
/**
|
||||||
|
* Reactive store for the current sort selection
|
||||||
|
*/
|
||||||
sortStore,
|
sortStore,
|
||||||
} from './store/sortStore.svelte';
|
} from './store/sortStore.svelte';
|
||||||
|
|||||||
@@ -32,13 +32,19 @@ import {
|
|||||||
* Provides reactive access to filter data
|
* Provides reactive access to filter data
|
||||||
*/
|
*/
|
||||||
class FiltersStore {
|
class FiltersStore {
|
||||||
/** TanStack Query result state */
|
/**
|
||||||
|
* TanStack Query result state
|
||||||
|
*/
|
||||||
protected result = $state<QueryObserverResult<FilterMetadata[], Error>>({} as any);
|
protected result = $state<QueryObserverResult<FilterMetadata[], Error>>({} as any);
|
||||||
|
|
||||||
/** TanStack Query observer instance */
|
/**
|
||||||
|
* TanStack Query observer instance
|
||||||
|
*/
|
||||||
protected observer: QueryObserver<FilterMetadata[], Error>;
|
protected observer: QueryObserver<FilterMetadata[], Error>;
|
||||||
|
|
||||||
/** Shared query client */
|
/**
|
||||||
|
* Shared query client
|
||||||
|
*/
|
||||||
protected qc = queryClient;
|
protected qc = queryClient;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -21,17 +21,23 @@ function createSortStore(initial: SortOption = 'Popularity') {
|
|||||||
let current = $state<SortOption>(initial);
|
let current = $state<SortOption>(initial);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
/** Current display label (e.g. 'Popularity') */
|
/**
|
||||||
|
* Current display label (e.g. 'Popularity')
|
||||||
|
*/
|
||||||
get value() {
|
get value() {
|
||||||
return current;
|
return current;
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Mapped API value (e.g. 'popularity') */
|
/**
|
||||||
|
* Mapped API value (e.g. 'popularity')
|
||||||
|
*/
|
||||||
get apiValue(): SortApiValue {
|
get apiValue(): SortApiValue {
|
||||||
return SORT_MAP[current];
|
return SORT_MAP[current];
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Set the active sort option by its display label */
|
/**
|
||||||
|
* Set the active sort option by its display label
|
||||||
|
*/
|
||||||
set(option: SortOption) {
|
set(option: SortOption) {
|
||||||
current = option;
|
current = option;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,12 +1,27 @@
|
|||||||
import type { Property } from '$shared/lib';
|
import type { Property } from '$shared/lib';
|
||||||
|
|
||||||
export interface FilterGroupConfig<TValue extends string> {
|
export interface FilterGroupConfig<TValue extends string> {
|
||||||
|
/**
|
||||||
|
* Unique identifier for the filter group (e.g. 'categories')
|
||||||
|
*/
|
||||||
id: string;
|
id: string;
|
||||||
|
/**
|
||||||
|
* Human-readable label displayed in the UI header
|
||||||
|
*/
|
||||||
label: string;
|
label: string;
|
||||||
|
/**
|
||||||
|
* List of toggleable properties within this group
|
||||||
|
*/
|
||||||
properties: Property<TValue>[];
|
properties: Property<TValue>[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FilterConfig<TValue extends string> {
|
export interface FilterConfig<TValue extends string> {
|
||||||
|
/**
|
||||||
|
* Optional string to filter results by name
|
||||||
|
*/
|
||||||
queryValue?: string;
|
queryValue?: string;
|
||||||
|
/**
|
||||||
|
* Collection of filter groups to display
|
||||||
|
*/
|
||||||
groups: FilterGroupConfig<TValue>[];
|
groups: FilterGroupConfig<TValue>[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<script module>
|
||||||
|
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||||
|
import Filters from './Filters.svelte';
|
||||||
|
|
||||||
|
const { Story } = defineMeta({
|
||||||
|
title: 'Features/Filters',
|
||||||
|
tags: ['autodocs'],
|
||||||
|
parameters: {
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
component:
|
||||||
|
'Renders the full list of filter groups managed by filterManager. Each group maps to a collapsible FilterGroup with checkboxes. No props — reads directly from the filterManager singleton.',
|
||||||
|
},
|
||||||
|
story: { inline: false },
|
||||||
|
},
|
||||||
|
layout: 'padded',
|
||||||
|
},
|
||||||
|
argTypes: {},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Story name="Default">
|
||||||
|
{#snippet template()}
|
||||||
|
<Filters />
|
||||||
|
{/snippet}
|
||||||
|
</Story>
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import {
|
||||||
|
filterManager,
|
||||||
|
filtersStore,
|
||||||
|
} from '$features/GetFonts';
|
||||||
|
import {
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
} from '@testing-library/svelte';
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
import Filters from './Filters.svelte';
|
||||||
|
|
||||||
|
describe('Filters', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Clear groups and mock filtersStore to be empty so the auto-sync effect doesn't overwrite us
|
||||||
|
filterManager.setGroups([]);
|
||||||
|
vi.spyOn(filtersStore, 'filters', 'get').mockReturnValue([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Rendering', () => {
|
||||||
|
it('renders nothing when filter groups are empty', () => {
|
||||||
|
const { container } = render(Filters);
|
||||||
|
// It might render an empty container if the component has one, but we expect no children
|
||||||
|
expect(container.firstChild?.childNodes.length ?? 0).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a label for each filter group', () => {
|
||||||
|
filterManager.setGroups([
|
||||||
|
{ id: 'cat', label: 'Categories', properties: [] },
|
||||||
|
{ id: 'prov', label: 'Font Providers', properties: [] },
|
||||||
|
]);
|
||||||
|
render(Filters);
|
||||||
|
expect(screen.getByText('Categories')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Font Providers')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders filter properties within groups', () => {
|
||||||
|
filterManager.setGroups([
|
||||||
|
{
|
||||||
|
id: 'cat',
|
||||||
|
label: 'Category',
|
||||||
|
properties: [
|
||||||
|
{ id: 'serif', name: 'Serif', value: 'serif', selected: false },
|
||||||
|
{ id: 'sans', name: 'Sans-Serif', value: 'sans-serif', selected: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
render(Filters);
|
||||||
|
expect(screen.getByText('Serif')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Sans-Serif')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders multiple groups with their properties', () => {
|
||||||
|
filterManager.setGroups([
|
||||||
|
{
|
||||||
|
id: 'cat',
|
||||||
|
label: 'Category',
|
||||||
|
properties: [{ id: 'mono', name: 'Monospace', value: 'monospace', selected: false }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'prov',
|
||||||
|
label: 'Provider',
|
||||||
|
properties: [{ id: 'google', name: 'Google', value: 'google', selected: false }],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
render(Filters);
|
||||||
|
expect(screen.getByText('Monospace')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Google')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<script module>
|
||||||
|
import Providers from '$shared/lib/storybook/Providers.svelte';
|
||||||
|
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||||
|
import FilterControls from './FilterControls.svelte';
|
||||||
|
|
||||||
|
const { Story } = defineMeta({
|
||||||
|
title: 'Features/FilterControls',
|
||||||
|
tags: ['autodocs'],
|
||||||
|
parameters: {
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
component:
|
||||||
|
'Sort options and Reset_Filters button rendered below the filter list. Reads sort state from sortStore and dispatches resets via filterManager. Requires responsive context — wrap with Providers.',
|
||||||
|
},
|
||||||
|
story: { inline: false },
|
||||||
|
},
|
||||||
|
layout: 'padded',
|
||||||
|
},
|
||||||
|
argTypes: {},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Story name="Default">
|
||||||
|
{#snippet template()}
|
||||||
|
<Providers>
|
||||||
|
<FilterControls />
|
||||||
|
</Providers>
|
||||||
|
{/snippet}
|
||||||
|
</Story>
|
||||||
|
|
||||||
|
<Story name="Mobile layout">
|
||||||
|
{#snippet template()}
|
||||||
|
<Providers>
|
||||||
|
<div style="width: 375px;">
|
||||||
|
<FilterControls />
|
||||||
|
</div>
|
||||||
|
</Providers>
|
||||||
|
{/snippet}
|
||||||
|
</Story>
|
||||||
@@ -4,12 +4,12 @@
|
|||||||
Sits below the filter list, separated by a top border.
|
Sits below the filter list, separated by a top border.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { unifiedFontStore } from '$entities/Font';
|
import { fontStore } from '$entities/Font';
|
||||||
import type { ResponsiveManager } from '$shared/lib';
|
import type { ResponsiveManager } from '$shared/lib';
|
||||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
|
||||||
import { Button } from '$shared/ui';
|
import { Button } from '$shared/ui';
|
||||||
import { Label } from '$shared/ui';
|
import { Label } from '$shared/ui';
|
||||||
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
||||||
|
import clsx from 'clsx';
|
||||||
import {
|
import {
|
||||||
getContext,
|
getContext,
|
||||||
untrack,
|
untrack,
|
||||||
@@ -33,7 +33,7 @@ const {
|
|||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const apiSort = sortStore.apiValue;
|
const apiSort = sortStore.apiValue;
|
||||||
untrack(() => unifiedFontStore.setSort(apiSort));
|
untrack(() => fontStore.setSort(apiSort));
|
||||||
});
|
});
|
||||||
|
|
||||||
const responsive = getContext<ResponsiveManager>('responsive');
|
const responsive = getContext<ResponsiveManager>('responsive');
|
||||||
@@ -45,7 +45,7 @@ function handleReset() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class={cn(
|
class={clsx(
|
||||||
'flex flex-col md:flex-row justify-between items-start md:items-center',
|
'flex flex-col md:flex-row justify-between items-start md:items-center',
|
||||||
'gap-1 md:gap-6',
|
'gap-1 md:gap-6',
|
||||||
'pt-6 mt-6 md:pt-8 md:mt-8',
|
'pt-6 mt-6 md:pt-8 md:mt-8',
|
||||||
@@ -61,13 +61,10 @@ function handleReset() {
|
|||||||
{#each SORT_OPTIONS as option}
|
{#each SORT_OPTIONS as option}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size={isMobileOrTabletPortrait ? 'sm' : 'md'}
|
size={isMobileOrTabletPortrait ? 'xs' : 'sm'}
|
||||||
active={sortStore.value === option}
|
active={sortStore.value === option}
|
||||||
onclick={() => sortStore.set(option)}
|
onclick={() => sortStore.set(option)}
|
||||||
class={cn(
|
class="tracking-wide px-0"
|
||||||
'font-bold uppercase tracking-wide font-primary, px-0',
|
|
||||||
isMobileOrTabletPortrait ? 'text-[0.5625rem]' : 'text-[0.625rem]',
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{option}
|
{option}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -78,12 +75,9 @@ function handleReset() {
|
|||||||
<!-- Reset_Filters -->
|
<!-- Reset_Filters -->
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size={isMobileOrTabletPortrait ? 'sm' : 'md'}
|
size={isMobileOrTabletPortrait ? 'xs' : 'sm'}
|
||||||
onclick={handleReset}
|
onclick={handleReset}
|
||||||
class={cn(
|
class={clsx('group font-mono tracking-widest text-neutral-400', isMobileOrTabletPortrait && 'px-0')}
|
||||||
'group text-[0.5625rem] md:text-[0.625rem] font-mono font-bold uppercase tracking-widest text-neutral-400',
|
|
||||||
isMobileOrTabletPortrait && 'px-0',
|
|
||||||
)}
|
|
||||||
iconPosition="left"
|
iconPosition="left"
|
||||||
>
|
>
|
||||||
{#snippet icon()}
|
{#snippet icon()}
|
||||||
|
|||||||
@@ -1,28 +1,6 @@
|
|||||||
export { TypographyMenu } from './ui';
|
|
||||||
|
|
||||||
export {
|
export {
|
||||||
type ControlId,
|
createTypographySettingsManager,
|
||||||
controlManager,
|
type TypographySettingsManager,
|
||||||
DEFAULT_FONT_SIZE,
|
|
||||||
DEFAULT_FONT_WEIGHT,
|
|
||||||
DEFAULT_LETTER_SPACING,
|
|
||||||
DEFAULT_LINE_HEIGHT,
|
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
|
||||||
FONT_SIZE_STEP,
|
|
||||||
FONT_WEIGHT_STEP,
|
|
||||||
LINE_HEIGHT_STEP,
|
|
||||||
MAX_FONT_SIZE,
|
|
||||||
MAX_FONT_WEIGHT,
|
|
||||||
MAX_LINE_HEIGHT,
|
|
||||||
MIN_FONT_SIZE,
|
|
||||||
MIN_FONT_WEIGHT,
|
|
||||||
MIN_LINE_HEIGHT,
|
|
||||||
MULTIPLIER_L,
|
|
||||||
MULTIPLIER_M,
|
|
||||||
MULTIPLIER_S,
|
|
||||||
} from './model';
|
|
||||||
|
|
||||||
export {
|
|
||||||
createTypographyControlManager,
|
|
||||||
type TypographyControlManager,
|
|
||||||
} from './lib';
|
} from './lib';
|
||||||
|
export { typographySettingsStore } from './model';
|
||||||
|
export { TypographyMenu } from './ui';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export {
|
export {
|
||||||
createTypographyControlManager,
|
createTypographySettingsManager,
|
||||||
type TypographyControlManager,
|
type TypographySettingsManager,
|
||||||
} from './controlManager/controlManager.svelte';
|
} from './settingsManager/settingsManager.svelte';
|
||||||
|
|||||||
+97
-36
@@ -10,6 +10,13 @@
|
|||||||
* when displaying/editing, but the base size is what's stored.
|
* when displaying/editing, but the base size is what's stored.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
type ControlId,
|
||||||
|
DEFAULT_FONT_SIZE,
|
||||||
|
DEFAULT_FONT_WEIGHT,
|
||||||
|
DEFAULT_LETTER_SPACING,
|
||||||
|
DEFAULT_LINE_HEIGHT,
|
||||||
|
} from '$entities/Font';
|
||||||
import {
|
import {
|
||||||
type ControlDataModel,
|
type ControlDataModel,
|
||||||
type ControlModel,
|
type ControlModel,
|
||||||
@@ -19,20 +26,16 @@ import {
|
|||||||
createTypographyControl,
|
createTypographyControl,
|
||||||
} from '$shared/lib';
|
} from '$shared/lib';
|
||||||
import { SvelteMap } from 'svelte/reactivity';
|
import { SvelteMap } from 'svelte/reactivity';
|
||||||
import {
|
|
||||||
type ControlId,
|
|
||||||
DEFAULT_FONT_SIZE,
|
|
||||||
DEFAULT_FONT_WEIGHT,
|
|
||||||
DEFAULT_LETTER_SPACING,
|
|
||||||
DEFAULT_LINE_HEIGHT,
|
|
||||||
} from '../../model';
|
|
||||||
|
|
||||||
type ControlOnlyFields<T extends string = string> = Omit<ControlModel<T>, keyof ControlDataModel>;
|
type ControlOnlyFields<T extends string = string> = Omit<ControlModel<T>, keyof ControlDataModel>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A control with its instance
|
* A control with its associated instance
|
||||||
*/
|
*/
|
||||||
export interface Control extends ControlOnlyFields<ControlId> {
|
export interface Control extends ControlOnlyFields<ControlId> {
|
||||||
|
/**
|
||||||
|
* The reactive typography control instance
|
||||||
|
*/
|
||||||
instance: TypographyControl;
|
instance: TypographyControl;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,9 +43,21 @@ export interface Control extends ControlOnlyFields<ControlId> {
|
|||||||
* Storage schema for typography settings
|
* Storage schema for typography settings
|
||||||
*/
|
*/
|
||||||
export interface TypographySettings {
|
export interface TypographySettings {
|
||||||
|
/**
|
||||||
|
* Base font size (User preference, unscaled)
|
||||||
|
*/
|
||||||
fontSize: number;
|
fontSize: number;
|
||||||
|
/**
|
||||||
|
* Numeric font weight (100-900)
|
||||||
|
*/
|
||||||
fontWeight: number;
|
fontWeight: number;
|
||||||
|
/**
|
||||||
|
* Line height multiplier (e.g. 1.5)
|
||||||
|
*/
|
||||||
lineHeight: number;
|
lineHeight: number;
|
||||||
|
/**
|
||||||
|
* Letter spacing in em/px
|
||||||
|
*/
|
||||||
letterSpacing: number;
|
letterSpacing: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,14 +67,22 @@ export interface TypographySettings {
|
|||||||
* Manages multiple typography controls with persistent storage and
|
* Manages multiple typography controls with persistent storage and
|
||||||
* responsive scaling support for font size.
|
* responsive scaling support for font size.
|
||||||
*/
|
*/
|
||||||
export class TypographyControlManager {
|
export class TypographySettingsManager {
|
||||||
/** Map of controls keyed by ID */
|
/**
|
||||||
|
* Internal map of reactive controls keyed by their identifier
|
||||||
|
*/
|
||||||
#controls = new SvelteMap<string, Control>();
|
#controls = new SvelteMap<string, Control>();
|
||||||
/** Responsive multiplier for font size display */
|
/**
|
||||||
|
* Global multiplier for responsive font size scaling
|
||||||
|
*/
|
||||||
#multiplier = $state(1);
|
#multiplier = $state(1);
|
||||||
/** Persistent storage for settings */
|
/**
|
||||||
|
* LocalStorage-backed storage for persistence
|
||||||
|
*/
|
||||||
#storage: PersistentStore<TypographySettings>;
|
#storage: PersistentStore<TypographySettings>;
|
||||||
/** Base font size (user preference, unscaled) */
|
/**
|
||||||
|
* The underlying font size before responsive scaling is applied
|
||||||
|
*/
|
||||||
#baseSize = $state(DEFAULT_FONT_SIZE);
|
#baseSize = $state(DEFAULT_FONT_SIZE);
|
||||||
|
|
||||||
constructor(configs: ControlModel<ControlId>[], storage: PersistentStore<TypographySettings>) {
|
constructor(configs: ControlModel<ControlId>[], storage: PersistentStore<TypographySettings>) {
|
||||||
@@ -105,7 +128,9 @@ export class TypographyControlManager {
|
|||||||
// This handles the "Multiplier" logic specifically for the Font Size Control
|
// This handles the "Multiplier" logic specifically for the Font Size Control
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const ctrl = this.#controls.get('font_size')?.instance;
|
const ctrl = this.#controls.get('font_size')?.instance;
|
||||||
if (!ctrl) return;
|
if (!ctrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// If the user moves the slider/clicks buttons in the UI:
|
// If the user moves the slider/clicks buttons in the UI:
|
||||||
// We update the baseSize (User Intent)
|
// We update the baseSize (User Intent)
|
||||||
@@ -124,26 +149,35 @@ export class TypographyControlManager {
|
|||||||
* Gets initial value for a control from storage or defaults
|
* Gets initial value for a control from storage or defaults
|
||||||
*/
|
*/
|
||||||
#getInitialValue(id: string, saved: TypographySettings): number {
|
#getInitialValue(id: string, saved: TypographySettings): number {
|
||||||
if (id === 'font_size') return saved.fontSize * this.#multiplier;
|
if (id === 'font_size') {
|
||||||
if (id === 'font_weight') return saved.fontWeight;
|
return saved.fontSize * this.#multiplier;
|
||||||
if (id === 'line_height') return saved.lineHeight;
|
}
|
||||||
if (id === 'letter_spacing') return saved.letterSpacing;
|
if (id === 'font_weight') {
|
||||||
|
return saved.fontWeight;
|
||||||
|
}
|
||||||
|
if (id === 'line_height') {
|
||||||
|
return saved.lineHeight;
|
||||||
|
}
|
||||||
|
if (id === 'letter_spacing') {
|
||||||
|
return saved.letterSpacing;
|
||||||
|
}
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Current multiplier for responsive scaling */
|
/**
|
||||||
|
* Active scaling factor for the rendered font size
|
||||||
|
*/
|
||||||
get multiplier() {
|
get multiplier() {
|
||||||
return this.#multiplier;
|
return this.#multiplier;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set the multiplier and update font size display
|
* Updates the multiplier and recalculates dependent control values
|
||||||
*
|
|
||||||
* When multiplier changes, the font size control's display value
|
|
||||||
* is updated to reflect the new scale while preserving base size.
|
|
||||||
*/
|
*/
|
||||||
set multiplier(value: number) {
|
set multiplier(value: number) {
|
||||||
if (this.#multiplier === value) return;
|
if (this.#multiplier === value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.#multiplier = value;
|
this.#multiplier = value;
|
||||||
|
|
||||||
// When multiplier changes, we must update the Font Size Control's display value
|
// When multiplier changes, we must update the Font Size Control's display value
|
||||||
@@ -154,14 +188,15 @@ export class TypographyControlManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The scaled size for CSS usage
|
* The actual pixel value for CSS font-size (baseSize * multiplier)
|
||||||
* Returns baseSize * multiplier for actual rendering
|
|
||||||
*/
|
*/
|
||||||
get renderedSize() {
|
get renderedSize() {
|
||||||
return this.#baseSize * this.#multiplier;
|
return this.#baseSize * this.#multiplier;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The base size (User Preference) */
|
/**
|
||||||
|
* The raw font size preference before scaling
|
||||||
|
*/
|
||||||
get baseSize() {
|
get baseSize() {
|
||||||
return this.#baseSize;
|
return this.#baseSize;
|
||||||
}
|
}
|
||||||
@@ -169,49 +204,69 @@ export class TypographyControlManager {
|
|||||||
set baseSize(val: number) {
|
set baseSize(val: number) {
|
||||||
this.#baseSize = val;
|
this.#baseSize = val;
|
||||||
const ctrl = this.#controls.get('font_size')?.instance;
|
const ctrl = this.#controls.get('font_size')?.instance;
|
||||||
if (ctrl) ctrl.value = val * this.#multiplier;
|
if (ctrl) {
|
||||||
|
ctrl.value = val * this.#multiplier;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Getters for controls
|
* List of all managed typography controls
|
||||||
*/
|
*/
|
||||||
get controls() {
|
get controls() {
|
||||||
return Array.from(this.#controls.values());
|
return Array.from(this.#controls.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reactive instance for weight manipulation
|
||||||
|
*/
|
||||||
get weightControl() {
|
get weightControl() {
|
||||||
return this.#controls.get('font_weight')?.instance;
|
return this.#controls.get('font_weight')?.instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reactive instance for size manipulation
|
||||||
|
*/
|
||||||
get sizeControl() {
|
get sizeControl() {
|
||||||
return this.#controls.get('font_size')?.instance;
|
return this.#controls.get('font_size')?.instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reactive instance for line-height manipulation
|
||||||
|
*/
|
||||||
get heightControl() {
|
get heightControl() {
|
||||||
return this.#controls.get('line_height')?.instance;
|
return this.#controls.get('line_height')?.instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reactive instance for letter-spacing manipulation
|
||||||
|
*/
|
||||||
get spacingControl() {
|
get spacingControl() {
|
||||||
return this.#controls.get('letter_spacing')?.instance;
|
return this.#controls.get('letter_spacing')?.instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Getters for values (besides font-size)
|
* Current numeric font weight (reactive)
|
||||||
*/
|
*/
|
||||||
get weight() {
|
get weight() {
|
||||||
return this.#controls.get('font_weight')?.instance.value ?? DEFAULT_FONT_WEIGHT;
|
return this.#controls.get('font_weight')?.instance.value ?? DEFAULT_FONT_WEIGHT;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current numeric line height (reactive)
|
||||||
|
*/
|
||||||
get height() {
|
get height() {
|
||||||
return this.#controls.get('line_height')?.instance.value ?? DEFAULT_LINE_HEIGHT;
|
return this.#controls.get('line_height')?.instance.value ?? DEFAULT_LINE_HEIGHT;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current numeric letter spacing (reactive)
|
||||||
|
*/
|
||||||
get spacing() {
|
get spacing() {
|
||||||
return this.#controls.get('letter_spacing')?.instance.value ?? DEFAULT_LETTER_SPACING;
|
return this.#controls.get('letter_spacing')?.instance.value ?? DEFAULT_LETTER_SPACING;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reset all controls to default values
|
* Reset all controls to project-defined defaults
|
||||||
*/
|
*/
|
||||||
reset() {
|
reset() {
|
||||||
this.#storage.clear();
|
this.#storage.clear();
|
||||||
@@ -227,9 +282,15 @@ export class TypographyControlManager {
|
|||||||
// Map storage key to control id
|
// Map storage key to control id
|
||||||
const key = c.id.replace('_', '') as keyof TypographySettings;
|
const key = c.id.replace('_', '') as keyof TypographySettings;
|
||||||
// Simplified for brevity, you'd map these properly:
|
// Simplified for brevity, you'd map these properly:
|
||||||
if (c.id === 'font_weight') c.instance.value = defaults.fontWeight;
|
if (c.id === 'font_weight') {
|
||||||
if (c.id === 'line_height') c.instance.value = defaults.lineHeight;
|
c.instance.value = defaults.fontWeight;
|
||||||
if (c.id === 'letter_spacing') c.instance.value = defaults.letterSpacing;
|
}
|
||||||
|
if (c.id === 'line_height') {
|
||||||
|
c.instance.value = defaults.lineHeight;
|
||||||
|
}
|
||||||
|
if (c.id === 'letter_spacing') {
|
||||||
|
c.instance.value = defaults.letterSpacing;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -242,7 +303,7 @@ export class TypographyControlManager {
|
|||||||
* @param storageId - Persistent storage identifier
|
* @param storageId - Persistent storage identifier
|
||||||
* @returns Typography control manager instance
|
* @returns Typography control manager instance
|
||||||
*/
|
*/
|
||||||
export function createTypographyControlManager(
|
export function createTypographySettingsManager(
|
||||||
configs: ControlModel<ControlId>[],
|
configs: ControlModel<ControlId>[],
|
||||||
storageId: string = 'glyphdiff:typography',
|
storageId: string = 'glyphdiff:typography',
|
||||||
) {
|
) {
|
||||||
@@ -252,5 +313,5 @@ export function createTypographyControlManager(
|
|||||||
lineHeight: DEFAULT_LINE_HEIGHT,
|
lineHeight: DEFAULT_LINE_HEIGHT,
|
||||||
letterSpacing: DEFAULT_LETTER_SPACING,
|
letterSpacing: DEFAULT_LETTER_SPACING,
|
||||||
});
|
});
|
||||||
return new TypographyControlManager(configs, storage);
|
return new TypographySettingsManager(configs, storage);
|
||||||
}
|
}
|
||||||
+55
-54
@@ -1,6 +1,14 @@
|
|||||||
/** @vitest-environment jsdom */
|
/**
|
||||||
|
* @vitest-environment jsdom
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
DEFAULT_FONT_SIZE,
|
||||||
|
DEFAULT_FONT_WEIGHT,
|
||||||
|
DEFAULT_LETTER_SPACING,
|
||||||
|
DEFAULT_LINE_HEIGHT,
|
||||||
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
|
} from '$entities/Font';
|
||||||
import {
|
import {
|
||||||
afterEach,
|
|
||||||
beforeEach,
|
beforeEach,
|
||||||
describe,
|
describe,
|
||||||
expect,
|
expect,
|
||||||
@@ -8,21 +16,14 @@ import {
|
|||||||
vi,
|
vi,
|
||||||
} from 'vitest';
|
} from 'vitest';
|
||||||
import {
|
import {
|
||||||
DEFAULT_FONT_SIZE,
|
|
||||||
DEFAULT_FONT_WEIGHT,
|
|
||||||
DEFAULT_LETTER_SPACING,
|
|
||||||
DEFAULT_LINE_HEIGHT,
|
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
|
||||||
} from '../../model';
|
|
||||||
import {
|
|
||||||
TypographyControlManager,
|
|
||||||
type TypographySettings,
|
type TypographySettings,
|
||||||
} from './controlManager.svelte';
|
TypographySettingsManager,
|
||||||
|
} from './settingsManager.svelte';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Test Strategy for TypographyControlManager
|
* Test Strategy for TypographySettingsManager
|
||||||
*
|
*
|
||||||
* This test suite validates the TypographyControlManager state management logic.
|
* This test suite validates the TypographySettingsManager state management logic.
|
||||||
* These are unit tests for the manager logic, separate from component rendering.
|
* These are unit tests for the manager logic, separate from component rendering.
|
||||||
*
|
*
|
||||||
* NOTE: Svelte 5's $effect runs in microtasks, so we need to flush effects
|
* NOTE: Svelte 5's $effect runs in microtasks, so we need to flush effects
|
||||||
@@ -45,7 +46,7 @@ async function flushEffects() {
|
|||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('TypographyControlManager - Unit Tests', () => {
|
describe('TypographySettingsManager - Unit Tests', () => {
|
||||||
let mockStorage: TypographySettings;
|
let mockStorage: TypographySettings;
|
||||||
let mockPersistentStore: {
|
let mockPersistentStore: {
|
||||||
value: TypographySettings;
|
value: TypographySettings;
|
||||||
@@ -85,7 +86,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
|||||||
|
|
||||||
describe('Initialization', () => {
|
describe('Initialization', () => {
|
||||||
it('creates manager with default values from storage', () => {
|
it('creates manager with default values from storage', () => {
|
||||||
const manager = new TypographyControlManager(
|
const manager = new TypographySettingsManager(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -105,7 +106,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
|||||||
};
|
};
|
||||||
mockPersistentStore = createMockPersistentStore(mockStorage);
|
mockPersistentStore = createMockPersistentStore(mockStorage);
|
||||||
|
|
||||||
const manager = new TypographyControlManager(
|
const manager = new TypographySettingsManager(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -117,7 +118,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('initializes font size control with base size multiplied by current multiplier (1)', () => {
|
it('initializes font size control with base size multiplied by current multiplier (1)', () => {
|
||||||
const manager = new TypographyControlManager(
|
const manager = new TypographySettingsManager(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -126,7 +127,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns all controls via controls getter', () => {
|
it('returns all controls via controls getter', () => {
|
||||||
const manager = new TypographyControlManager(
|
const manager = new TypographySettingsManager(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -142,7 +143,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns individual controls via specific getters', () => {
|
it('returns individual controls via specific getters', () => {
|
||||||
const manager = new TypographyControlManager(
|
const manager = new TypographySettingsManager(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -160,7 +161,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('control instances have expected interface', () => {
|
it('control instances have expected interface', () => {
|
||||||
const manager = new TypographyControlManager(
|
const manager = new TypographySettingsManager(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -179,7 +180,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
|||||||
|
|
||||||
describe('Multiplier System', () => {
|
describe('Multiplier System', () => {
|
||||||
it('has default multiplier of 1', () => {
|
it('has default multiplier of 1', () => {
|
||||||
const manager = new TypographyControlManager(
|
const manager = new TypographySettingsManager(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -188,7 +189,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('updates multiplier when set', () => {
|
it('updates multiplier when set', () => {
|
||||||
const manager = new TypographyControlManager(
|
const manager = new TypographySettingsManager(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -201,7 +202,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('does not update multiplier if set to same value', () => {
|
it('does not update multiplier if set to same value', () => {
|
||||||
const manager = new TypographyControlManager(
|
const manager = new TypographySettingsManager(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -217,7 +218,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
|||||||
mockStorage = { fontSize: 48, fontWeight: 400, lineHeight: 1.5, letterSpacing: 0 };
|
mockStorage = { fontSize: 48, fontWeight: 400, lineHeight: 1.5, letterSpacing: 0 };
|
||||||
mockPersistentStore = createMockPersistentStore(mockStorage);
|
mockPersistentStore = createMockPersistentStore(mockStorage);
|
||||||
|
|
||||||
const manager = new TypographyControlManager(
|
const manager = new TypographySettingsManager(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -241,7 +242,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('updates font size control display value when multiplier increases', () => {
|
it('updates font size control display value when multiplier increases', () => {
|
||||||
const manager = new TypographyControlManager(
|
const manager = new TypographySettingsManager(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -262,7 +263,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
|||||||
|
|
||||||
describe('Base Size Setter', () => {
|
describe('Base Size Setter', () => {
|
||||||
it('updates baseSize when set directly', () => {
|
it('updates baseSize when set directly', () => {
|
||||||
const manager = new TypographyControlManager(
|
const manager = new TypographySettingsManager(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -273,7 +274,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('updates size control value when baseSize is set', () => {
|
it('updates size control value when baseSize is set', () => {
|
||||||
const manager = new TypographyControlManager(
|
const manager = new TypographySettingsManager(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -284,7 +285,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('applies multiplier to size control when baseSize is set', () => {
|
it('applies multiplier to size control when baseSize is set', () => {
|
||||||
const manager = new TypographyControlManager(
|
const manager = new TypographySettingsManager(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -298,7 +299,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
|||||||
|
|
||||||
describe('Rendered Size Calculation', () => {
|
describe('Rendered Size Calculation', () => {
|
||||||
it('calculates renderedSize as baseSize * multiplier', () => {
|
it('calculates renderedSize as baseSize * multiplier', () => {
|
||||||
const manager = new TypographyControlManager(
|
const manager = new TypographySettingsManager(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -307,7 +308,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('updates renderedSize when multiplier changes', () => {
|
it('updates renderedSize when multiplier changes', () => {
|
||||||
const manager = new TypographyControlManager(
|
const manager = new TypographySettingsManager(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -320,7 +321,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('updates renderedSize when baseSize changes', () => {
|
it('updates renderedSize when baseSize changes', () => {
|
||||||
const manager = new TypographyControlManager(
|
const manager = new TypographySettingsManager(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -340,7 +341,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
|||||||
// proxy effect behavior should be tested in E2E tests.
|
// proxy effect behavior should be tested in E2E tests.
|
||||||
|
|
||||||
it('does NOT immediately update baseSize from control change (effect is async)', () => {
|
it('does NOT immediately update baseSize from control change (effect is async)', () => {
|
||||||
const manager = new TypographyControlManager(
|
const manager = new TypographySettingsManager(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -355,7 +356,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('updates baseSize via direct setter (synchronous)', () => {
|
it('updates baseSize via direct setter (synchronous)', () => {
|
||||||
const manager = new TypographyControlManager(
|
const manager = new TypographySettingsManager(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -380,7 +381,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
|||||||
};
|
};
|
||||||
mockPersistentStore = createMockPersistentStore(mockStorage);
|
mockPersistentStore = createMockPersistentStore(mockStorage);
|
||||||
|
|
||||||
const manager = new TypographyControlManager(
|
const manager = new TypographySettingsManager(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -393,7 +394,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('syncs to storage after effect flush (async)', async () => {
|
it('syncs to storage after effect flush (async)', async () => {
|
||||||
const manager = new TypographyControlManager(
|
const manager = new TypographySettingsManager(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -409,7 +410,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('syncs control changes to storage after effect flush (async)', async () => {
|
it('syncs control changes to storage after effect flush (async)', async () => {
|
||||||
const manager = new TypographyControlManager(
|
const manager = new TypographySettingsManager(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -422,7 +423,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('syncs height control changes to storage after effect flush (async)', async () => {
|
it('syncs height control changes to storage after effect flush (async)', async () => {
|
||||||
const manager = new TypographyControlManager(
|
const manager = new TypographySettingsManager(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -434,7 +435,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('syncs spacing control changes to storage after effect flush (async)', async () => {
|
it('syncs spacing control changes to storage after effect flush (async)', async () => {
|
||||||
const manager = new TypographyControlManager(
|
const manager = new TypographySettingsManager(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -448,7 +449,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
|||||||
|
|
||||||
describe('Control Value Getters', () => {
|
describe('Control Value Getters', () => {
|
||||||
it('returns current weight value', () => {
|
it('returns current weight value', () => {
|
||||||
const manager = new TypographyControlManager(
|
const manager = new TypographySettingsManager(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -460,7 +461,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns current height value', () => {
|
it('returns current height value', () => {
|
||||||
const manager = new TypographyControlManager(
|
const manager = new TypographySettingsManager(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -472,7 +473,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns current spacing value', () => {
|
it('returns current spacing value', () => {
|
||||||
const manager = new TypographyControlManager(
|
const manager = new TypographySettingsManager(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -485,7 +486,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
|||||||
|
|
||||||
it('returns default value when control is not found', () => {
|
it('returns default value when control is not found', () => {
|
||||||
// Create a manager with empty configs (no controls)
|
// Create a manager with empty configs (no controls)
|
||||||
const manager = new TypographyControlManager([], mockPersistentStore);
|
const manager = new TypographySettingsManager([], mockPersistentStore);
|
||||||
|
|
||||||
expect(manager.weight).toBe(DEFAULT_FONT_WEIGHT);
|
expect(manager.weight).toBe(DEFAULT_FONT_WEIGHT);
|
||||||
expect(manager.height).toBe(DEFAULT_LINE_HEIGHT);
|
expect(manager.height).toBe(DEFAULT_LINE_HEIGHT);
|
||||||
@@ -503,7 +504,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
|||||||
};
|
};
|
||||||
mockPersistentStore = createMockPersistentStore(mockStorage);
|
mockPersistentStore = createMockPersistentStore(mockStorage);
|
||||||
|
|
||||||
const manager = new TypographyControlManager(
|
const manager = new TypographySettingsManager(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -536,7 +537,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
|||||||
clear: clearSpy,
|
clear: clearSpy,
|
||||||
};
|
};
|
||||||
|
|
||||||
const manager = new TypographyControlManager(
|
const manager = new TypographySettingsManager(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -547,7 +548,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('respects multiplier when resetting font size control', () => {
|
it('respects multiplier when resetting font size control', () => {
|
||||||
const manager = new TypographyControlManager(
|
const manager = new TypographySettingsManager(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -565,7 +566,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
|||||||
|
|
||||||
describe('Complex Scenarios', () => {
|
describe('Complex Scenarios', () => {
|
||||||
it('handles changing multiplier then modifying baseSize', () => {
|
it('handles changing multiplier then modifying baseSize', () => {
|
||||||
const manager = new TypographyControlManager(
|
const manager = new TypographySettingsManager(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -586,7 +587,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('maintains correct renderedSize throughout changes', () => {
|
it('maintains correct renderedSize throughout changes', () => {
|
||||||
const manager = new TypographyControlManager(
|
const manager = new TypographySettingsManager(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -608,7 +609,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('handles multiple control changes in sequence', async () => {
|
it('handles multiple control changes in sequence', async () => {
|
||||||
const manager = new TypographyControlManager(
|
const manager = new TypographySettingsManager(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -633,7 +634,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
|||||||
mockStorage = { fontSize: 48, fontWeight: 400, lineHeight: 1.5, letterSpacing: 0 };
|
mockStorage = { fontSize: 48, fontWeight: 400, lineHeight: 1.5, letterSpacing: 0 };
|
||||||
mockPersistentStore = createMockPersistentStore(mockStorage);
|
mockPersistentStore = createMockPersistentStore(mockStorage);
|
||||||
|
|
||||||
const manager = new TypographyControlManager(
|
const manager = new TypographySettingsManager(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -645,7 +646,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('handles very small multiplier', () => {
|
it('handles very small multiplier', () => {
|
||||||
const manager = new TypographyControlManager(
|
const manager = new TypographySettingsManager(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -658,7 +659,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('handles large base size with multiplier', () => {
|
it('handles large base size with multiplier', () => {
|
||||||
const manager = new TypographyControlManager(
|
const manager = new TypographySettingsManager(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -671,7 +672,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('handles floating point precision in multiplier', () => {
|
it('handles floating point precision in multiplier', () => {
|
||||||
const manager = new TypographyControlManager(
|
const manager = new TypographySettingsManager(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -690,7 +691,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('handles control methods (increase/decrease)', () => {
|
it('handles control methods (increase/decrease)', () => {
|
||||||
const manager = new TypographyControlManager(
|
const manager = new TypographySettingsManager(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -704,7 +705,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('handles control boundary conditions', () => {
|
it('handles control boundary conditions', () => {
|
||||||
const manager = new TypographyControlManager(
|
const manager = new TypographySettingsManager(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -1,24 +1 @@
|
|||||||
export {
|
export { typographySettingsStore } from './state/typographySettingsStore';
|
||||||
DEFAULT_FONT_SIZE,
|
|
||||||
DEFAULT_FONT_WEIGHT,
|
|
||||||
DEFAULT_LETTER_SPACING,
|
|
||||||
DEFAULT_LINE_HEIGHT,
|
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
|
||||||
FONT_SIZE_STEP,
|
|
||||||
FONT_WEIGHT_STEP,
|
|
||||||
LINE_HEIGHT_STEP,
|
|
||||||
MAX_FONT_SIZE,
|
|
||||||
MAX_FONT_WEIGHT,
|
|
||||||
MAX_LINE_HEIGHT,
|
|
||||||
MIN_FONT_SIZE,
|
|
||||||
MIN_FONT_WEIGHT,
|
|
||||||
MIN_LINE_HEIGHT,
|
|
||||||
MULTIPLIER_L,
|
|
||||||
MULTIPLIER_M,
|
|
||||||
MULTIPLIER_S,
|
|
||||||
} from './const/const';
|
|
||||||
|
|
||||||
export {
|
|
||||||
type ControlId,
|
|
||||||
controlManager,
|
|
||||||
} from './state/manager.svelte';
|
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
import { createTypographyControlManager } from '../../lib';
|
|
||||||
import { DEFAULT_TYPOGRAPHY_CONTROLS_DATA } from '../const/const';
|
|
||||||
|
|
||||||
export type ControlId = 'font_size' | 'font_weight' | 'line_height' | 'letter_spacing';
|
|
||||||
|
|
||||||
export const controlManager = createTypographyControlManager(DEFAULT_TYPOGRAPHY_CONTROLS_DATA);
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { DEFAULT_TYPOGRAPHY_CONTROLS_DATA } from '$entities/Font';
|
||||||
|
import { createTypographySettingsManager } from '../../lib';
|
||||||
|
|
||||||
|
export const typographySettingsStore = createTypographySettingsManager(
|
||||||
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
|
'glyphdiff:comparison:typography',
|
||||||
|
);
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<script module>
|
||||||
|
import Providers from '$shared/lib/storybook/Providers.svelte';
|
||||||
|
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||||
|
import TypographyMenu from './TypographyMenu.svelte';
|
||||||
|
|
||||||
|
const { Story } = defineMeta({
|
||||||
|
title: 'Features/TypographyMenu',
|
||||||
|
component: TypographyMenu,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
parameters: {
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
component:
|
||||||
|
'Floating typography controls. Mobile/tablet: settings button that opens a popover. Desktop: inline bar with combo controls.',
|
||||||
|
},
|
||||||
|
story: { inline: false },
|
||||||
|
},
|
||||||
|
layout: 'centered',
|
||||||
|
storyStage: { maxWidth: 'max-w-xl' },
|
||||||
|
},
|
||||||
|
argTypes: {
|
||||||
|
hidden: { control: 'boolean' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Story name="Desktop">
|
||||||
|
{#snippet template()}
|
||||||
|
<Providers>
|
||||||
|
<div class="relative h-20 flex items-end justify-center p-4">
|
||||||
|
<TypographyMenu />
|
||||||
|
</div>
|
||||||
|
</Providers>
|
||||||
|
{/snippet}
|
||||||
|
</Story>
|
||||||
|
|
||||||
|
<Story name="Hidden">
|
||||||
|
{#snippet template()}
|
||||||
|
<Providers>
|
||||||
|
<div class="relative h-20 flex items-end justify-center p-4">
|
||||||
|
<TypographyMenu hidden={true} />
|
||||||
|
</div>
|
||||||
|
</Providers>
|
||||||
|
{/snippet}
|
||||||
|
</Story>
|
||||||
@@ -1,13 +1,16 @@
|
|||||||
<!--
|
<!--
|
||||||
Component: TypographyMenu
|
Component: TypographyMenu
|
||||||
Floating controls bar for typography settings.
|
Floating controls bar for typography settings.
|
||||||
Warm surface, sharp corners, Settings icon header, dividers between units.
|
|
||||||
Mobile: popover with slider controls anchored to settings button.
|
Mobile: popover with slider controls anchored to settings button.
|
||||||
Desktop: inline bar with combo controls.
|
Desktop: inline bar with combo controls.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
MULTIPLIER_L,
|
||||||
|
MULTIPLIER_M,
|
||||||
|
MULTIPLIER_S,
|
||||||
|
} from '$entities/Font';
|
||||||
import type { ResponsiveManager } from '$shared/lib';
|
import type { ResponsiveManager } from '$shared/lib';
|
||||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
ComboControl,
|
ComboControl,
|
||||||
@@ -17,15 +20,11 @@ import {
|
|||||||
import Settings2Icon from '@lucide/svelte/icons/settings-2';
|
import Settings2Icon from '@lucide/svelte/icons/settings-2';
|
||||||
import XIcon from '@lucide/svelte/icons/x';
|
import XIcon from '@lucide/svelte/icons/x';
|
||||||
import { Popover } from 'bits-ui';
|
import { Popover } from 'bits-ui';
|
||||||
|
import clsx from 'clsx';
|
||||||
import { getContext } from 'svelte';
|
import { getContext } from 'svelte';
|
||||||
import { cubicOut } from 'svelte/easing';
|
import { cubicOut } from 'svelte/easing';
|
||||||
import { fly } from 'svelte/transition';
|
import { fly } from 'svelte/transition';
|
||||||
import {
|
import { typographySettingsStore } from '../../model';
|
||||||
MULTIPLIER_L,
|
|
||||||
MULTIPLIER_M,
|
|
||||||
MULTIPLIER_S,
|
|
||||||
controlManager,
|
|
||||||
} from '../../model';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/**
|
/**
|
||||||
@@ -37,67 +36,62 @@ interface Props {
|
|||||||
* @default false
|
* @default false
|
||||||
*/
|
*/
|
||||||
hidden?: boolean;
|
hidden?: boolean;
|
||||||
|
/**
|
||||||
|
* Bindable popover open state
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
open?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { class: className, hidden = false }: Props = $props();
|
let { class: className, hidden = false, open = $bindable(false) }: Props = $props();
|
||||||
|
|
||||||
const responsive = getContext<ResponsiveManager>('responsive');
|
const responsive = getContext<ResponsiveManager>('responsive');
|
||||||
|
|
||||||
let isOpen = $state(false);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the common font size multiplier based on the current responsive state.
|
* Sets the common font size multiplier based on the current responsive state.
|
||||||
*/
|
*/
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!responsive) return;
|
if (!responsive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
switch (true) {
|
switch (true) {
|
||||||
case responsive.isMobile:
|
case responsive.isMobile:
|
||||||
controlManager.multiplier = MULTIPLIER_S;
|
typographySettingsStore.multiplier = MULTIPLIER_S;
|
||||||
break;
|
break;
|
||||||
case responsive.isTablet:
|
case responsive.isTablet:
|
||||||
controlManager.multiplier = MULTIPLIER_M;
|
typographySettingsStore.multiplier = MULTIPLIER_M;
|
||||||
break;
|
break;
|
||||||
case responsive.isDesktop:
|
case responsive.isDesktop:
|
||||||
controlManager.multiplier = MULTIPLIER_L;
|
typographySettingsStore.multiplier = MULTIPLIER_L;
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
controlManager.multiplier = MULTIPLIER_L;
|
typographySettingsStore.multiplier = MULTIPLIER_L;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if !hidden}
|
{#if !hidden}
|
||||||
{#if responsive.isMobile}
|
{#if responsive.isMobileOrTablet}
|
||||||
<Popover.Root bind:open={isOpen}>
|
<Popover.Root bind:open>
|
||||||
<Popover.Trigger>
|
<Popover.Trigger>
|
||||||
{#snippet child({ props })}
|
{#snippet child({ props })}
|
||||||
<button
|
<Button class={className} variant="primary" {...props}>
|
||||||
{...props}
|
{#snippet icon()}
|
||||||
class={cn(
|
<Settings2Icon class="size-4" />
|
||||||
'inline-flex items-center justify-center',
|
{/snippet}
|
||||||
'size-8 p-0',
|
</Button>
|
||||||
'border border-transparent rounded-none',
|
|
||||||
'transition-colors duration-150',
|
|
||||||
'hover:bg-white/50 dark:hover:bg-white/5',
|
|
||||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/30',
|
|
||||||
isOpen && 'bg-paper dark:bg-dark-card border-black/5 dark:border-white/10 shadow-sm',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Settings2Icon class="size-4" />
|
|
||||||
</button>
|
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Popover.Trigger>
|
</Popover.Trigger>
|
||||||
|
|
||||||
<Popover.Portal>
|
<Popover.Portal>
|
||||||
<Popover.Content
|
<Popover.Content
|
||||||
side="top"
|
side="top"
|
||||||
align="start"
|
align="end"
|
||||||
sideOffset={8}
|
sideOffset={8}
|
||||||
class={cn(
|
class={clsx(
|
||||||
'z-50 w-72',
|
'z-50 w-72',
|
||||||
'bg-surface dark:bg-dark-card',
|
'bg-surface dark:bg-dark-card',
|
||||||
'border border-black/5 dark:border-white/10',
|
'border border-subtle',
|
||||||
'shadow-[0_20px_40px_-10px_rgba(0,0,0,0.15)]',
|
'shadow-[0_20px_40px_-10px_rgba(0,0,0,0.15)]',
|
||||||
'rounded-none p-4',
|
'rounded-none p-4',
|
||||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||||
@@ -110,11 +104,11 @@ $effect(() => {
|
|||||||
escapeKeydownBehavior="close"
|
escapeKeydownBehavior="close"
|
||||||
>
|
>
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="flex items-center justify-between mb-3 pb-3 border-b border-black/5 dark:border-white/10">
|
<div class="flex items-center justify-between mb-3 pb-3 border-b border-subtle">
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<Settings2Icon size={12} class="text-swiss-red" />
|
<Settings2Icon size={12} class="text-swiss-red" />
|
||||||
<span
|
<span
|
||||||
class="text-[0.5625rem] font-mono uppercase tracking-widest font-bold text-swiss-black dark:text-neutral-200"
|
class="text-3xs font-mono uppercase tracking-widest font-bold text-swiss-black dark:text-neutral-200"
|
||||||
>
|
>
|
||||||
CONTROLS
|
CONTROLS
|
||||||
</span>
|
</span>
|
||||||
@@ -133,7 +127,7 @@ $effect(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Controls -->
|
<!-- Controls -->
|
||||||
{#each controlManager.controls as control (control.id)}
|
{#each typographySettingsStore.controls as control (control.id)}
|
||||||
<ControlGroup label={control.controlLabel ?? ''}>
|
<ControlGroup label={control.controlLabel ?? ''}>
|
||||||
<Slider
|
<Slider
|
||||||
bind:value={control.instance.value}
|
bind:value={control.instance.value}
|
||||||
@@ -148,33 +142,33 @@ $effect(() => {
|
|||||||
</Popover.Root>
|
</Popover.Root>
|
||||||
{:else}
|
{:else}
|
||||||
<div
|
<div
|
||||||
class={cn('w-full md:w-auto', className)}
|
class={clsx('w-full md:w-auto', className)}
|
||||||
transition:fly={{ y: 100, duration: 200, easing: cubicOut }}
|
transition:fly={{ y: 100, duration: 200, easing: cubicOut }}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class={cn(
|
class={clsx(
|
||||||
'flex items-center gap-1 md:gap-2 p-1.5 md:p-2',
|
'flex items-center gap-1 md:gap-2 p-1.5 md:p-2',
|
||||||
'bg-surface/95 dark:bg-dark-bg/95 backdrop-blur-xl',
|
'bg-surface/95 dark:bg-dark-bg/95 backdrop-blur-xl',
|
||||||
'border border-black/5 dark:border-white/10',
|
'border border-subtle',
|
||||||
'shadow-[0_20px_40px_-10px_rgba(0,0,0,0.1)]',
|
'shadow-[0_20px_40px_-10px_rgba(0,0,0,0.1)]',
|
||||||
'rounded-none ring-1 ring-black/5 dark:ring-white/5',
|
'rounded-none ring-1 ring-black/5 dark:ring-white/5',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<!-- Header: icon + label -->
|
<!-- Header: icon + label -->
|
||||||
<div class="px-2 md:px-3 flex items-center gap-1.5 md:gap-2 border-r border-black/5 dark:border-white/10 mr-1 text-swiss-black dark:text-neutral-200 shrink-0">
|
<div class="px-2 md:px-3 flex items-center gap-1.5 md:gap-2 border-r border-subtle mr-1 text-swiss-black dark:text-neutral-200 shrink-0">
|
||||||
<Settings2Icon
|
<Settings2Icon
|
||||||
size={14}
|
size={14}
|
||||||
class="text-swiss-red"
|
class="text-swiss-red"
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
class="text-[0.5625rem] md:text-[0.625rem] font-mono uppercase tracking-widest font-bold hidden sm:inline whitespace-nowrap"
|
class="text-3xs md:text-2xs font-mono uppercase tracking-widest font-bold hidden sm:inline whitespace-nowrap"
|
||||||
>
|
>
|
||||||
GLOBAL_CONTROLS
|
GLOBAL_CONTROLS
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Controls with dividers between each -->
|
<!-- Controls with dividers between each -->
|
||||||
{#each controlManager.controls as control, i (control.id)}
|
{#each typographySettingsStore.controls as control, i (control.id)}
|
||||||
{#if i > 0}
|
{#if i > 0}
|
||||||
<div class="w-px h-6 md:h-8 bg-black/5 dark:bg-white/10 mx-0.5 md:mx-1 shrink-0"></div>
|
<div class="w-px h-6 md:h-8 bg-black/5 dark:bg-white/10 mx-0.5 md:mx-1 shrink-0"></div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* Application entry point
|
||||||
|
*
|
||||||
|
* Mounts the main App component to the DOM and initializes
|
||||||
|
* global styles.
|
||||||
|
*/
|
||||||
import App from '$app/App.svelte';
|
import App from '$app/App.svelte';
|
||||||
import { mount } from 'svelte';
|
import { mount } from 'svelte';
|
||||||
import '$app/styles/app.css';
|
import '$app/styles/app.css';
|
||||||
|
|||||||
@@ -3,10 +3,7 @@
|
|||||||
Description: The main page component of the application.
|
Description: The main page component of the application.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { scrollBreadcrumbsStore } from '$entities/Breadcrumb';
|
|
||||||
import { ComparisonView } from '$widgets/ComparisonView';
|
import { ComparisonView } from '$widgets/ComparisonView';
|
||||||
import { FontSearchSection } from '$widgets/FontSearch';
|
|
||||||
import { SampleListSection } from '$widgets/SampleList';
|
|
||||||
import { cubicIn } from 'svelte/easing';
|
import { cubicIn } from 'svelte/easing';
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
</script>
|
</script>
|
||||||
@@ -15,11 +12,7 @@ import { fade } from 'svelte/transition';
|
|||||||
class="h-full flex flex-col gap-3 sm:gap-4"
|
class="h-full flex flex-col gap-3 sm:gap-4"
|
||||||
in:fade={{ duration: 500, delay: 150, easing: cubicIn }}
|
in:fade={{ duration: 500, delay: 150, easing: cubicIn }}
|
||||||
>
|
>
|
||||||
<section class="w-auto">
|
<main class="w-auto">
|
||||||
<ComparisonView />
|
<ComparisonView />
|
||||||
</section>
|
|
||||||
<main class="w-full pt-0 pb-10 sm:px-6 sm:pt-16 sm:pb-12 md:px-8 md:pt-32 md:pb-16 lg:px-10 lg:pt-48 lg:pb-20 xl:px-16">
|
|
||||||
<FontSearchSection />
|
|
||||||
<SampleListSection index={1} />
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -41,10 +41,14 @@ export class ApiError extends Error {
|
|||||||
* @param response - Original fetch Response object
|
* @param response - Original fetch Response object
|
||||||
*/
|
*/
|
||||||
constructor(
|
constructor(
|
||||||
/** HTTP status code */
|
/**
|
||||||
|
* HTTP status code
|
||||||
|
*/
|
||||||
public status: number,
|
public status: number,
|
||||||
message: string,
|
message: string,
|
||||||
/** Original Response object for inspection */
|
/**
|
||||||
|
* Original Response object for inspection
|
||||||
|
*/
|
||||||
public response?: Response,
|
public response?: Response,
|
||||||
) {
|
) {
|
||||||
super(message);
|
super(message);
|
||||||
|
|||||||
@@ -15,15 +15,25 @@ import { QueryClient } from '@tanstack/query-core';
|
|||||||
export const queryClient = new QueryClient({
|
export const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
/** Data remains fresh for 5 minutes after fetch */
|
/**
|
||||||
|
* Data remains fresh for 5 minutes after fetch
|
||||||
|
*/
|
||||||
staleTime: 5 * 60 * 1000,
|
staleTime: 5 * 60 * 1000,
|
||||||
/** Unused cache entries are removed after 10 minutes */
|
/**
|
||||||
|
* Unused cache entries are removed after 10 minutes
|
||||||
|
*/
|
||||||
gcTime: 10 * 60 * 1000,
|
gcTime: 10 * 60 * 1000,
|
||||||
/** Don't refetch when window regains focus */
|
/**
|
||||||
|
* Don't refetch when window regains focus
|
||||||
|
*/
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
/** Refetch on mount if data is stale */
|
/**
|
||||||
|
* Refetch on mount if data is stale
|
||||||
|
*/
|
||||||
refetchOnMount: true,
|
refetchOnMount: true,
|
||||||
/** Retry failed requests up to 3 times */
|
/**
|
||||||
|
* Retry failed requests up to 3 times
|
||||||
|
*/
|
||||||
retry: 3,
|
retry: 3,
|
||||||
/**
|
/**
|
||||||
* Exponential backoff for retries
|
* Exponential backoff for retries
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import {
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
} from 'vitest';
|
||||||
|
import { fontKeys } from './queryKeys';
|
||||||
|
|
||||||
|
describe('fontKeys', () => {
|
||||||
|
describe('Hierarchy', () => {
|
||||||
|
it('should generate base keys', () => {
|
||||||
|
expect(fontKeys.all).toEqual(['fonts']);
|
||||||
|
expect(fontKeys.lists()).toEqual(['fonts', 'list']);
|
||||||
|
expect(fontKeys.batches()).toEqual(['fonts', 'batch']);
|
||||||
|
expect(fontKeys.details()).toEqual(['fonts', 'detail']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Batch Keys (Stability & Sorting)', () => {
|
||||||
|
it('should sort IDs for stable serialization', () => {
|
||||||
|
const key1 = fontKeys.batch(['b', 'a', 'c']);
|
||||||
|
const key2 = fontKeys.batch(['c', 'b', 'a']);
|
||||||
|
const expected = ['fonts', 'batch', ['a', 'b', 'c']];
|
||||||
|
expect(key1).toEqual(expected);
|
||||||
|
expect(key2).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty ID arrays', () => {
|
||||||
|
expect(fontKeys.batch([])).toEqual(['fonts', 'batch', []]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not mutate the input array when sorting', () => {
|
||||||
|
const ids = ['c', 'b', 'a'];
|
||||||
|
fontKeys.batch(ids);
|
||||||
|
expect(ids).toEqual(['c', 'b', 'a']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('batch key should be rooted in batches() base', () => {
|
||||||
|
const key = fontKeys.batch(['a']);
|
||||||
|
expect(key.slice(0, 2)).toEqual(fontKeys.batches());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('List Keys (Parameters)', () => {
|
||||||
|
it('should include parameters in list keys', () => {
|
||||||
|
const params = { provider: 'google' };
|
||||||
|
expect(fontKeys.list(params)).toEqual(['fonts', 'list', params]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty parameters', () => {
|
||||||
|
expect(fontKeys.list({})).toEqual(['fonts', 'list', {}]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('list key should be rooted in lists() base', () => {
|
||||||
|
const key = fontKeys.list({ provider: 'google' });
|
||||||
|
expect(key.slice(0, 2)).toEqual(fontKeys.lists());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Detail Keys', () => {
|
||||||
|
it('should generate unique detail keys per ID', () => {
|
||||||
|
expect(fontKeys.detail('roboto')).toEqual(['fonts', 'detail', 'roboto']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should generate different keys for different IDs', () => {
|
||||||
|
expect(fontKeys.detail('roboto')).not.toEqual(fontKeys.detail('open-sans'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detail key should be rooted in details() base', () => {
|
||||||
|
const key = fontKeys.detail('roboto');
|
||||||
|
expect(key.slice(0, 2)).toEqual(fontKeys.details());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* Stable query key factory for font-related queries.
|
||||||
|
* Ensures consistent serialization for batch requests by sorting IDs.
|
||||||
|
*/
|
||||||
|
export const fontKeys = {
|
||||||
|
/**
|
||||||
|
* Base key for all font queries
|
||||||
|
*/
|
||||||
|
all: ['fonts'] as const,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keys for font list queries
|
||||||
|
*/
|
||||||
|
lists: () => [...fontKeys.all, 'list'] as const,
|
||||||
|
/**
|
||||||
|
* Specific font list key with filter parameters
|
||||||
|
*/
|
||||||
|
list: (params: object) => [...fontKeys.lists(), params] as const,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keys for font batch queries
|
||||||
|
*/
|
||||||
|
batches: () => [...fontKeys.all, 'batch'] as const,
|
||||||
|
/**
|
||||||
|
* Specific batch key, sorted for stability
|
||||||
|
*/
|
||||||
|
batch: (ids: string[]) => [...fontKeys.batches(), [...ids].sort()] as const,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keys for font detail queries
|
||||||
|
*/
|
||||||
|
details: () => [...fontKeys.all, 'detail'] as const,
|
||||||
|
/**
|
||||||
|
* Specific font detail key by ID
|
||||||
|
*/
|
||||||
|
detail: (id: string) => [...fontKeys.details(), id] as const,
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="103" height="87" viewBox="0 0 103 87" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M50.688 86.144C43.008 86.144 36.0533 85.248 29.824 83.456C23.68 81.664 18.3467 78.976 13.824 75.392C9.38667 71.808 5.97333 67.3707 3.584 62.08C1.19467 56.7893 0 50.688 0 43.776C0 36.7787 1.23733 30.592 3.712 25.216C6.272 19.7547 9.856 15.1467 14.464 11.392C19.1573 7.63733 24.704 4.82133 31.104 2.944C37.5893 0.981333 44.7573 0 52.608 0C61.9093 0 69.9307 1.32267 76.672 3.968C83.4133 6.528 88.704 10.1547 92.544 14.848C96.4693 19.5413 98.688 25.1307 99.2 31.616H82.816C81.7067 28.2027 79.872 25.2587 77.312 22.784C74.8373 20.224 71.552 18.2613 67.456 16.896C63.36 15.4453 58.4107 14.72 52.608 14.72C45.184 14.72 38.8267 15.9147 33.536 18.304C28.3307 20.6933 24.3627 24.064 21.632 28.416C18.9013 32.768 17.536 37.888 17.536 43.776C17.536 49.4933 18.7307 54.4427 21.12 58.624C23.5093 62.72 27.1787 65.8773 32.128 68.096C37.1627 70.3147 43.5627 71.424 51.328 71.424C57.3013 71.424 62.5493 70.656 67.072 69.12C71.68 67.4987 75.52 65.3653 78.592 62.72C81.664 59.9893 83.84 56.96 85.12 53.632L91.776 51.2C90.6667 62.208 86.4853 70.784 79.232 76.928C72.064 83.072 62.5493 86.144 50.688 86.144ZM87.424 84.48C87.424 81.8347 87.5947 78.8053 87.936 75.392C88.2773 71.8933 88.704 68.3947 89.216 64.896C89.728 61.312 90.1973 58.0267 90.624 55.04H52.736V44.16H102.144V84.48H87.424Z" fill="#FF3B30"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -1,4 +0,0 @@
|
|||||||
<svg width="52" height="35" viewBox="0 0 52 35" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M10.608 34.368C8.496 34.368 6.64 33.968 5.04 33.168C3.44 32.336 2.192 31.184 1.296 29.712C0.432 28.208 0 26.48 0 24.528V9.84C0 7.888 0.432 6.176 1.296 4.704C2.192 3.2 3.44 2.048 5.04 1.248C6.64 0.415999 8.496 0 10.608 0C12.688 0 14.528 0.415999 16.128 1.248C17.728 2.048 18.96 3.2 19.824 4.704C20.688 6.176 21.12 7.872 21.12 9.792V10.512C21.12 10.832 20.96 10.992 20.64 10.992H20.16C19.84 10.992 19.68 10.832 19.68 10.512V9.744C19.68 7.216 18.848 5.184 17.184 3.648C15.52 2.112 13.328 1.344 10.608 1.344C7.856 1.344 5.632 2.128 3.936 3.696C2.272 5.232 1.44 7.264 1.44 9.792V24.576C1.44 27.104 2.272 29.152 3.936 30.72C5.632 32.256 7.856 33.024 10.608 33.024C13.328 33.024 15.52 32.272 17.184 30.768C18.848 29.232 19.68 27.2 19.68 24.672V19.152C19.68 19.024 19.616 18.96 19.488 18.96H11.472C11.152 18.96 10.992 18.8 10.992 18.48V18.144C10.992 17.824 11.152 17.664 11.472 17.664H20.64C20.96 17.664 21.12 17.824 21.12 18.144V24.48C21.12 26.464 20.688 28.208 19.824 29.712C18.96 31.184 17.728 32.336 16.128 33.168C14.528 33.968 12.688 34.368 10.608 34.368Z" fill="white"/>
|
|
||||||
<path d="M31.2124 33.984C30.8924 33.984 30.7324 33.824 30.7324 33.504V0.863997C30.7324 0.543998 30.8924 0.383998 31.2124 0.383998H42.1084C45.0204 0.383998 47.3084 1.168 48.9724 2.736C50.6684 4.272 51.5164 6.4 51.5164 9.12V25.248C51.5164 27.968 50.6684 30.112 48.9724 31.68C47.3084 33.216 45.0204 33.984 42.1084 33.984H31.2124ZM32.1724 32.448C32.1724 32.576 32.2364 32.64 32.3644 32.64H42.2044C44.6364 32.64 46.5564 31.984 47.9644 30.672C49.3724 29.328 50.0764 27.504 50.0764 25.2V9.216C50.0764 6.88 49.3724 5.056 47.9644 3.744C46.5564 2.4 44.6364 1.728 42.2044 1.728H32.3644C32.2364 1.728 32.1724 1.792 32.1724 1.92V32.448Z" fill="white"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.8 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,51 @@
|
|||||||
|
import { queryClient } from '$shared/api/queryClient';
|
||||||
|
import {
|
||||||
|
QueryObserver,
|
||||||
|
type QueryObserverOptions,
|
||||||
|
type QueryObserverResult,
|
||||||
|
} from '@tanstack/query-core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abstract base class for reactive Svelte 5 stores backed by TanStack Query.
|
||||||
|
*
|
||||||
|
* Provides a unified way to use TanStack Query observers within Svelte 5 classes
|
||||||
|
* using runes for reactivity. Handles subscription lifecycle automatically.
|
||||||
|
*
|
||||||
|
* @template TData - The type of data returned by the query.
|
||||||
|
* @template TError - The type of error that can be thrown.
|
||||||
|
*/
|
||||||
|
export abstract class BaseQueryStore<TData, TError = Error> {
|
||||||
|
#result = $state<QueryObserverResult<TData, TError>>({} as QueryObserverResult<TData, TError>);
|
||||||
|
#observer: QueryObserver<TData, TError>;
|
||||||
|
#unsubscribe: () => void;
|
||||||
|
|
||||||
|
constructor(options: QueryObserverOptions<TData, TError, TData, any, any>) {
|
||||||
|
this.#observer = new QueryObserver(queryClient, options);
|
||||||
|
this.#unsubscribe = this.#observer.subscribe(result => {
|
||||||
|
this.#result = result;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current query result (reactive)
|
||||||
|
*/
|
||||||
|
protected get result(): QueryObserverResult<TData, TError> {
|
||||||
|
return this.#result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates observer options dynamically.
|
||||||
|
* Use this when query parameters or dependencies change.
|
||||||
|
*/
|
||||||
|
protected updateOptions(options: QueryObserverOptions<TData, TError, TData, any, any>): void {
|
||||||
|
this.#observer.setOptions(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleans up the observer subscription.
|
||||||
|
* Should be called when the store is no longer needed.
|
||||||
|
*/
|
||||||
|
destroy(): void {
|
||||||
|
this.#unsubscribe();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { queryClient } from '$shared/api/queryClient';
|
||||||
|
import {
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
vi,
|
||||||
|
} from 'vitest';
|
||||||
|
import { BaseQueryStore } from './BaseQueryStore.svelte';
|
||||||
|
|
||||||
|
class TestStore extends BaseQueryStore<string> {
|
||||||
|
constructor(key = ['test'], fn = () => Promise.resolve('ok')) {
|
||||||
|
super({
|
||||||
|
queryKey: key,
|
||||||
|
queryFn: fn,
|
||||||
|
retry: false, // Disable retries for faster error testing
|
||||||
|
});
|
||||||
|
}
|
||||||
|
get data() {
|
||||||
|
return this.result.data;
|
||||||
|
}
|
||||||
|
get isLoading() {
|
||||||
|
return this.result.isLoading;
|
||||||
|
}
|
||||||
|
get isError() {
|
||||||
|
return this.result.isError;
|
||||||
|
}
|
||||||
|
|
||||||
|
update(newKey: string[], newFn?: () => Promise<string>) {
|
||||||
|
this.updateOptions({
|
||||||
|
queryKey: newKey,
|
||||||
|
queryFn: newFn ?? (() => Promise.resolve('ok')),
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
import * as tq from '@tanstack/query-core';
|
||||||
|
|
||||||
|
// ... (TestStore remains same)
|
||||||
|
|
||||||
|
describe('BaseQueryStore', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
queryClient.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Lifecycle & Fetching', () => {
|
||||||
|
it('should transition from loading to success', async () => {
|
||||||
|
const store = new TestStore();
|
||||||
|
expect(store.isLoading).toBe(true);
|
||||||
|
await vi.waitFor(() => expect(store.data).toBe('ok'), { timeout: 1000 });
|
||||||
|
expect(store.isLoading).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have undefined data and no error in initial loading state', () => {
|
||||||
|
const store = new TestStore(['initial-state'], () => new Promise(r => setTimeout(() => r('late'), 500)));
|
||||||
|
expect(store.data).toBeUndefined();
|
||||||
|
expect(store.isError).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Error Handling', () => {
|
||||||
|
it('should handle query failures', async () => {
|
||||||
|
const store = new TestStore(['fail'], () => Promise.reject(new Error('fail')));
|
||||||
|
await vi.waitFor(() => expect(store.isError).toBe(true), { timeout: 1000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Reactivity', () => {
|
||||||
|
it('should refetch and update data when options change', async () => {
|
||||||
|
const store = new TestStore(['key1'], () => Promise.resolve('val1'));
|
||||||
|
await vi.waitFor(() => expect(store.data).toBe('val1'), { timeout: 1000 });
|
||||||
|
|
||||||
|
store.update(['key2'], () => Promise.resolve('val2'));
|
||||||
|
await vi.waitFor(() => expect(store.data).toBe('val2'), { timeout: 1000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Cleanup', () => {
|
||||||
|
it('should unsubscribe observer on destroy', () => {
|
||||||
|
const unsubscribe = vi.fn();
|
||||||
|
const subscribeSpy = vi.spyOn(tq.QueryObserver.prototype, 'subscribe').mockReturnValue(unsubscribe);
|
||||||
|
|
||||||
|
const store = new TestStore();
|
||||||
|
store.destroy();
|
||||||
|
|
||||||
|
expect(unsubscribe).toHaveBeenCalled();
|
||||||
|
subscribeSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user