Compare commits
117 Commits
d21de1bf78
...
feature/un
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
e88cca9289 | ||
|
|
d4cf6764b4 | ||
|
|
5a065ae5a1 | ||
|
|
20110168f2 | ||
|
|
f88729cc77 |
@@ -41,7 +41,7 @@ jobs:
|
|||||||
run: yarn lint
|
run: yarn lint
|
||||||
|
|
||||||
- name: Type Check
|
- name: Type Check
|
||||||
run: yarn check:shadcn-excluded
|
run: yarn check
|
||||||
|
|
||||||
publish:
|
publish:
|
||||||
needs: build # Only runs if tests/lint pass
|
needs: build # Only runs if tests/lint pass
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
|
||||||
@@ -31,7 +31,12 @@
|
|||||||
"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",
|
||||||
|
"whileStatement.useBraces": "always",
|
||||||
|
"forStatement.useBraces": "always",
|
||||||
|
"forInStatement.useBraces": "always",
|
||||||
|
"forOfStatement.useBraces": "always"
|
||||||
},
|
},
|
||||||
"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>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ pre-push:
|
|||||||
run: yarn tsc --noEmit
|
run: yarn tsc --noEmit
|
||||||
|
|
||||||
svelte-check:
|
svelte-check:
|
||||||
run: yarn check:shadcn-excluded --threshold warning
|
run: yarn check --threshold warning
|
||||||
|
|
||||||
format-check:
|
format-check:
|
||||||
glob: "*.{ts,js,svelte,json,md}"
|
glob: "*.{ts,js,svelte,json,md}"
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
* {
|
* {
|
||||||
|
|||||||
@@ -14,12 +14,10 @@
|
|||||||
*
|
*
|
||||||
* - Footer 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 GD from '$shared/assets/GD.svg';
|
||||||
import { ResponsiveProvider } from '$shared/lib';
|
import { ResponsiveProvider } from '$shared/lib';
|
||||||
import { Provider as TooltipProvider } from '$shared/shadcn/ui/tooltip';
|
import clsx from 'clsx';
|
||||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
|
||||||
import {
|
import {
|
||||||
type Snippet,
|
type Snippet,
|
||||||
onDestroy,
|
onDestroy,
|
||||||
@@ -80,24 +78,14 @@ onDestroy(() => themeManager.destroy());
|
|||||||
<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',
|
||||||
theme === 'dark' ? 'dark' : '',
|
theme === 'dark' ? 'dark' : '',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<header>
|
|
||||||
<BreadcrumbHeader />
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- <ScrollArea class="h-screen w-screen"> -->
|
|
||||||
<!-- <main class="flex-1 w-full mx-auto relative"> -->
|
|
||||||
<TooltipProvider>
|
|
||||||
{#if fontsReady}
|
{#if fontsReady}
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
{/if}
|
{/if}
|
||||||
</TooltipProvider>
|
|
||||||
<!-- </main> -->
|
|
||||||
<!-- </ScrollArea> -->
|
|
||||||
<footer></footer>
|
<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}`;
|
||||||
|
|||||||
@@ -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';
|
|
||||||
|
|||||||
51
src/entities/Font/lib/errors/errors.test.ts
Normal file
51
src/entities/Font/lib/errors/errors.test.ts
Normal file
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
28
src/entities/Font/lib/errors/errors.ts
Normal file
28
src/entities/Font/lib/errors/errors.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
134
src/entities/Font/lib/sizeResolver/createFontRowSizeResolver.ts
Normal file
134
src/entities/Font/lib/sizeResolver/createFontRowSizeResolver.ts
Normal file
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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,14 +1,9 @@
|
|||||||
/** @vitest-environment jsdom */
|
/**
|
||||||
import {
|
* @vitest-environment jsdom
|
||||||
afterEach,
|
*/
|
||||||
beforeEach,
|
|
||||||
describe,
|
|
||||||
expect,
|
|
||||||
it,
|
|
||||||
vi,
|
|
||||||
} from 'vitest';
|
|
||||||
import { AppliedFontsManager } from './appliedFontsStore.svelte';
|
import { AppliedFontsManager } from './appliedFontsStore.svelte';
|
||||||
import { FontEvictionPolicy } from './fontEvictionPolicy/FontEvictionPolicy';
|
import { FontFetchError } from './errors';
|
||||||
|
import { FontEvictionPolicy } from './utils/fontEvictionPolicy/FontEvictionPolicy';
|
||||||
|
|
||||||
class FakeBufferCache {
|
class FakeBufferCache {
|
||||||
async get(_url: string): Promise<ArrayBuffer> {
|
async get(_url: string): Promise<ArrayBuffer> {
|
||||||
@@ -18,29 +13,34 @@ class FakeBufferCache {
|
|||||||
clear(): void {}
|
clear(): void {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Throws {@link FontFetchError} on every `get()` — simulates network/HTTP failure.
|
||||||
|
*/
|
||||||
|
class FailingBufferCache {
|
||||||
|
async get(url: string): Promise<never> {
|
||||||
|
throw new FontFetchError(url, new Error('network error'), 500);
|
||||||
|
}
|
||||||
|
evict(_url: string): void {}
|
||||||
|
clear(): void {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const makeConfig = (id: string, overrides: Partial<{ weight: number; isVariable: boolean }> = {}) => ({
|
||||||
|
id,
|
||||||
|
name: id,
|
||||||
|
url: `https://example.com/${id}.woff2`,
|
||||||
|
weight: 400,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
describe('AppliedFontsManager', () => {
|
describe('AppliedFontsManager', () => {
|
||||||
let manager: AppliedFontsManager;
|
let manager: AppliedFontsManager;
|
||||||
let mockFontFaceSet: any;
|
let eviction: FontEvictionPolicy;
|
||||||
let fakeEviction: FontEvictionPolicy;
|
let mockFontFaceSet: { add: ReturnType<typeof vi.fn>; delete: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
fakeEviction = new FontEvictionPolicy({ ttl: 60000 });
|
eviction = new FontEvictionPolicy({ ttl: 60000 });
|
||||||
|
mockFontFaceSet = { add: vi.fn(), delete: vi.fn() };
|
||||||
mockFontFaceSet = {
|
|
||||||
add: vi.fn(),
|
|
||||||
delete: vi.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const MockFontFace = vi.fn(function(this: any, name: string, bufferOrUrl: ArrayBuffer | string) {
|
|
||||||
this.name = name;
|
|
||||||
this.bufferOrUrl = bufferOrUrl;
|
|
||||||
this.load = vi.fn().mockImplementation(() => {
|
|
||||||
return Promise.resolve(this);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.stubGlobal('FontFace', MockFontFace);
|
|
||||||
|
|
||||||
Object.defineProperty(document, 'fonts', {
|
Object.defineProperty(document, 'fonts', {
|
||||||
value: mockFontFaceSet,
|
value: mockFontFaceSet,
|
||||||
@@ -48,11 +48,14 @@ describe('AppliedFontsManager', () => {
|
|||||||
writable: true,
|
writable: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.stubGlobal('crypto', {
|
const MockFontFace = vi.fn(function(this: any, name: string, buffer: BufferSource) {
|
||||||
randomUUID: () => '11111111-1111-1111-1111-111111111111' as any,
|
this.name = name;
|
||||||
|
this.buffer = buffer;
|
||||||
|
this.load = vi.fn().mockResolvedValue(this);
|
||||||
});
|
});
|
||||||
|
vi.stubGlobal('FontFace', MockFontFace);
|
||||||
|
|
||||||
manager = new AppliedFontsManager({ cache: new FakeBufferCache() as any, eviction: fakeEviction });
|
manager = new AppliedFontsManager({ cache: new FakeBufferCache() as any, eviction });
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -61,26 +64,177 @@ describe('AppliedFontsManager', () => {
|
|||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should batch multiple font requests into a single process', async () => {
|
describe('touch()', () => {
|
||||||
const configs = [
|
it('queues and loads a new font', async () => {
|
||||||
{ id: 'lato-400', name: 'Lato', url: 'https://example.com/lato.ttf', weight: 400 },
|
manager.touch([makeConfig('roboto')]);
|
||||||
{ id: 'lato-700', name: 'Lato', url: 'https://example.com/lato-bold.ttf', weight: 700 },
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
];
|
|
||||||
|
expect(manager.getFontStatus('roboto', 400)).toBe('loaded');
|
||||||
manager.touch(configs);
|
});
|
||||||
|
|
||||||
|
it('batches multiple fonts into a single queue flush', async () => {
|
||||||
|
manager.touch([makeConfig('lato'), makeConfig('inter')]);
|
||||||
await vi.advanceTimersByTimeAsync(50);
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
|
||||||
expect(manager.getFontStatus('lato-400', 400)).toBe('loaded');
|
|
||||||
expect(mockFontFaceSet.add).toHaveBeenCalledTimes(2);
|
expect(mockFontFaceSet.add).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should purge fonts after TTL expires', async () => {
|
it('skips fonts that are already loaded', async () => {
|
||||||
const config = { id: 'ephemeral', name: 'Temp', url: 'https://example.com/temp.ttf', weight: 400 };
|
manager.touch([makeConfig('lato')]);
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
manager.touch([config]);
|
|
||||||
|
manager.touch([makeConfig('lato')]);
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
|
||||||
|
expect(mockFontFaceSet.add).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips fonts that are currently loading', async () => {
|
||||||
|
manager.touch([makeConfig('lato')]);
|
||||||
|
// simulate loading state before queue drains
|
||||||
|
manager.statuses.set('lato@400', 'loading');
|
||||||
|
manager.touch([makeConfig('lato')]);
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
|
||||||
|
expect(mockFontFaceSet.add).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips fonts that have exhausted retries', async () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
const failManager = new AppliedFontsManager({ cache: new FailingBufferCache() as any, eviction });
|
||||||
|
|
||||||
|
// exhaust all 3 retries
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
failManager.statuses.delete('broken@400');
|
||||||
|
failManager.touch([makeConfig('broken')]);
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
}
|
||||||
|
|
||||||
|
failManager.touch([makeConfig('broken')]);
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
|
||||||
|
expect(failManager.getFontStatus('broken', 400)).toBe('error');
|
||||||
|
expect(mockFontFaceSet.add).not.toHaveBeenCalled();
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing after manager is destroyed', async () => {
|
||||||
|
manager.destroy();
|
||||||
|
manager.touch([makeConfig('roboto')]);
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
|
||||||
|
expect(manager.statuses.size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('queue processing', () => {
|
||||||
|
it('filters non-critical weights in data-saver mode', async () => {
|
||||||
|
(navigator as any).connection = { saveData: true };
|
||||||
|
|
||||||
|
manager.touch([
|
||||||
|
makeConfig('light', { weight: 300 }),
|
||||||
|
makeConfig('regular', { weight: 400 }),
|
||||||
|
makeConfig('bold', { weight: 700 }),
|
||||||
|
]);
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
|
||||||
|
expect(manager.getFontStatus('light', 300)).toBeUndefined();
|
||||||
|
expect(manager.getFontStatus('regular', 400)).toBe('loaded');
|
||||||
|
expect(manager.getFontStatus('bold', 700)).toBe('loaded');
|
||||||
|
|
||||||
|
delete (navigator as any).connection;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads variable fonts in data-saver mode regardless of weight', async () => {
|
||||||
|
(navigator as any).connection = { saveData: true };
|
||||||
|
|
||||||
|
manager.touch([makeConfig('vf', { weight: 300, isVariable: true })]);
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
|
||||||
|
expect(manager.getFontStatus('vf', 300, true)).toBe('loaded');
|
||||||
|
|
||||||
|
delete (navigator as any).connection;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Phase 1 — fetch', () => {
|
||||||
|
it('sets status to error on fetch failure', async () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
const failManager = new AppliedFontsManager({ cache: new FailingBufferCache() as any, eviction });
|
||||||
|
|
||||||
|
failManager.touch([makeConfig('broken')]);
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
|
||||||
|
expect(failManager.getFontStatus('broken', 400)).toBe('error');
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs a console error on fetch failure', async () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
const failManager = new AppliedFontsManager({ cache: new FailingBufferCache() as any, eviction });
|
||||||
|
|
||||||
|
failManager.touch([makeConfig('broken')]);
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
|
||||||
|
expect(consoleSpy).toHaveBeenCalled();
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not set error status or log for aborted fetches', async () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
const abortingCache = {
|
||||||
|
async get(url: string): Promise<never> {
|
||||||
|
throw new FontFetchError(url, Object.assign(new Error('Aborted'), { name: 'AbortError' }));
|
||||||
|
},
|
||||||
|
evict() {},
|
||||||
|
clear() {},
|
||||||
|
};
|
||||||
|
const abortManager = new AppliedFontsManager({ cache: abortingCache as any, eviction });
|
||||||
|
|
||||||
|
abortManager.touch([makeConfig('aborted')]);
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
|
||||||
|
// status is left as 'loading' (not 'error') — abort is not a retriable failure
|
||||||
|
expect(abortManager.getFontStatus('aborted', 400)).not.toBe('error');
|
||||||
|
expect(consoleSpy).not.toHaveBeenCalled();
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Phase 2 — parse', () => {
|
||||||
|
it('sets status to error on parse failure', async () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
const FailingFontFace = vi.fn(function(this: any) {
|
||||||
|
this.load = vi.fn().mockRejectedValue(new Error('parse failed'));
|
||||||
|
});
|
||||||
|
vi.stubGlobal('FontFace', FailingFontFace);
|
||||||
|
|
||||||
|
manager.touch([makeConfig('broken')]);
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
|
||||||
|
expect(manager.getFontStatus('broken', 400)).toBe('error');
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs a console error on parse failure', async () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
const FailingFontFace = vi.fn(function(this: any) {
|
||||||
|
this.load = vi.fn().mockRejectedValue(new Error('parse failed'));
|
||||||
|
});
|
||||||
|
vi.stubGlobal('FontFace', FailingFontFace);
|
||||||
|
|
||||||
|
manager.touch([makeConfig('broken')]);
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
|
||||||
|
expect(consoleSpy).toHaveBeenCalled();
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('#purgeUnused', () => {
|
||||||
|
it('evicts fonts after TTL expires', async () => {
|
||||||
|
manager.touch([makeConfig('ephemeral')]);
|
||||||
await vi.advanceTimersByTimeAsync(50);
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
expect(manager.getFontStatus('ephemeral', 400)).toBe('loaded');
|
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(61000);
|
await vi.advanceTimersByTimeAsync(61000);
|
||||||
|
|
||||||
@@ -88,81 +242,77 @@ describe('AppliedFontsManager', () => {
|
|||||||
expect(mockFontFaceSet.delete).toHaveBeenCalled();
|
expect(mockFontFaceSet.delete).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should NOT purge fonts that are still being "touched"', async () => {
|
it('removes the evicted key from the eviction policy', async () => {
|
||||||
const config = { id: 'active', name: 'Active', url: 'https://example.com/active.ttf', weight: 400 };
|
manager.touch([makeConfig('ephemeral')]);
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(61000);
|
||||||
|
|
||||||
|
expect(Array.from(eviction.keys())).not.toContain('ephemeral@400');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refreshes TTL when font is re-touched before expiry', async () => {
|
||||||
|
const config = makeConfig('active');
|
||||||
manager.touch([config]);
|
manager.touch([config]);
|
||||||
await vi.advanceTimersByTimeAsync(50);
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(40000);
|
await vi.advanceTimersByTimeAsync(40000);
|
||||||
|
manager.touch([config]); // refresh at t≈40s
|
||||||
|
|
||||||
manager.touch([config]);
|
await vi.advanceTimersByTimeAsync(25000); // purge at t≈60s sees only ~20s elapsed → not evicted
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(20000);
|
|
||||||
|
|
||||||
expect(manager.getFontStatus('active', 400)).toBe('loaded');
|
expect(manager.getFontStatus('active', 400)).toBe('loaded');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should serve buffer from memory without calling fetch again', async () => {
|
it('does not evict pinned fonts', async () => {
|
||||||
const config = { id: 'cached', name: 'Cached', url: 'https://example.com/cached.ttf', weight: 400 };
|
manager.touch([makeConfig('pinned')]);
|
||||||
|
|
||||||
manager.touch([config]);
|
|
||||||
await vi.advanceTimersByTimeAsync(50);
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
expect(manager.getFontStatus('cached', 400)).toBe('loaded');
|
|
||||||
|
|
||||||
manager.statuses.delete('cached@400');
|
|
||||||
|
|
||||||
manager.touch([config]);
|
|
||||||
await vi.advanceTimersByTimeAsync(50);
|
|
||||||
|
|
||||||
expect(manager.getFontStatus('cached', 400)).toBe('loaded');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should NOT purge a pinned font after TTL expires', async () => {
|
|
||||||
const config = { id: 'pinned', name: 'Pinned', url: 'https://example.com/pinned.ttf', weight: 400 };
|
|
||||||
|
|
||||||
manager.touch([config]);
|
|
||||||
await vi.advanceTimersByTimeAsync(50);
|
|
||||||
expect(manager.getFontStatus('pinned', 400)).toBe('loaded');
|
|
||||||
|
|
||||||
manager.pin('pinned', 400);
|
manager.pin('pinned', 400);
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(61000);
|
await vi.advanceTimersByTimeAsync(61000);
|
||||||
|
|
||||||
expect(manager.getFontStatus('pinned', 400)).toBe('loaded');
|
expect(manager.getFontStatus('pinned', 400)).toBe('loaded');
|
||||||
expect(mockFontFaceSet.delete).not.toHaveBeenCalled();
|
expect(mockFontFaceSet.delete).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should evict a font after it is unpinned and TTL expires', async () => {
|
it('evicts font after it is unpinned and TTL expires', async () => {
|
||||||
const config = { id: 'unpinned', name: 'Unpinned', url: 'https://example.com/unpinned.ttf', weight: 400 };
|
manager.touch([makeConfig('toggled')]);
|
||||||
|
|
||||||
manager.touch([config]);
|
|
||||||
await vi.advanceTimersByTimeAsync(50);
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
expect(manager.getFontStatus('unpinned', 400)).toBe('loaded');
|
|
||||||
|
|
||||||
manager.pin('unpinned', 400);
|
|
||||||
manager.unpin('unpinned', 400);
|
|
||||||
|
|
||||||
|
manager.pin('toggled', 400);
|
||||||
|
manager.unpin('toggled', 400);
|
||||||
await vi.advanceTimersByTimeAsync(61000);
|
await vi.advanceTimersByTimeAsync(61000);
|
||||||
|
|
||||||
expect(manager.getFontStatus('unpinned', 400)).toBeUndefined();
|
expect(manager.getFontStatus('toggled', 400)).toBeUndefined();
|
||||||
expect(mockFontFaceSet.delete).toHaveBeenCalled();
|
expect(mockFontFaceSet.delete).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('should clear pinned set on destroy without errors', async () => {
|
describe('destroy()', () => {
|
||||||
const config = {
|
it('clears all statuses', async () => {
|
||||||
id: 'destroy-pin',
|
manager.touch([makeConfig('roboto')]);
|
||||||
name: 'DestroyPin',
|
|
||||||
url: 'https://example.com/destroypin.ttf',
|
|
||||||
weight: 400,
|
|
||||||
};
|
|
||||||
|
|
||||||
manager.touch([config]);
|
|
||||||
await vi.advanceTimersByTimeAsync(50);
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
|
||||||
manager.pin('destroy-pin', 400);
|
|
||||||
manager.destroy();
|
manager.destroy();
|
||||||
|
|
||||||
expect(manager.statuses.size).toBe(0);
|
expect(manager.statuses.size).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('removes all loaded fonts from document.fonts', async () => {
|
||||||
|
manager.touch([makeConfig('roboto'), makeConfig('inter')]);
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
|
||||||
|
manager.destroy();
|
||||||
|
|
||||||
|
expect(mockFontFaceSet.delete).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prevents further loading after destroy', async () => {
|
||||||
|
manager.destroy();
|
||||||
|
manager.touch([makeConfig('roboto')]);
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
|
||||||
|
expect(manager.statuses.size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -131,9 +131,19 @@ export class AppliedFontsManager {
|
|||||||
hasNewItems = true;
|
hasNewItems = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Schedule queue processing if we have new items and no existing timer
|
|
||||||
if (hasNewItems && !this.#timeoutId) {
|
if (hasNewItems && !this.#timeoutId) {
|
||||||
// Prefer requestIdleCallback for better performance (waits for browser idle)
|
this.#scheduleProcessing();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedules `#processQueue()` via `requestIdleCallback` (150ms timeout) when available,
|
||||||
|
* falling back to `setTimeout(16ms)` for ~60fps timing.
|
||||||
|
*/
|
||||||
|
#scheduleProcessing(): void {
|
||||||
if (typeof requestIdleCallback !== 'undefined') {
|
if (typeof requestIdleCallback !== 'undefined') {
|
||||||
this.#timeoutId = requestIdleCallback(
|
this.#timeoutId = requestIdleCallback(
|
||||||
() => this.#processQueue(),
|
() => this.#processQueue(),
|
||||||
@@ -141,17 +151,14 @@ export class AppliedFontsManager {
|
|||||||
) as unknown as ReturnType<typeof setTimeout>;
|
) as unknown as ReturnType<typeof setTimeout>;
|
||||||
this.#pendingType = 'idle';
|
this.#pendingType = 'idle';
|
||||||
} else {
|
} else {
|
||||||
// Fallback to setTimeout with ~60fps timing
|
|
||||||
this.#timeoutId = setTimeout(() => this.#processQueue(), 16);
|
this.#timeoutId = setTimeout(() => this.#processQueue(), 16);
|
||||||
this.#pendingType = 'timeout';
|
this.#pendingType = 'timeout';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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;
|
||||||
}
|
}
|
||||||
@@ -183,35 +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) {
|
||||||
// Process in chunks based on concurrency limit
|
await this.#fetchChunk(entries.slice(i, i + concurrency), buffers);
|
||||||
const chunk = entries.slice(i, i + concurrency);
|
|
||||||
const results = await Promise.allSettled(
|
|
||||||
chunk.map(async ([key, config]) => {
|
|
||||||
this.statuses.set(key, 'loading');
|
|
||||||
// Fetch buffer via cache (checks memory → Cache API → network)
|
|
||||||
const buffer = await this.#cache.get(config.url, this.#abortController.signal);
|
|
||||||
buffers.set(key, buffer);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Handle fetch errors - set status and increment retry count
|
|
||||||
for (let j = 0; j < results.length; j++) {
|
|
||||||
if (results[j].status === 'rejected') {
|
|
||||||
const [key, config] = chunk[j];
|
|
||||||
const reason = (results[j] as PromiseRejectedResult).reason;
|
|
||||||
if (reason instanceof FontFetchError) {
|
|
||||||
console.error(`Font fetch failed: ${config.name}`, reason);
|
|
||||||
}
|
|
||||||
this.statuses.set(key, 'error');
|
|
||||||
this.#queue.incrementRetry(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 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();
|
||||||
@@ -224,19 +207,7 @@ export class AppliedFontsManager {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
await this.#processFont(key, config, buffer);
|
||||||
// Parse buffer into FontFace and register with document
|
|
||||||
const font = await loadFont(config, buffer);
|
|
||||||
this.#loadedFonts.set(key, font);
|
|
||||||
this.#urlByKey.set(key, config.url);
|
|
||||||
this.statuses.set(key, 'loaded');
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof FontParseError) {
|
|
||||||
console.error(`Font parse failed: ${config.name}`, e);
|
|
||||||
this.statuses.set(key, 'error');
|
|
||||||
this.#queue.incrementRetry(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Yield to main thread if needed (prevents UI blocking)
|
// Yield to main thread if needed (prevents UI blocking)
|
||||||
// Chromium: use isInputPending() for optimal responsiveness
|
// Chromium: use isInputPending() for optimal responsiveness
|
||||||
@@ -252,7 +223,69 @@ export class AppliedFontsManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Removes fonts unused within TTL (LRU-style cleanup). Runs every PURGE_INTERVAL. Pinned fonts are never evicted. */
|
/**
|
||||||
|
* Fetches a chunk of fonts concurrently and populates `buffers` with successful results.
|
||||||
|
* Each promise carries its own key and config so results need no index correlation.
|
||||||
|
* Aborted fetches are silently skipped; other errors set status to `'error'` and increment retry.
|
||||||
|
*/
|
||||||
|
async #fetchChunk(
|
||||||
|
chunk: Array<[string, FontLoadRequestConfig]>,
|
||||||
|
buffers: Map<string, ArrayBuffer>,
|
||||||
|
): Promise<void> {
|
||||||
|
const results = await Promise.all(
|
||||||
|
chunk.map(async ([key, config]) => {
|
||||||
|
this.statuses.set(key, 'loading');
|
||||||
|
try {
|
||||||
|
const buffer = await this.#cache.get(config.url, this.#abortController.signal);
|
||||||
|
buffers.set(key, buffer);
|
||||||
|
return { ok: true as const, key };
|
||||||
|
} catch (reason) {
|
||||||
|
return { ok: false as const, key, config, reason };
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const result of results) {
|
||||||
|
if (result.ok) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const { key, config, reason } = result;
|
||||||
|
const isAbort = reason instanceof FontFetchError
|
||||||
|
&& reason.cause instanceof Error
|
||||||
|
&& reason.cause.name === 'AbortError';
|
||||||
|
if (isAbort) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (reason instanceof FontFetchError) {
|
||||||
|
console.error(`Font fetch failed: ${config.name}`, reason);
|
||||||
|
}
|
||||||
|
this.statuses.set(key, 'error');
|
||||||
|
this.#queue.incrementRetry(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a fetched buffer into a {@link FontFace}, registers it with `document.fonts`,
|
||||||
|
* and updates reactive status. On failure, sets status to `'error'` and increments the retry count.
|
||||||
|
*/
|
||||||
|
async #processFont(key: string, config: FontLoadRequestConfig, buffer: ArrayBuffer): Promise<void> {
|
||||||
|
try {
|
||||||
|
const font = await loadFont(config, buffer);
|
||||||
|
this.#loadedFonts.set(key, font);
|
||||||
|
this.#urlByKey.set(key, config.url);
|
||||||
|
this.statuses.set(key, 'loaded');
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof FontParseError) {
|
||||||
|
console.error(`Font parse failed: ${config.name}`, e);
|
||||||
|
this.statuses.set(key, 'error');
|
||||||
|
this.#queue.incrementRetry(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes fonts unused within TTL (LRU-style cleanup). Runs every PURGE_INTERVAL. Pinned fonts are never evicted.
|
||||||
|
*/
|
||||||
#purgeUnused() {
|
#purgeUnused() {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
// Iterate through all tracked font keys
|
// Iterate through all tracked font keys
|
||||||
@@ -264,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);
|
||||||
@@ -276,10 +311,13 @@ export class AppliedFontsManager {
|
|||||||
// Clean up remaining state
|
// Clean up remaining state
|
||||||
this.#loadedFonts.delete(key);
|
this.#loadedFonts.delete(key);
|
||||||
this.statuses.delete(key);
|
this.statuses.delete(key);
|
||||||
|
this.#eviction.remove(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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 }));
|
||||||
@@ -288,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;
|
||||||
@@ -308,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();
|
||||||
@@ -347,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();
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -15,7 +17,7 @@ const makeFetcher = (overrides: Partial<Response> = {}) =>
|
|||||||
|
|
||||||
describe('FontBufferCache', () => {
|
describe('FontBufferCache', () => {
|
||||||
let cache: FontBufferCache;
|
let cache: FontBufferCache;
|
||||||
let fetcher: ReturnType<typeof vi.fn>;
|
let fetcher: ReturnType<typeof makeFetcher>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
fetcher = makeFetcher();
|
fetcher = makeFetcher();
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
@@ -42,6 +42,23 @@ describe('FontEvictionPolicy', () => {
|
|||||||
expect(Array.from(policy.keys())).toEqual(expect.arrayContaining(['a@400', 'b@vf']));
|
expect(Array.from(policy.keys())).toEqual(expect.arrayContaining(['a@400', 'b@vf']));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('remove deletes key from tracking so it no longer appears in keys()', () => {
|
||||||
|
policy.touch('a@400', t0);
|
||||||
|
policy.touch('b@vf', t0);
|
||||||
|
policy.remove('a@400');
|
||||||
|
expect(Array.from(policy.keys())).not.toContain('a@400');
|
||||||
|
expect(Array.from(policy.keys())).toContain('b@vf');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('remove unpins the key so a subsequent touch + TTL would evict it', () => {
|
||||||
|
policy.touch('a@400', t0);
|
||||||
|
policy.pin('a@400');
|
||||||
|
policy.remove('a@400');
|
||||||
|
// re-touch and check it can be evicted again
|
||||||
|
policy.touch('a@400', t0);
|
||||||
|
expect(policy.shouldEvict('a@400', t0 + TTL)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('clear resets all state', () => {
|
it('clear resets all state', () => {
|
||||||
policy.touch('a@400', t0);
|
policy.touch('a@400', t0);
|
||||||
policy.pin('a@400');
|
policy.pin('a@400');
|
||||||
@@ -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,17 +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();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Clears all usage timestamps and pinned keys. */
|
/**
|
||||||
|
* Removes a font key from tracking. Called by the orchestrator after eviction.
|
||||||
|
*/
|
||||||
|
remove(key: string): void {
|
||||||
|
this.#usageTracker.delete(key);
|
||||||
|
this.#pinnedFonts.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears all usage timestamps and pinned keys.
|
||||||
|
*/
|
||||||
clear(): void {
|
clear(): void {
|
||||||
this.#usageTracker.clear();
|
this.#usageTracker.clear();
|
||||||
this.#pinnedFonts.clear();
|
this.#pinnedFonts.clear();
|
||||||
@@ -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 => ({
|
||||||
@@ -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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
93
src/entities/Font/model/store/batchFontStore.svelte.ts
Normal file
93
src/entities/Font/model/store/batchFontStore.svelte.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
107
src/entities/Font/model/store/batchFontStore.test.ts
Normal file
107
src/entities/Font/model/store/batchFontStore.test.ts
Normal file
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
564
src/entities/Font/model/store/fontStore/fontStore.svelte.spec.ts
Normal file
564
src/entities/Font/model/store/fontStore/fontStore.svelte.spec.ts
Normal file
@@ -0,0 +1,564 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
364
src/entities/Font/model/store/fontStore/fontStore.svelte.ts
Normal file
364
src/entities/Font/model/store/fontStore/fontStore.svelte.ts
Normal file
@@ -0,0 +1,364 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 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');
|
||||||
|
}
|
||||||
|
|
||||||
|
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';
|
|
||||||
227
src/entities/Font/model/types/font.ts
Normal file
227
src/entities/Font/model/types/font.ts
Normal file
@@ -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';
|
||||||
}
|
}
|
||||||
|
|||||||
1
src/entities/Font/model/types/typography.ts
Normal file
1
src/entities/Font/model/types/typography.ts
Normal file
@@ -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>
|
||||||
@@ -6,10 +6,11 @@
|
|||||||
- Adds smooth transition when font appears
|
- 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 { prefersReducedMotion } from 'svelte/motion';
|
||||||
import {
|
import {
|
||||||
|
DEFAULT_FONT_WEIGHT,
|
||||||
type UnifiedFont,
|
type UnifiedFont,
|
||||||
appliedFontsManager,
|
appliedFontsManager,
|
||||||
} from '../../model';
|
} from '../../model';
|
||||||
@@ -36,7 +37,7 @@ interface Props {
|
|||||||
|
|
||||||
let {
|
let {
|
||||||
font,
|
font,
|
||||||
weight = 400,
|
weight = DEFAULT_FONT_WEIGHT,
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
@@ -63,7 +64,7 @@ const transitionClasses = $derived(
|
|||||||
style:font-family={shouldReveal
|
style:font-family={shouldReveal
|
||||||
? `'${font.name}'`
|
? `'${font.name}'`
|
||||||
: 'system-ui, -apple-system, sans-serif'}
|
: 'system-ui, -apple-system, sans-serif'}
|
||||||
class={cn(
|
class={clsx(
|
||||||
transitionClasses,
|
transitionClasses,
|
||||||
// If reduced motion is on, we skip the transform/blur entirely
|
// If reduced motion is on, we skip the transform/blur entirely
|
||||||
!shouldReveal
|
!shouldReveal
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -18,7 +18,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 +50,58 @@ 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[] = [];
|
|
||||||
|
|
||||||
visibleItems.forEach(item => {
|
|
||||||
const url = getFontUrl(item, weight);
|
|
||||||
|
|
||||||
if (url) {
|
|
||||||
configs.push({
|
|
||||||
id: item.id,
|
|
||||||
name: item.name,
|
|
||||||
weight,
|
|
||||||
url,
|
|
||||||
isVariable: item.features?.isVariable,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Auto-register fonts with the manager
|
|
||||||
appliedFontsManager.touch(configs);
|
|
||||||
|
|
||||||
|
function handleInternalVisibleChange(items: UnifiedFont[]) {
|
||||||
|
visibleFonts = items;
|
||||||
// Forward the call to any external listener
|
// Forward the call to any external listener
|
||||||
// onVisibleItemsChange?.(visibleItems);
|
onVisibleItemsChange?.(items);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-touch whenever visible set or weight changes — fixes weight-change gap
|
||||||
|
$effect(() => {
|
||||||
|
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) {
|
||||||
|
appliedFontsManager.touch(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,17 +111,17 @@ 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) {
|
if (hasMore && !fontStore.isFetching) {
|
||||||
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 skeleton && isLoading && fontStore.fonts.length === 0}
|
||||||
<!-- 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 transition:fade={{ duration: 300 }}>
|
||||||
{@render skeleton()}
|
{@render skeleton()}
|
||||||
@@ -115,8 +129,8 @@ function handleNearBottom(_lastVisibleIndex: number) {
|
|||||||
{: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}
|
||||||
onVisibleItemsChange={handleInternalVisibleChange}
|
onVisibleItemsChange={handleInternalVisibleChange}
|
||||||
onNearBottom={handleNearBottom}
|
onNearBottom={handleNearBottom}
|
||||||
|
|||||||
@@ -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[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>[];
|
||||||
}
|
}
|
||||||
|
|||||||
26
src/features/GetFonts/ui/Filters/Filters.stories.svelte
Normal file
26
src/features/GetFonts/ui/Filters/Filters.stories.svelte
Normal file
@@ -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>
|
||||||
63
src/features/GetFonts/ui/Filters/Filters.svelte.test.ts
Normal file
63
src/features/GetFonts/ui/Filters/Filters.svelte.test.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { filterManager } from '$features/GetFonts';
|
||||||
|
import {
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
} from '@testing-library/svelte';
|
||||||
|
import Filters from './Filters.svelte';
|
||||||
|
|
||||||
|
describe('Filters', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
filterManager.setGroups([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Rendering', () => {
|
||||||
|
it('renders nothing when filter groups are empty', () => {
|
||||||
|
const { container } = render(Filters);
|
||||||
|
expect(container.firstElementChild).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a label for each filter group', () => {
|
||||||
|
filterManager.setGroups([
|
||||||
|
{ id: 'cat', label: 'Category', properties: [] },
|
||||||
|
{ id: 'prov', label: 'Provider', properties: [] },
|
||||||
|
]);
|
||||||
|
render(Filters);
|
||||||
|
expect(screen.getByText('Category')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Provider')).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';
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
@@ -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(
|
|
||||||
'inline-flex items-center justify-center',
|
|
||||||
'size-8 p-0',
|
|
||||||
'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" />
|
<Settings2Icon class="size-4" />
|
||||||
</button>
|
{/snippet}
|
||||||
|
</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>
|
||||||
@@ -18,8 +15,4 @@ import { fade } from 'svelte/transition';
|
|||||||
<section class="w-auto">
|
<section class="w-auto">
|
||||||
<ComparisonView />
|
<ComparisonView />
|
||||||
</section>
|
</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>
|
|
||||||
</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
|
||||||
|
|||||||
73
src/shared/api/queryKeys.test.ts
Normal file
73
src/shared/api/queryKeys.test.ts
Normal file
@@ -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());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
37
src/shared/api/queryKeys.ts
Normal file
37
src/shared/api/queryKeys.ts
Normal file
@@ -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;
|
||||||
51
src/shared/lib/helpers/BaseQueryStore.svelte.ts
Normal file
51
src/shared/lib/helpers/BaseQueryStore.svelte.ts
Normal file
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
91
src/shared/lib/helpers/BaseQueryStore.test.ts
Normal file
91
src/shared/lib/helpers/BaseQueryStore.test.ts
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
import {
|
||||||
|
type PreparedTextWithSegments,
|
||||||
|
layoutWithLines,
|
||||||
|
prepareWithSegments,
|
||||||
|
} from '@chenglou/pretext';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single laid-out line produced by dual-font comparison layout.
|
||||||
|
*
|
||||||
|
* Line breaking is determined by the unified worst-case widths, so both fonts
|
||||||
|
* always break at identical positions. Per-character `xA`/`xB` offsets reflect
|
||||||
|
* each font's actual advance widths independently.
|
||||||
|
*/
|
||||||
|
export interface ComparisonLine {
|
||||||
|
/**
|
||||||
|
* Full text of this line as returned by pretext.
|
||||||
|
*/
|
||||||
|
text: string;
|
||||||
|
/**
|
||||||
|
* Rendered width of this line in pixels — maximum across font A and font B.
|
||||||
|
*/
|
||||||
|
width: number;
|
||||||
|
/**
|
||||||
|
* Individual character metadata for both fonts in this line
|
||||||
|
*/
|
||||||
|
chars: Array<{
|
||||||
|
/**
|
||||||
|
* The grapheme cluster string (may be >1 code unit for emoji, etc.).
|
||||||
|
*/
|
||||||
|
char: string;
|
||||||
|
/**
|
||||||
|
* X offset from the start of the line in font A, in pixels.
|
||||||
|
*/
|
||||||
|
xA: number;
|
||||||
|
/**
|
||||||
|
* Advance width of this grapheme in font A, in pixels.
|
||||||
|
*/
|
||||||
|
widthA: number;
|
||||||
|
/**
|
||||||
|
* X offset from the start of the line in font B, in pixels.
|
||||||
|
*/
|
||||||
|
xB: number;
|
||||||
|
/**
|
||||||
|
* Advance width of this grapheme in font B, in pixels.
|
||||||
|
*/
|
||||||
|
widthB: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggregated output of a dual-font layout pass.
|
||||||
|
*/
|
||||||
|
export interface ComparisonResult {
|
||||||
|
/**
|
||||||
|
* Per-line grapheme data for both fonts. Empty when input text is empty.
|
||||||
|
*/
|
||||||
|
lines: ComparisonLine[];
|
||||||
|
/**
|
||||||
|
* Total height in pixels. Equals `lines.length * lineHeight` (pretext guarantee).
|
||||||
|
*/
|
||||||
|
totalHeight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dual-font text layout engine backed by `@chenglou/pretext`.
|
||||||
|
*
|
||||||
|
* Computes identical line breaks for two fonts simultaneously by constructing a
|
||||||
|
* "unified" prepared-text object whose per-glyph widths are the worst-case maximum
|
||||||
|
* of font A and font B. This guarantees that both fonts wrap at exactly the same
|
||||||
|
* positions, making side-by-side or slider comparison visually coherent.
|
||||||
|
*
|
||||||
|
* **Two-level caching strategy**
|
||||||
|
* 1. Font-change cache (`#preparedA`, `#preparedB`, `#unifiedPrepared`): rebuilt only
|
||||||
|
* when `text`, `fontA`, or `fontB` changes. `prepareWithSegments` is expensive
|
||||||
|
* (canvas measurement), so this avoids re-measuring during slider interaction.
|
||||||
|
* 2. Layout cache (`#lastResult`): rebuilt when `width` or `lineHeight` changes but
|
||||||
|
* the fonts have not changed. Line-breaking is cheap relative to measurement, but
|
||||||
|
* still worth skipping on every render tick.
|
||||||
|
*
|
||||||
|
* **`as any` casts:** `PreparedTextWithSegments` exposes only the `segments` field in
|
||||||
|
* its public TypeScript type. The numeric arrays (`widths`, `breakableFitAdvances`,
|
||||||
|
* `lineEndFitAdvances`, `lineEndPaintAdvances`) are internal implementation details of
|
||||||
|
* pretext that are not part of the published type signature. The casts are required to
|
||||||
|
* access these fields; they are verified against the pretext source at
|
||||||
|
* `node_modules/@chenglou/pretext/src/layout.ts`.
|
||||||
|
*/
|
||||||
|
export class CharacterComparisonEngine {
|
||||||
|
#segmenter: Intl.Segmenter;
|
||||||
|
|
||||||
|
// Cached prepared data
|
||||||
|
#preparedA: PreparedTextWithSegments | null = null;
|
||||||
|
#preparedB: PreparedTextWithSegments | null = null;
|
||||||
|
#unifiedPrepared: PreparedTextWithSegments | null = null;
|
||||||
|
|
||||||
|
#lastText = '';
|
||||||
|
#lastFontA = '';
|
||||||
|
#lastFontB = '';
|
||||||
|
|
||||||
|
// Cached layout results
|
||||||
|
#lastWidth = -1;
|
||||||
|
#lastLineHeight = -1;
|
||||||
|
#lastResult: ComparisonResult | null = null;
|
||||||
|
|
||||||
|
constructor(locale?: string) {
|
||||||
|
this.#segmenter = new Intl.Segmenter(locale, { granularity: 'grapheme' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lay out `text` using both fonts within `width` pixels.
|
||||||
|
*
|
||||||
|
* Line breaks are determined by the worst-case (maximum) glyph widths across
|
||||||
|
* both fonts, so both fonts always wrap at identical positions.
|
||||||
|
*
|
||||||
|
* @param text Raw text to lay out.
|
||||||
|
* @param fontA CSS font string for the first font: `"weight sizepx \"family\""`.
|
||||||
|
* @param fontB CSS font string for the second font: `"weight sizepx \"family\""`.
|
||||||
|
* @param width Available line width in pixels.
|
||||||
|
* @param lineHeight Line height in pixels (passed directly to pretext).
|
||||||
|
* @returns Per-line grapheme data for both fonts. Empty `lines` when `text` is empty.
|
||||||
|
*/
|
||||||
|
layout(
|
||||||
|
text: string,
|
||||||
|
fontA: string,
|
||||||
|
fontB: string,
|
||||||
|
width: number,
|
||||||
|
lineHeight: number,
|
||||||
|
): ComparisonResult {
|
||||||
|
if (!text) {
|
||||||
|
return { lines: [], totalHeight: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const isFontChange = text !== this.#lastText || fontA !== this.#lastFontA || fontB !== this.#lastFontB;
|
||||||
|
const isLayoutChange = width !== this.#lastWidth || lineHeight !== this.#lastLineHeight;
|
||||||
|
|
||||||
|
if (!isFontChange && !isLayoutChange && this.#lastResult) {
|
||||||
|
return this.#lastResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Prepare (or use cache)
|
||||||
|
if (isFontChange) {
|
||||||
|
this.#preparedA = prepareWithSegments(text, fontA);
|
||||||
|
this.#preparedB = prepareWithSegments(text, fontB);
|
||||||
|
this.#unifiedPrepared = this.#createUnifiedPrepared(this.#preparedA, this.#preparedB);
|
||||||
|
|
||||||
|
this.#lastText = text;
|
||||||
|
this.#lastFontA = fontA;
|
||||||
|
this.#lastFontB = fontB;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.#unifiedPrepared || !this.#preparedA || !this.#preparedB) {
|
||||||
|
return { lines: [], totalHeight: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Layout using the unified widths.
|
||||||
|
// `PreparedTextWithSegments` only exposes `segments` in its public type; cast to `any`
|
||||||
|
// so pretext's layoutWithLines can read the internal numeric arrays at runtime.
|
||||||
|
const { lines, height } = layoutWithLines(this.#unifiedPrepared as any, width, lineHeight);
|
||||||
|
|
||||||
|
// 3. Map results back to both fonts
|
||||||
|
const resultLines: ComparisonLine[] = lines.map(line => {
|
||||||
|
const chars: ComparisonLine['chars'] = [];
|
||||||
|
let currentXA = 0;
|
||||||
|
let currentXB = 0;
|
||||||
|
|
||||||
|
const start = line.start;
|
||||||
|
const end = line.end;
|
||||||
|
|
||||||
|
// Cast to `any`: accessing internal numeric arrays not in the public type signature.
|
||||||
|
const intA = this.#preparedA as any;
|
||||||
|
const intB = this.#preparedB as any;
|
||||||
|
|
||||||
|
for (let sIdx = start.segmentIndex; sIdx <= end.segmentIndex; sIdx++) {
|
||||||
|
const segmentText = this.#preparedA!.segments[sIdx];
|
||||||
|
if (segmentText === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PERFORMANCE: Reuse segmenter results if possible, but for now just optimize the loop
|
||||||
|
const graphemes = Array.from(this.#segmenter.segment(segmentText), s => s.segment);
|
||||||
|
|
||||||
|
const advA = intA.breakableFitAdvances[sIdx];
|
||||||
|
const advB = intB.breakableFitAdvances[sIdx];
|
||||||
|
|
||||||
|
const gStart = sIdx === start.segmentIndex ? start.graphemeIndex : 0;
|
||||||
|
const gEnd = sIdx === end.segmentIndex ? end.graphemeIndex : graphemes.length;
|
||||||
|
|
||||||
|
for (let gIdx = gStart; gIdx < gEnd; gIdx++) {
|
||||||
|
const char = graphemes[gIdx];
|
||||||
|
const wA = advA != null ? advA[gIdx]! : intA.widths[sIdx]!;
|
||||||
|
const wB = advB != null ? advB[gIdx]! : intB.widths[sIdx]!;
|
||||||
|
|
||||||
|
chars.push({
|
||||||
|
char,
|
||||||
|
xA: currentXA,
|
||||||
|
widthA: wA,
|
||||||
|
xB: currentXB,
|
||||||
|
widthB: wB,
|
||||||
|
});
|
||||||
|
currentXA += wA;
|
||||||
|
currentXB += wB;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: line.text,
|
||||||
|
width: line.width,
|
||||||
|
chars,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
this.#lastWidth = width;
|
||||||
|
this.#lastLineHeight = lineHeight;
|
||||||
|
this.#lastResult = {
|
||||||
|
lines: resultLines,
|
||||||
|
totalHeight: height,
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.#lastResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates character proximity and direction relative to a slider position.
|
||||||
|
*
|
||||||
|
* Uses the most recent `layout()` result — must be called after `layout()`.
|
||||||
|
* No DOM calls are made; all geometry is derived from cached layout data.
|
||||||
|
*
|
||||||
|
* @param lineIndex Zero-based index of the line within the last layout result.
|
||||||
|
* @param charIndex Zero-based index of the character within that line's `chars` array.
|
||||||
|
* @param sliderPos Current slider position as a percentage (0–100) of `containerWidth`.
|
||||||
|
* @param containerWidth Total container width in pixels, used to convert pixel offsets to %.
|
||||||
|
* @returns `proximity` in [0, 1] (1 = slider exactly over char center) and
|
||||||
|
* `isPast` (true when the slider has already passed the char center).
|
||||||
|
*/
|
||||||
|
getCharState(
|
||||||
|
lineIndex: number,
|
||||||
|
charIndex: number,
|
||||||
|
sliderPos: number,
|
||||||
|
containerWidth: number,
|
||||||
|
): { proximity: number; isPast: boolean } {
|
||||||
|
if (!this.#lastResult || !this.#lastResult.lines[lineIndex]) {
|
||||||
|
return { proximity: 0, isPast: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const line = this.#lastResult.lines[lineIndex];
|
||||||
|
const char = line.chars[charIndex];
|
||||||
|
|
||||||
|
if (!char) {
|
||||||
|
return { proximity: 0, isPast: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Center the comparison on the unified width
|
||||||
|
// In the UI, lines are centered. So we need to calculate the global X.
|
||||||
|
const lineXOffset = (containerWidth - line.width) / 2;
|
||||||
|
const charCenterX = lineXOffset + char.xA + (char.widthA / 2);
|
||||||
|
|
||||||
|
const charGlobalPercent = (charCenterX / containerWidth) * 100;
|
||||||
|
|
||||||
|
const distance = Math.abs(sliderPos - charGlobalPercent);
|
||||||
|
const range = 5;
|
||||||
|
const proximity = Math.max(0, 1 - distance / range);
|
||||||
|
const isPast = sliderPos > charGlobalPercent;
|
||||||
|
|
||||||
|
return { proximity, isPast };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal helper to merge two prepared texts into a "worst-case" unified version
|
||||||
|
*/
|
||||||
|
#createUnifiedPrepared(a: PreparedTextWithSegments, b: PreparedTextWithSegments): PreparedTextWithSegments {
|
||||||
|
// Cast to `any`: accessing internal numeric arrays not in the public type signature.
|
||||||
|
const intA = a as any;
|
||||||
|
const intB = b as any;
|
||||||
|
|
||||||
|
const unified = { ...intA };
|
||||||
|
|
||||||
|
unified.widths = intA.widths.map((w: number, i: number) => Math.max(w, intB.widths[i]));
|
||||||
|
unified.lineEndFitAdvances = intA.lineEndFitAdvances.map((w: number, i: number) =>
|
||||||
|
Math.max(w, intB.lineEndFitAdvances[i])
|
||||||
|
);
|
||||||
|
unified.lineEndPaintAdvances = intA.lineEndPaintAdvances.map((w: number, i: number) =>
|
||||||
|
Math.max(w, intB.lineEndPaintAdvances[i])
|
||||||
|
);
|
||||||
|
|
||||||
|
unified.breakableFitAdvances = intA.breakableFitAdvances.map((advA: number[] | null, i: number) => {
|
||||||
|
const advB = intB.breakableFitAdvances[i];
|
||||||
|
if (!advA && !advB) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!advA) {
|
||||||
|
return advB;
|
||||||
|
}
|
||||||
|
if (!advB) {
|
||||||
|
return advA;
|
||||||
|
}
|
||||||
|
|
||||||
|
return advA.map((w: number, j: number) => Math.max(w, advB[j]));
|
||||||
|
});
|
||||||
|
|
||||||
|
return unified;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { clearCache } from '@chenglou/pretext';
|
||||||
|
import {
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
} from 'vitest';
|
||||||
|
import { installCanvasMock } from '../__mocks__/canvas';
|
||||||
|
import { CharacterComparisonEngine } from './CharacterComparisonEngine.svelte';
|
||||||
|
|
||||||
|
// FontA: 10px per character. FontB: 15px per character.
|
||||||
|
// The mock dispatches on whether the font string contains 'FontA' or 'FontB'.
|
||||||
|
const FONT_A_WIDTH = 10;
|
||||||
|
const FONT_B_WIDTH = 15;
|
||||||
|
|
||||||
|
function fontWidthFactory(font: string, text: string): number {
|
||||||
|
const perChar = font.includes('FontA') ? FONT_A_WIDTH : FONT_B_WIDTH;
|
||||||
|
return text.length * perChar;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CharacterComparisonEngine', () => {
|
||||||
|
let engine: CharacterComparisonEngine;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
installCanvasMock(fontWidthFactory);
|
||||||
|
clearCache();
|
||||||
|
engine = new CharacterComparisonEngine();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty result for empty string', () => {
|
||||||
|
const result = engine.layout('', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
|
||||||
|
expect(result.lines).toHaveLength(0);
|
||||||
|
expect(result.totalHeight).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses worst-case width across both fonts to determine line breaks', () => {
|
||||||
|
// 'AB CD' — two 2-char words separated by a space.
|
||||||
|
// FontA: 'AB'=20px, 'CD'=20px. Both fit in 25px? No: 'AB CD' = 50px total.
|
||||||
|
// FontB: 'AB'=30px, 'CD'=30px. Width 35px forces wrap after 'AB '.
|
||||||
|
// Unified must use FontB widths — so it must wrap at the same place FontB wraps.
|
||||||
|
const result = engine.layout('AB CD', '400 16px "FontA"', '400 16px "FontB"', 35, 20);
|
||||||
|
expect(result.lines.length).toBeGreaterThan(1);
|
||||||
|
// First line text must not include both words.
|
||||||
|
expect(result.lines[0].text).not.toContain('CD');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('provides xA and xB offsets for both fonts on a single line', () => {
|
||||||
|
// 'ABC' fits in 500px for both fonts.
|
||||||
|
// FontA: A@0(w=10), B@10(w=10), C@20(w=10)
|
||||||
|
// FontB: A@0(w=15), B@15(w=15), C@30(w=15)
|
||||||
|
const result = engine.layout('ABC', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
|
||||||
|
const chars = result.lines[0].chars;
|
||||||
|
|
||||||
|
expect(chars).toHaveLength(3);
|
||||||
|
|
||||||
|
expect(chars[0].xA).toBe(0);
|
||||||
|
expect(chars[0].widthA).toBe(FONT_A_WIDTH);
|
||||||
|
expect(chars[0].xB).toBe(0);
|
||||||
|
expect(chars[0].widthB).toBe(FONT_B_WIDTH);
|
||||||
|
|
||||||
|
expect(chars[1].xA).toBe(FONT_A_WIDTH); // 10
|
||||||
|
expect(chars[1].widthA).toBe(FONT_A_WIDTH);
|
||||||
|
expect(chars[1].xB).toBe(FONT_B_WIDTH); // 15
|
||||||
|
expect(chars[1].widthB).toBe(FONT_B_WIDTH);
|
||||||
|
|
||||||
|
expect(chars[2].xA).toBe(FONT_A_WIDTH * 2); // 20
|
||||||
|
expect(chars[2].xB).toBe(FONT_B_WIDTH * 2); // 30
|
||||||
|
});
|
||||||
|
|
||||||
|
it('xA positions are monotonically increasing', () => {
|
||||||
|
const result = engine.layout('ABCDE', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
|
||||||
|
const chars = result.lines[0].chars;
|
||||||
|
for (let i = 1; i < chars.length; i++) {
|
||||||
|
expect(chars[i].xA).toBeGreaterThan(chars[i - 1].xA);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('xB positions are monotonically increasing', () => {
|
||||||
|
const result = engine.layout('ABCDE', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
|
||||||
|
const chars = result.lines[0].chars;
|
||||||
|
for (let i = 1; i < chars.length; i++) {
|
||||||
|
expect(chars[i].xB).toBeGreaterThan(chars[i - 1].xB);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns cached result when called again with same arguments', () => {
|
||||||
|
const r1 = engine.layout('ABC', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
|
||||||
|
const r2 = engine.layout('ABC', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
|
||||||
|
expect(r2).toBe(r1); // strict reference equality — same object
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-computes when text changes', () => {
|
||||||
|
const r1 = engine.layout('ABC', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
|
||||||
|
const r2 = engine.layout('DEF', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
|
||||||
|
expect(r2).not.toBe(r1);
|
||||||
|
expect(r2.lines[0].text).not.toBe(r1.lines[0].text);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-computes when width changes', () => {
|
||||||
|
const r1 = engine.layout('Hello World', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
|
||||||
|
const r2 = engine.layout('Hello World', '400 16px "FontA"', '400 16px "FontB"', 60, 20);
|
||||||
|
expect(r2).not.toBe(r1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-computes when fontA changes', () => {
|
||||||
|
const r1 = engine.layout('ABC', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
|
||||||
|
const r2 = engine.layout('ABC', '400 24px "FontA"', '400 16px "FontB"', 500, 20);
|
||||||
|
expect(r2).not.toBe(r1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getCharState returns proximity 1 when slider is exactly over char center', () => {
|
||||||
|
// 'A' only: FontA width=10. Container=500px. Line centered.
|
||||||
|
// lineXOffset = (500 - maxWidth) / 2. maxWidth = max(10, 15) = 15 (FontB is wider).
|
||||||
|
// charCenterX = lineXOffset + xA + widthA/2.
|
||||||
|
// Using xA=0, widthA=10: charCenterX = (500-15)/2 + 0 + 5 = 247.5 + 5 = 252.5
|
||||||
|
// charGlobalPercent = (252.5 / 500) * 100 = 50.5
|
||||||
|
// distance = |50.5 - 50.5| = 0 => proximity = 1
|
||||||
|
const containerWidth = 500;
|
||||||
|
engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', containerWidth, 20);
|
||||||
|
// Recalculate expected percent manually:
|
||||||
|
const lineWidth = Math.max(FONT_A_WIDTH, FONT_B_WIDTH); // 15 (unified worst-case)
|
||||||
|
const lineXOffset = (containerWidth - lineWidth) / 2;
|
||||||
|
const charCenterX = lineXOffset + 0 + FONT_A_WIDTH / 2;
|
||||||
|
const charPercent = (charCenterX / containerWidth) * 100;
|
||||||
|
|
||||||
|
const state = engine.getCharState(0, 0, charPercent, containerWidth);
|
||||||
|
expect(state.proximity).toBe(1);
|
||||||
|
expect(state.isPast).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getCharState returns proximity 0 when slider is far from char', () => {
|
||||||
|
engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
|
||||||
|
// Slider at 0%, char is near 50% — distance > 5 range => proximity = 0
|
||||||
|
const state = engine.getCharState(0, 0, 0, 500);
|
||||||
|
expect(state.proximity).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getCharState isPast is true when slider has passed char center', () => {
|
||||||
|
engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
|
||||||
|
const state = engine.getCharState(0, 0, 100, 500);
|
||||||
|
expect(state.isPast).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getCharState returns safe default for out-of-range lineIndex', () => {
|
||||||
|
engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
|
||||||
|
const state = engine.getCharState(99, 0, 50, 500);
|
||||||
|
expect(state.proximity).toBe(0);
|
||||||
|
expect(state.isPast).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getCharState returns safe default for out-of-range charIndex', () => {
|
||||||
|
engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
|
||||||
|
const state = engine.getCharState(0, 99, 50, 500);
|
||||||
|
expect(state.proximity).toBe(0);
|
||||||
|
expect(state.isPast).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getCharState returns safe default before layout() has been called', () => {
|
||||||
|
const state = engine.getCharState(0, 0, 50, 500);
|
||||||
|
expect(state.proximity).toBe(0);
|
||||||
|
expect(state.isPast).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import {
|
||||||
|
layoutWithLines,
|
||||||
|
prepareWithSegments,
|
||||||
|
} from '@chenglou/pretext';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single laid-out line of text, with per-grapheme x offsets and widths.
|
||||||
|
*
|
||||||
|
* `chars` is indexed by grapheme cluster (not UTF-16 code unit), so emoji
|
||||||
|
* sequences and combining characters each produce exactly one entry.
|
||||||
|
*/
|
||||||
|
export interface LayoutLine {
|
||||||
|
/**
|
||||||
|
* Full text of this line as returned by pretext.
|
||||||
|
*/
|
||||||
|
text: string;
|
||||||
|
/**
|
||||||
|
* Rendered width of this line in pixels.
|
||||||
|
*/
|
||||||
|
width: number;
|
||||||
|
/**
|
||||||
|
* Individual character metadata for this line
|
||||||
|
*/
|
||||||
|
chars: Array<{
|
||||||
|
/**
|
||||||
|
* The grapheme cluster string (may be >1 code unit for emoji, etc.).
|
||||||
|
*/
|
||||||
|
char: string;
|
||||||
|
/**
|
||||||
|
* X offset from the start of the line, in pixels.
|
||||||
|
*/
|
||||||
|
x: number;
|
||||||
|
/**
|
||||||
|
* Advance width of this grapheme, in pixels.
|
||||||
|
*/
|
||||||
|
width: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggregated output of a single-font layout pass.
|
||||||
|
*/
|
||||||
|
export interface LayoutResult {
|
||||||
|
/**
|
||||||
|
* Per-line grapheme data. Empty when input text is empty.
|
||||||
|
*/
|
||||||
|
lines: LayoutLine[];
|
||||||
|
/**
|
||||||
|
* Total height in pixels. Equals `lines.length * lineHeight` (pretext guarantee).
|
||||||
|
*/
|
||||||
|
totalHeight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single-font text layout engine backed by `@chenglou/pretext`.
|
||||||
|
*
|
||||||
|
* Replaces the canvas-DOM hybrid `createCharacterComparison` for cases where
|
||||||
|
* only one font is needed. For dual-font comparison use `CharacterComparisonEngine`.
|
||||||
|
*
|
||||||
|
* **Usage**
|
||||||
|
* ```ts
|
||||||
|
* const engine = new TextLayoutEngine();
|
||||||
|
* const result = engine.layout('Hello World', '400 16px "Inter"', 320, 24);
|
||||||
|
* // result.lines[0].chars → [{ char: 'H', x: 0, width: 9 }, ...]
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* **Font string format:** `"${weight} ${size}px \"${family}\""` — e.g. `'400 16px "Inter"'`.
|
||||||
|
* This matches what SliderArea constructs from `typography.weight` and `typography.renderedSize`.
|
||||||
|
*
|
||||||
|
* **Canvas requirement:** pretext calls `document.createElement('canvas').getContext('2d')` on
|
||||||
|
* first use and caches the context for the process lifetime. Tests must install a canvas mock
|
||||||
|
* (see `__mocks__/canvas.ts`) before the first `layout()` call.
|
||||||
|
*/
|
||||||
|
export class TextLayoutEngine {
|
||||||
|
/**
|
||||||
|
* Grapheme segmenter used to split segment text into individual clusters.
|
||||||
|
*
|
||||||
|
* Pretext maintains its own internal segmenter for line-breaking decisions.
|
||||||
|
* We keep a separate one here so we can iterate graphemes in `layout()`
|
||||||
|
* without depending on pretext internals — the two segmenters produce
|
||||||
|
* identical boundaries because both use `{ granularity: 'grapheme' }`.
|
||||||
|
*/
|
||||||
|
#segmenter: Intl.Segmenter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param locale BCP 47 language tag passed to Intl.Segmenter. Defaults to the runtime locale.
|
||||||
|
*/
|
||||||
|
constructor(locale?: string) {
|
||||||
|
this.#segmenter = new Intl.Segmenter(locale, { granularity: 'grapheme' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lay out `text` in the given `font` within `width` pixels.
|
||||||
|
*
|
||||||
|
* @param text Raw text to lay out.
|
||||||
|
* @param font CSS font string: `"weight sizepx \"family\""`.
|
||||||
|
* @param width Available line width in pixels.
|
||||||
|
* @param lineHeight Line height in pixels (passed directly to pretext).
|
||||||
|
* @returns Per-line grapheme data. Empty `lines` when `text` is empty.
|
||||||
|
*/
|
||||||
|
layout(text: string, font: string, width: number, lineHeight: number): LayoutResult {
|
||||||
|
if (!text) {
|
||||||
|
return { lines: [], totalHeight: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// prepareWithSegments measures the text and builds the segment data structure
|
||||||
|
// (widths, breakableFitAdvances, etc.) that the line-walker consumes.
|
||||||
|
const prepared = prepareWithSegments(text, font);
|
||||||
|
const { lines, height } = layoutWithLines(prepared, width, lineHeight);
|
||||||
|
|
||||||
|
// `PreparedTextWithSegments` has these fields in its public type definition
|
||||||
|
// but the TypeScript signature only exposes `segments`. We cast to `any` to
|
||||||
|
// access the parallel numeric arrays — they are documented in the plan and
|
||||||
|
// verified against the pretext source at node_modules/@chenglou/pretext/src/layout.ts.
|
||||||
|
const internal = prepared as any;
|
||||||
|
const breakableFitAdvances = internal.breakableFitAdvances as (number[] | null)[];
|
||||||
|
const widths = internal.widths as number[];
|
||||||
|
|
||||||
|
const resultLines: LayoutLine[] = lines.map(line => {
|
||||||
|
const chars: LayoutLine['chars'] = [];
|
||||||
|
let currentX = 0;
|
||||||
|
|
||||||
|
const start = line.start;
|
||||||
|
const end = line.end;
|
||||||
|
|
||||||
|
// Walk every segment that falls within this line's [start, end] cursors.
|
||||||
|
// Both cursors are grapheme-level: start is inclusive, end is exclusive.
|
||||||
|
for (let sIdx = start.segmentIndex; sIdx <= end.segmentIndex; sIdx++) {
|
||||||
|
const segmentText = prepared.segments[sIdx];
|
||||||
|
if (segmentText === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const graphemes = Array.from(this.#segmenter.segment(segmentText), s => s.segment);
|
||||||
|
const advances = breakableFitAdvances[sIdx];
|
||||||
|
|
||||||
|
// For the first and last segments of the line the cursor may point
|
||||||
|
// into the middle of the segment — respect those boundaries.
|
||||||
|
// All intermediate segments are walked in full (gStart=0, gEnd=length).
|
||||||
|
const gStart = sIdx === start.segmentIndex ? start.graphemeIndex : 0;
|
||||||
|
const gEnd = sIdx === end.segmentIndex ? end.graphemeIndex : graphemes.length;
|
||||||
|
|
||||||
|
for (let gIdx = gStart; gIdx < gEnd; gIdx++) {
|
||||||
|
const char = graphemes[gIdx];
|
||||||
|
|
||||||
|
// `breakableFitAdvances[sIdx]` is an array of per-grapheme advance
|
||||||
|
// widths when the segment has >1 grapheme (multi-character words).
|
||||||
|
// It is `null` for single-grapheme segments (spaces, punctuation,
|
||||||
|
// emoji, etc.) — in that case the entire segment width is attributed
|
||||||
|
// to this single grapheme.
|
||||||
|
const charWidth = advances != null ? advances[gIdx]! : widths[sIdx]!;
|
||||||
|
|
||||||
|
chars.push({
|
||||||
|
char,
|
||||||
|
x: currentX,
|
||||||
|
width: charWidth,
|
||||||
|
});
|
||||||
|
currentX += charWidth;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: line.text,
|
||||||
|
width: line.width,
|
||||||
|
chars,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
lines: resultLines,
|
||||||
|
// pretext guarantees height === lineCount * lineHeight (see layout.ts source).
|
||||||
|
totalHeight: height,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { clearCache } from '@chenglou/pretext';
|
||||||
|
import {
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
} from 'vitest';
|
||||||
|
import { installCanvasMock } from '../__mocks__/canvas';
|
||||||
|
import { TextLayoutEngine } from './TextLayoutEngine.svelte';
|
||||||
|
|
||||||
|
// Fixed-width mock: every segment is measured as (text.length * 10) px.
|
||||||
|
// This is font-independent so we can reason about wrapping precisely.
|
||||||
|
const CHAR_WIDTH = 10;
|
||||||
|
|
||||||
|
describe('TextLayoutEngine', () => {
|
||||||
|
let engine: TextLayoutEngine;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Install mock BEFORE any prepareWithSegments call.
|
||||||
|
// clearMeasurementCaches resets pretext's cached canvas context
|
||||||
|
// and segment metric caches so each test gets a clean slate.
|
||||||
|
installCanvasMock((_font, text) => text.length * CHAR_WIDTH);
|
||||||
|
clearCache();
|
||||||
|
engine = new TextLayoutEngine();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty result for empty string', () => {
|
||||||
|
const result = engine.layout('', '400 16px "Inter"', 500, 20);
|
||||||
|
expect(result.lines).toHaveLength(0);
|
||||||
|
expect(result.totalHeight).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a single line when text fits within width', () => {
|
||||||
|
// 'ABC' = 3 chars × 10px = 30px, fits in 500px
|
||||||
|
const result = engine.layout('ABC', '400 16px "Inter"', 500, 20);
|
||||||
|
expect(result.lines).toHaveLength(1);
|
||||||
|
expect(result.lines[0].text).toBe('ABC');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('breaks text into multiple lines when it exceeds width', () => {
|
||||||
|
// 'Hello World' — pretext will split at the space.
|
||||||
|
// 'Hello' = 50px, ' ' hangs, 'World' = 50px. Width = 60px forces wrap after 'Hello '.
|
||||||
|
const result = engine.layout('Hello World', '400 16px "Inter"', 60, 20);
|
||||||
|
expect(result.lines.length).toBeGreaterThan(1);
|
||||||
|
// First line must not exceed the container width.
|
||||||
|
expect(result.lines[0].width).toBeLessThanOrEqual(60);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('assigns correct x positions to characters on a single line', () => {
|
||||||
|
// 'ABC': A=10px, B=10px, C=10px; all on one line in 500px container.
|
||||||
|
const result = engine.layout('ABC', '400 16px "Inter"', 500, 20);
|
||||||
|
const chars = result.lines[0].chars;
|
||||||
|
|
||||||
|
expect(chars).toHaveLength(3);
|
||||||
|
expect(chars[0].char).toBe('A');
|
||||||
|
expect(chars[0].x).toBe(0);
|
||||||
|
expect(chars[0].width).toBe(CHAR_WIDTH);
|
||||||
|
|
||||||
|
expect(chars[1].char).toBe('B');
|
||||||
|
expect(chars[1].x).toBe(CHAR_WIDTH);
|
||||||
|
expect(chars[1].width).toBe(CHAR_WIDTH);
|
||||||
|
|
||||||
|
expect(chars[2].char).toBe('C');
|
||||||
|
expect(chars[2].x).toBe(CHAR_WIDTH * 2);
|
||||||
|
expect(chars[2].width).toBe(CHAR_WIDTH);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('x positions are monotonically increasing across a line', () => {
|
||||||
|
const result = engine.layout('ABCDE', '400 16px "Inter"', 500, 20);
|
||||||
|
const chars = result.lines[0].chars;
|
||||||
|
for (let i = 1; i < chars.length; i++) {
|
||||||
|
expect(chars[i].x).toBeGreaterThan(chars[i - 1].x);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('each line has at least one char', () => {
|
||||||
|
const result = engine.layout('Hello World', '400 16px "Inter"', 60, 20);
|
||||||
|
for (const line of result.lines) {
|
||||||
|
expect(line.chars.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('totalHeight equals lineCount * lineHeight', () => {
|
||||||
|
const lineHeight = 24;
|
||||||
|
const result = engine.layout('Hello World', '400 16px "Inter"', 60, lineHeight);
|
||||||
|
expect(result.totalHeight).toBe(result.lines.length * lineHeight);
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user