Compare commits

..

38 Commits

Author SHA1 Message Date
Ilia Mashkov
dde187e0b2 chore: move ControlId type to the entities/Font layer 2026-04-16 11:19:17 +03:00
Ilia Mashkov
5a7c61ade7 feat(FontVirtualList): re-touch on weight change and pin visible fonts 2026-04-16 11:05:09 +03:00
Ilia Mashkov
d2bce85f9c feat(ComparisonStore): pin fontA/fontB to prevent eviction while on-screen 2026-04-16 10:55:41 +03:00
Ilia Mashkov
e509463911 chore: remove unused 2026-04-16 09:07:46 +03:00
Ilia Mashkov
db08f523f6 chore: move typography constants to the entity/Font layer 2026-04-16 09:05:34 +03:00
Ilia Mashkov
c5fa159c14 fix(FontList): remove weight prop, use default weight for FontList 2026-04-16 08:51:18 +03:00
Ilia Mashkov
8645c7dcc8 feat: use typographySettingsStore everywhere for the typography settings 2026-04-16 08:44:49 +03:00
Ilia Mashkov
fbeb84270b feat(Layout): remove breadcrumbs 2026-04-16 08:40:16 +03:00
Ilia Mashkov
c1ac9b5bc4 chore(SetupFont): rename controlManager to typographySettingsStore for better semantic 2026-04-16 08:22:08 +03:00
46d0d887b1 Merge pull request 'feature/unified-tanstack-query' (#36) from feature/unified-tanstack-query into main
All checks were successful
Workflow / build (push) Successful in 45s
Workflow / publish (push) Successful in 47s
Reviewed-on: #36
2026-04-16 04:53:28 +00:00
Ilia Mashkov
0a489a8adc fix(BaseQueryStore): use QueryObserverOptions instead of QueryOptions
All checks were successful
Workflow / build (pull_request) Successful in 58s
Workflow / publish (pull_request) Has been skipped
QueryOptions has queryKey as optional; QueryObserverOptions requires it,
matching what QueryObserver.constructor and setOptions actually expect.
2026-04-15 22:37:30 +03:00
Ilia Mashkov
cd349aec92 fix: imports 2026-04-15 22:32:45 +03:00
Ilia Mashkov
adaa6d7648 feat: refactor ComparisonStore to use BatchFontStore
Replace hand-rolled async fetching (fetchFontsByIds + isRestoring flag)
with BatchFontStore backed by TanStack Query. Three reactive effects
handle batch sync, CSS font loading, and default-font fallback.
isLoading now derives from batchStore.isLoading + fontsReady.
2026-04-15 22:25:34 +03:00
Ilia Mashkov
10f4781a67 test: enrich coverage for queryKeys, BaseQueryStore, and BatchFontStore
- queryKeys: add mutation-safety test for batch(), key hierarchy tests
  (list/batch/detail keys rooted in their parent base keys), and
  unique-key test for different detail IDs
- BaseQueryStore: add initial-state test (data undefined, isError false
  before any fetch resolves)
- BatchFontStore: add FontResponseError type assertion on malformed
  response, null error assertion on success, and setIds([]) disables
  query and returns empty fonts without triggering a fetch
2026-04-15 15:59:01 +03:00
Ilia Mashkov
f4a568832a feat: implement reactive BatchFontStore 2026-04-15 12:29:16 +03:00
Ilia Mashkov
4e9670118a feat: add seedFontCache utility 2026-04-15 12:21:04 +03:00
Ilia Mashkov
8e88d1b7cf feat: add BaseQueryStore for reactive query observers 2026-04-15 12:19:25 +03:00
Ilia Mashkov
1cbc262af7 feat: add stable query key factory 2026-04-15 12:06:32 +03:00
f072c5b270 Merge pull request 'fix/initial-fonts-loading' (#35) from fix/initial-fonts-loading into main
All checks were successful
Workflow / build (push) Successful in 46s
Workflow / publish (push) Successful in 45s
Reviewed-on: #35
2026-04-15 08:37:40 +00:00
Ilia Mashkov
bfa99cde20 fix(comparisonStore): add missing batch request and effect for initial font loading
All checks were successful
Workflow / build (pull_request) Successful in 3m8s
Workflow / publish (pull_request) Has been skipped
2026-04-15 11:35:37 +03:00
Ilia Mashkov
75b62265be fix: add missing export 2026-04-15 09:13:22 +03:00
5b81be6614 Merge pull request 'feature/pretext' (#34) from feature/pretext into main
Some checks failed
Workflow / build (push) Failing after 36s
Workflow / publish (push) Has been skipped
Reviewed-on: #34
2026-04-14 07:12:41 +00:00
Ilia Mashkov
a74abbb0b3 feat: wire createFontRowSizeResolver into SampleList for pretext-backed row heights
Some checks failed
Workflow / build (pull_request) Failing after 49s
Workflow / publish (pull_request) Has been skipped
2026-04-13 13:23:03 +03:00
Ilia Mashkov
20accb9c93 feat: implement createFontRowSizeResolver with canvas-measured heights and reactive status check 2026-04-13 08:54:19 +03:00
Ilia Mashkov
46b9db1db3 feat: export ItemSizeResolver type and document reactive estimateSize contract 2026-04-12 19:43:44 +03:00
Ilia Mashkov
4b017a83bb fix: add missing JSDoc, return types, and as-any comments to layout engines 2026-04-12 09:51:36 +03:00
Ilia Mashkov
49822f8af7 feat: install pretext library 2026-04-12 09:08:01 +03:00
Ilia Mashkov
338ca9b4fd feat: export TextLayoutEngine and CharacterComparisonEngine from shared helpers index
Remove deleted createCharacterComparison exports and benchmark.
2026-04-11 16:44:49 +03:00
Ilia Mashkov
99f662e2d5 fix: iterate pre-computed chars array in Line.svelte to fix unicode grapheme splitting bug 2026-04-11 16:26:41 +03:00
Ilia Mashkov
5977e0a0dc fix: correct advances null-check in CharacterComparisonEngine and remove unused TextLayoutEngine dep 2026-04-11 16:14:28 +03:00
Ilia Mashkov
2b0d8470e5 test: fix CharacterComparisonEngine tests — correct env directive, canvas mock, and full spec coverage 2026-04-11 16:14:24 +03:00
Ilia Mashkov
351ee9fd52 docs: add inline documentation to TextLayoutEngine 2026-04-11 16:10:01 +03:00
Ilia Mashkov
a526a51af8 test: fix TextLayoutEngine tests — correct jsdom directive placement and canvas mock setup
fix: correct grapheme-width fallback in TextLayoutEngine for null breakableFitAdvances
2026-04-11 15:48:52 +03:00
Ilia Mashkov
fcde78abad test: add canvas mock helper for pretext-based engine tests 2026-04-11 15:48:47 +03:00
26737f2f11 Merge pull request 'chore/purge-unused' (#33) from chore/purge-unused into main
All checks were successful
Workflow / build (push) Successful in 45s
Workflow / publish (push) Successful in 23s
Reviewed-on: #33
2026-04-10 14:31:27 +00:00
Ilia Mashkov
d9fa2bc501 refactor: consolidate font domain and model types into font.ts
All checks were successful
Workflow / build (pull_request) Successful in 59s
Workflow / publish (pull_request) Has been skipped
2026-04-10 17:29:15 +03:00
Ilia Mashkov
5f38996665 chore: purge legacy font provider types and normalization logic 2026-04-10 16:05:57 +03:00
d70fc9f918 Merge pull request 'feat/font-store-merge' (#32) from feat/font-store-merge into main
All checks were successful
Workflow / build (push) Successful in 43s
Workflow / publish (push) Successful in 21s
Reviewed-on: #32
2026-04-10 05:13:39 +00:00
60 changed files with 2166 additions and 3486 deletions

View File

@@ -66,6 +66,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"
} }
} }

View File

@@ -85,19 +85,11 @@ onDestroy(() => themeManager.destroy());
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> <TooltipProvider>
{#if fontsReady} {#if fontsReady}
{@render children?.()} {@render children?.()}
{/if} {/if}
</TooltipProvider> </TooltipProvider>
<!-- </main> -->
<!-- </ScrollArea> -->
<footer></footer> <footer></footer>
</div> </div>
</ResponsiveProvider> </ResponsiveProvider>

View File

@@ -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();
});
});
}); });

View File

@@ -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

View File

@@ -1,3 +1,4 @@
export * from './api'; export * from './api';
export * from './lib';
export * from './model'; export * from './model';
export * from './ui'; export * from './ui';

View File

@@ -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,13 +34,9 @@ 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,
@@ -61,3 +48,6 @@ export {
FontNetworkError, FontNetworkError,
FontResponseError, FontResponseError,
} from './errors/errors'; } from './errors/errors';
export { createFontRowSizeResolver } from './sizeResolver/createFontRowSizeResolver';
export type { FontRowSizeResolverOptions } from './sizeResolver/createFontRowSizeResolver';

View File

@@ -58,29 +58,6 @@ export interface MockFilters {
// FONT CATEGORIES // 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,6 +67,8 @@ 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 // FONT SUBSETS

View File

@@ -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,351 +45,6 @@ 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
/** /**

View File

@@ -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,

View File

@@ -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,
},
},
],
};
}

View File

@@ -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';

View File

@@ -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);
});
});

View File

@@ -0,0 +1,112 @@
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;
};
}

View File

@@ -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

View File

@@ -1,7 +1,3 @@
export { export * from './const/const';
appliedFontsManager, export * from './store';
createFontStore,
FontStore,
fontStore,
} from './store';
export * from './types'; export * from './types';

View File

@@ -0,0 +1,91 @@
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;
}
}

View 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 });
});
});
});

View File

@@ -1,15 +1,10 @@
/** // Applied fonts manager
* ============================================================================ export * from './appliedFontsStore/appliedFontsStore.svelte';
* UNIFIED FONT STORE EXPORTS
* ============================================================================
*
* Single export point for the unified font store infrastructure.
*/
// Applied fonts manager (CSS loading - unchanged) // Batch font store
export { appliedFontsManager } from './appliedFontsStore/appliedFontsStore.svelte'; export { BatchFontStore } from './batchFontStore.svelte';
// Single FontStore (new implementation) // Single FontStore
export { export {
createFontStore, createFontStore,
FontStore, FontStore,

View File

@@ -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';

View File

@@ -1,25 +1,85 @@
/** /**
* ============================================================================ * Font domain types
* NORMALIZATION TYPES *
* ============================================================================ * Shared types for font entities across providers (Google, Fontshare).
* Includes categories, subsets, weights, and the unified font model.
*/ */
import type { /**
FontCategory, * Unified font category across all providers
FontProvider, */
FontSubset, export type FontCategory =
FontVariant, | 'sans-serif'
} from './common'; | 'serif'
| 'display'
| 'handwriting'
| 'monospace'
| 'slab'
| 'script';
/** /**
* Font variant types (standardized) * Font provider identifier
*/
export type FontProvider = 'google' | 'fontshare';
/**
* Character subset support
*/
export type FontSubset = 'latin' | 'latin-ext' | 'cyrillic' | 'greek' | 'arabic' | 'devanagari';
/**
* Combined filter state for font queries
*/
export interface FontFilters {
/** Selected font providers */
providers: FontProvider[];
/** Selected font categories */
categories: FontCategory[];
/** Selected character subsets */
subsets: FontSubset[];
}
/** Filter group identifier */
export type FilterGroup = 'providers' | 'categories' | 'subsets';
/** Filter type including search query */
export type FilterType = FilterGroup | 'searchQuery';
/**
* Numeric font weights (100-900)
*/
export type FontWeight = '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900';
/**
* Italic variant with weight: "100italic", "400italic", "700italic", etc.
*/
export type FontWeightItalic = `${FontWeight}italic`;
/**
* All possible font variant identifiers
*
* Includes:
* - Numeric weights: "400", "700", etc.
* - Italic variants: "400italic", "700italic", etc.
* - Legacy names: "regular", "italic", "bold", "bolditalic"
*/
export type FontVariant =
| FontWeight
| FontWeightItalic
| 'regular'
| 'italic'
| 'bold'
| 'bolditalic';
/**
* Standardized font variant alias
*/ */
export type UnifiedFontVariant = FontVariant; export type UnifiedFontVariant = FontVariant;
/** /**
* Font style URLs * Font style URLs
*/ */
export interface LegacyFontStyleUrls { export interface FontStyleUrls {
/** Regular weight URL */ /** Regular weight URL */
regular?: string; regular?: string;
/** Italic URL */ /** Italic URL */
@@ -28,9 +88,7 @@ export interface LegacyFontStyleUrls {
bold?: string; bold?: string;
/** Bold italic URL */ /** Bold italic URL */
boldItalic?: string; boldItalic?: string;
} /** Additional variant mapping */
export interface FontStyleUrls extends LegacyFontStyleUrls {
variants?: Partial<Record<UnifiedFontVariant, string>>; variants?: Partial<Record<UnifiedFontVariant, string>>;
} }

View File

@@ -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;
}

View File

@@ -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>>;

View File

@@ -7,48 +7,23 @@
* All imports should use: `import { X } from '$entities/Font/model/types'` * All imports should use: `import { X } from '$entities/Font/model/types'`
*/ */
// Domain types // Font domain and model 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 +33,4 @@ export type {
} from './store'; } from './store';
export * from './store/appliedFonts'; export * from './store/appliedFonts';
export * from './typography';

View File

@@ -8,8 +8,8 @@ import type {
FontCategory, FontCategory,
FontProvider, FontProvider,
FontSubset, FontSubset,
} from './common'; UnifiedFont,
import type { UnifiedFont } from './normalize'; } from './font';
/** /**
* Font collection state * Font collection state

View File

@@ -0,0 +1 @@
export type ControlId = 'font_size' | 'font_weight' | 'line_height' | 'letter_spacing';

View File

@@ -10,6 +10,7 @@ import { cn } from '$shared/shadcn/utils/shadcn-utils';
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();

View File

@@ -53,30 +53,42 @@ const isLoading = $derived(
fontStore.isFetching || fontStore.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
*/ */

View File

@@ -35,7 +35,6 @@ 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';
// Mock fonts for testing // Mock fonts for testing
const mockArial: UnifiedFont = { const mockArial: UnifiedFont = {

View File

@@ -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>
@@ -75,7 +69,7 @@ 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
@@ -140,12 +134,12 @@ 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>

View File

@@ -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';

View File

@@ -1,4 +1,4 @@
export { export {
createTypographyControlManager, createTypographySettingsManager,
type TypographyControlManager, type TypographySettingsManager,
} from './controlManager/controlManager.svelte'; } from './settingsManager/settingsManager.svelte';

View File

@@ -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,13 +26,6 @@ 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>;
@@ -52,7 +52,7 @@ 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 */ /** Map of controls keyed by ID */
#controls = new SvelteMap<string, Control>(); #controls = new SvelteMap<string, Control>();
/** Responsive multiplier for font size display */ /** Responsive multiplier for font size display */
@@ -242,7 +242,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 +252,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);
} }

View File

@@ -1,4 +1,11 @@
/** @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, afterEach,
beforeEach, beforeEach,
@@ -8,21 +15,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 +45,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 +85,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 +105,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 +117,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 +126,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 +142,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 +160,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 +179,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 +188,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 +201,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 +217,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 +241,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 +262,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 +273,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 +284,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 +298,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 +307,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 +320,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 +340,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 +355,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 +380,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 +393,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 +409,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 +422,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 +434,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 +448,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 +460,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 +472,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 +485,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 +503,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 +536,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 +547,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 +565,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 +586,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 +608,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 +633,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 +645,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 +658,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 +671,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 +690,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 +704,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,
); );

View File

@@ -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';

View File

@@ -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);

View File

@@ -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',
);

View File

@@ -6,10 +6,14 @@
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 { cn } from '$shared/shadcn/utils/shadcn-utils';
import { import {
Button,
ComboControl, ComboControl,
ControlGroup, ControlGroup,
Slider, Slider,
@@ -20,12 +24,7 @@ import { Popover } from 'bits-ui';
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 {
/** /**
@@ -52,16 +51,16 @@ $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>
@@ -133,7 +132,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}
@@ -174,7 +173,7 @@ $effect(() => {
</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}

View 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());
});
});
});

View File

@@ -0,0 +1,23 @@
/**
* 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;

View 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();
}
}

View 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();
});
});
});

View File

@@ -0,0 +1,270 @@
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;
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 (0100) 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;
}
}

View File

@@ -0,0 +1,168 @@
// @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();
});
// --- layout() ---
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);
});
// --- getCharState() ---
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);
});
});

View File

@@ -0,0 +1,154 @@
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;
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,
};
}
}

View File

@@ -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);
});
});

View File

@@ -0,0 +1,29 @@
// src/shared/lib/helpers/__mocks__/canvas.ts
//
// Call installCanvasMock(fn) before any pretext import to control measureText.
// The factory receives the current ctx.font string and the text to measure.
import { vi } from 'vitest';
export type MeasureFactory = (font: string, text: string) => number;
export function installCanvasMock(factory: MeasureFactory): void {
let currentFont = '';
const mockCtx = {
get font() {
return currentFont;
},
set font(f: string) {
currentFont = f;
},
measureText: vi.fn((text: string) => ({ width: factory(currentFont, text) })),
};
// HTMLCanvasElement.prototype.getContext is the entry point pretext uses in DOM environments.
// OffscreenCanvas takes priority in pretext; jsdom does not define it so DOM path is used.
Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', {
configurable: true,
writable: true,
value: vi.fn(() => mockCtx),
});
}

View File

@@ -1,374 +0,0 @@
/**
* Character-by-character font comparison helper
*
* Creates utilities for comparing two fonts character by character.
* Used by the ComparisonView widget to render morphing text effects
* where characters transition between font A and font B based on
* slider position.
*
* Features:
* - Responsive text measurement using canvas
* - Binary search for optimal line breaking
* - Character proximity calculation for morphing effects
* - Handles CSS transforms correctly (uses offsetWidth)
*
* @example
* ```svelte
* <script lang="ts">
* import { createCharacterComparison } from '$shared/lib/helpers';
*
* const comparison = createCharacterComparison(
* () => text,
* () => fontA,
* () => fontB,
* () => weight,
* () => size
* );
*
* $: lines = comparison.lines;
* </script>
*
* <canvas bind:this={measureCanvas} hidden></canvas>
* <div bind:this={container}>
* {#each lines as line}
* <span>{line.text}</span>
* {/each}
* </div>
* ```
*/
/**
* Represents a single line of text with its measured width
*/
export interface LineData {
/** The text content of the line */
text: string;
/** Maximum width between both fonts in pixels */
width: number;
}
/**
* Creates a character comparison helper for morphing text effects
*
* Measures text in both fonts to determine line breaks and calculates
* character-level proximity for morphing animations.
*
* @param text - Getter for the text to compare
* @param fontA - Getter for the first font (left/top side)
* @param fontB - Getter for the second font (right/bottom side)
* @param weight - Getter for the current font weight
* @param size - Getter for the controlled font size
* @returns Character comparison instance with lines and proximity calculations
*
* @example
* ```ts
* const comparison = createCharacterComparison(
* () => $sampleText,
* () => $selectedFontA,
* () => $selectedFontB,
* () => $fontWeight,
* () => $fontSize
* );
*
* // Call when DOM is ready
* comparison.breakIntoLines(container, canvas);
*
* // Get character state for morphing
* const state = comparison.getCharState(5, sliderPosition, lineEl, container);
* // state.proximity: 0-1 value for opacity/interpolation
* // state.isPast: true if slider is past this character
* ```
*/
export function createCharacterComparison<
T extends { name: string; id: string } | undefined = undefined,
>(
text: () => string,
fontA: () => T,
fontB: () => T,
weight: () => number,
size: () => number,
) {
let lines = $state<LineData[]>([]);
let containerWidth = $state(0);
/**
* Type guard to check if a font is defined
*/
function fontDefined<T extends { name: string; id: string }>(font: T | undefined): font is T {
return font !== undefined;
}
/**
* Measures text width using canvas 2D context
*
* @param ctx - Canvas rendering context
* @param text - Text string to measure
* @param fontSize - Font size in pixels
* @param fontWeight - Font weight (100-900)
* @param fontFamily - Font family name (optional, returns 0 if missing)
* @returns Width of text in pixels
*/
function measureText(
ctx: CanvasRenderingContext2D,
text: string,
fontSize: number,
fontWeight: number,
fontFamily?: string,
): number {
if (!fontFamily) return 0;
ctx.font = `${fontWeight} ${fontSize}px ${fontFamily}`;
return ctx.measureText(text).width;
}
/**
* Gets responsive font size based on viewport width
*
* Matches Tailwind breakpoints used in the component:
* - < 640px: 64px
* - 640-767px: 80px
* - 768-1023px: 96px
* - >= 1024px: 112px
*/
function getFontSize() {
if (typeof window === 'undefined') {
return 64;
}
return window.innerWidth >= 1024
? 112
: window.innerWidth >= 768
? 96
: window.innerWidth >= 640
? 80
: 64;
}
/**
* Breaks text into lines based on container width
*
* Measures text in BOTH fonts and uses the wider width to prevent
* layout shifts. Uses binary search for efficient word breaking.
*
* @param container - Container element to measure width from
* @param measureCanvas - Hidden canvas element for text measurement
*/
function breakIntoLines(
container: HTMLElement | undefined,
measureCanvas: HTMLCanvasElement | undefined,
) {
if (!container || !measureCanvas || !fontA() || !fontB()) {
return;
}
// Use offsetWidth to avoid CSS transform scaling issues
// getBoundingClientRect() includes transform scale which breaks calculations
const width = container.offsetWidth;
containerWidth = width;
const padding = window.innerWidth < 640 ? 48 : 96;
const availableWidth = width - padding;
const ctx = measureCanvas.getContext('2d');
if (!ctx) {
return;
}
const controlledFontSize = size();
const fontSize = getFontSize();
const currentWeight = weight();
const words = text().split(' ');
const newLines: LineData[] = [];
let currentLineWords: string[] = [];
/**
* Adds a line to the output using the wider font's width
*/
function pushLine(words: string[]) {
if (words.length === 0 || !fontDefined(fontA()) || !fontDefined(fontB())) {
return;
}
const lineText = words.join(' ');
const widthA = measureText(
ctx!,
lineText,
Math.min(fontSize, controlledFontSize),
currentWeight,
fontA()?.name,
);
const widthB = measureText(
ctx!,
lineText,
Math.min(fontSize, controlledFontSize),
currentWeight,
fontB()?.name,
);
const maxWidth = Math.max(widthA, widthB);
newLines.push({ text: lineText, width: maxWidth });
}
for (const word of words) {
const testLine = currentLineWords.length > 0
? currentLineWords.join(' ') + ' ' + word
: word;
// Measure with both fonts - use wider to prevent shifts
const widthA = measureText(
ctx,
testLine,
Math.min(fontSize, controlledFontSize),
currentWeight,
fontA()?.name,
);
const widthB = measureText(
ctx,
testLine,
Math.min(fontSize, controlledFontSize),
currentWeight,
fontB()?.name,
);
const maxWidth = Math.max(widthA, widthB);
const isContainerOverflown = maxWidth > availableWidth;
if (isContainerOverflown) {
if (currentLineWords.length > 0) {
pushLine(currentLineWords);
currentLineWords = [];
}
// Check if word alone fits
const wordWidthA = measureText(
ctx,
word,
Math.min(fontSize, controlledFontSize),
currentWeight,
fontA()?.name,
);
const wordWidthB = measureText(
ctx,
word,
Math.min(fontSize, controlledFontSize),
currentWeight,
fontB()?.name,
);
const wordAloneWidth = Math.max(wordWidthA, wordWidthB);
if (wordAloneWidth <= availableWidth) {
currentLineWords = [word];
} else {
// Word doesn't fit - binary search to find break point
let remainingWord = word;
while (remainingWord.length > 0) {
let low = 1;
let high = remainingWord.length;
let bestBreak = 1;
// Binary search for maximum characters that fit
while (low <= high) {
const mid = Math.floor((low + high) / 2);
const testFragment = remainingWord.slice(0, mid);
const wA = measureText(
ctx,
testFragment,
fontSize,
currentWeight,
fontA()?.name,
);
const wB = measureText(
ctx,
testFragment,
fontSize,
currentWeight,
fontB()?.name,
);
if (Math.max(wA, wB) <= availableWidth) {
bestBreak = mid;
low = mid + 1;
} else {
high = mid - 1;
}
}
pushLine([remainingWord.slice(0, bestBreak)]);
remainingWord = remainingWord.slice(bestBreak);
}
}
} else if (maxWidth > availableWidth && currentLineWords.length > 0) {
pushLine(currentLineWords);
currentLineWords = [word];
} else {
currentLineWords.push(word);
}
}
if (currentLineWords.length > 0) {
pushLine(currentLineWords);
}
lines = newLines;
}
/**
* Calculates character proximity to slider position
*
* Used for morphing effects - returns how close a character is to
* the slider and whether it's on the "past" side.
*
* @param charIndex - Index of character within its line
* @param sliderPos - Slider position (0-100, percent across container)
* @param lineElement - The line element containing the character
* @param container - The container element for position calculations
* @returns Proximity (0-1, 1 = at slider) and isPast (true = right of slider)
*/
function getCharState(
charIndex: number,
sliderPos: number,
lineElement?: HTMLElement,
container?: HTMLElement,
) {
if (!containerWidth || !container) {
return {
proximity: 0,
isPast: false,
};
}
const charElement = lineElement?.children[charIndex] as HTMLElement;
if (!charElement) {
return { proximity: 0, isPast: false };
}
// Get character bounding box relative to container
const charRect = charElement.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
// Calculate character center as percentage of container width
const charCenter = charRect.left + (charRect.width / 2) - containerRect.left;
const charGlobalPercent = (charCenter / containerWidth) * 100;
// Calculate proximity (1.0 = at slider, 0.0 = 5% away)
const distance = Math.abs(sliderPos - charGlobalPercent);
const range = 5;
const proximity = Math.max(0, 1 - distance / range);
const isPast = sliderPos > charGlobalPercent;
return { proximity, isPast };
}
return {
/** Reactive array of broken lines */
get lines() {
return lines;
},
/** Container width in pixels */
get containerWidth() {
return containerWidth;
},
/** Break text into lines based on current container and fonts */
breakIntoLines,
/** Get character state for morphing calculations */
getCharState,
};
}
/**
* Type representing a character comparison instance
*/
export type CharacterComparison = ReturnType<typeof createCharacterComparison>;

View File

@@ -1,312 +0,0 @@
import {
beforeEach,
describe,
expect,
it,
vi,
} from 'vitest';
import { createCharacterComparison } from './createCharacterComparison.svelte';
type Font = { name: string; id: string };
const fontA: Font = { name: 'Roboto', id: 'roboto' };
const fontB: Font = { name: 'Open Sans', id: 'open-sans' };
function createMockCanvas(charWidth = 10): HTMLCanvasElement {
return {
getContext: () => ({
font: '',
measureText: (text: string) => ({ width: text.length * charWidth }),
}),
} as unknown as HTMLCanvasElement;
}
function createMockContainer(offsetWidth = 500): HTMLElement {
return {
offsetWidth,
getBoundingClientRect: () => ({
left: 0,
width: offsetWidth,
top: 0,
right: offsetWidth,
bottom: 0,
height: 0,
}),
} as unknown as HTMLElement;
}
describe('createCharacterComparison', () => {
beforeEach(() => {
// Mock window.innerWidth for getFontSize and padding calculations
Object.defineProperty(globalThis, 'window', {
value: { innerWidth: 1024 },
writable: true,
configurable: true,
});
});
describe('Initial State', () => {
it('should initialize with empty lines and zero container width', () => {
const comparison = createCharacterComparison(
() => 'test',
() => fontA,
() => fontB,
() => 400,
() => 48,
);
expect(comparison.lines).toEqual([]);
expect(comparison.containerWidth).toBe(0);
});
});
describe('breakIntoLines', () => {
it('should not break lines when container or canvas is undefined', () => {
const comparison = createCharacterComparison(
() => 'Hello world',
() => fontA,
() => fontB,
() => 400,
() => 48,
);
comparison.breakIntoLines(undefined, undefined);
expect(comparison.lines).toEqual([]);
comparison.breakIntoLines(createMockContainer(), undefined);
expect(comparison.lines).toEqual([]);
});
it('should not break lines when fonts are undefined', () => {
const comparison = createCharacterComparison(
() => 'Hello world',
() => undefined,
() => undefined,
() => 400,
() => 48,
);
comparison.breakIntoLines(createMockContainer(), createMockCanvas());
expect(comparison.lines).toEqual([]);
});
it('should produce a single line when text fits within container', () => {
// charWidth=10, padding=96 (innerWidth>=640), availableWidth=500-96=404
// "Hello" = 5 chars * 10 = 50px, fits easily
const comparison = createCharacterComparison(
() => 'Hello',
() => fontA,
() => fontB,
() => 400,
() => 48,
);
comparison.breakIntoLines(createMockContainer(500), createMockCanvas(10));
expect(comparison.lines).toHaveLength(1);
expect(comparison.lines[0].text).toBe('Hello');
});
it('should break text into multiple lines when it overflows', () => {
// charWidth=10, container=200, padding=96, availableWidth=104
// "Hello world test" => "Hello" (50px), "Hello world" (110px > 104)
// So "Hello" goes on line 1, "world" (50px) fits, "world test" (100px) fits
const comparison = createCharacterComparison(
() => 'Hello world test',
() => fontA,
() => fontB,
() => 400,
() => 48,
);
comparison.breakIntoLines(createMockContainer(200), createMockCanvas(10));
expect(comparison.lines.length).toBeGreaterThan(1);
// All original text should be preserved across lines
const reconstructed = comparison.lines.map(l => l.text).join(' ');
expect(reconstructed).toBe('Hello world test');
});
it('should update containerWidth after breaking lines', () => {
const comparison = createCharacterComparison(
() => 'Hi',
() => fontA,
() => fontB,
() => 400,
() => 48,
);
comparison.breakIntoLines(createMockContainer(750), createMockCanvas(10));
expect(comparison.containerWidth).toBe(750);
});
it('should use smaller padding on narrow viewports', () => {
Object.defineProperty(globalThis, 'window', {
value: { innerWidth: 500 },
writable: true,
configurable: true,
});
// container=150, padding=48 (innerWidth<640), availableWidth=102
// "ABCDEFGHIJ" = 10 chars * 10 = 100px, fits in 102
const comparison = createCharacterComparison(
() => 'ABCDEFGHIJ',
() => fontA,
() => fontB,
() => 400,
() => 48,
);
comparison.breakIntoLines(createMockContainer(150), createMockCanvas(10));
expect(comparison.lines).toHaveLength(1);
expect(comparison.lines[0].text).toBe('ABCDEFGHIJ');
});
it('should break a single long word using binary search', () => {
// container=150, padding=96, availableWidth=54
// "ABCDEFGHIJ" = 10 chars * 10 = 100px, doesn't fit as single word
// Binary search should split it
const comparison = createCharacterComparison(
() => 'ABCDEFGHIJ',
() => fontA,
() => fontB,
() => 400,
() => 48,
);
comparison.breakIntoLines(createMockContainer(150), createMockCanvas(10));
expect(comparison.lines.length).toBeGreaterThan(1);
const reconstructed = comparison.lines.map(l => l.text).join('');
expect(reconstructed).toBe('ABCDEFGHIJ');
});
it('should store max width between both fonts for each line', () => {
// Use a canvas where measureText returns text.length * charWidth
// Both fonts measure the same, so width = text.length * charWidth
const comparison = createCharacterComparison(
() => 'Hi',
() => fontA,
() => fontB,
() => 400,
() => 48,
);
comparison.breakIntoLines(createMockContainer(500), createMockCanvas(10));
expect(comparison.lines[0].width).toBe(20); // "Hi" = 2 chars * 10
});
});
describe('getCharState', () => {
it('should return zero proximity and isPast=false when containerWidth is 0', () => {
const comparison = createCharacterComparison(
() => 'test',
() => fontA,
() => fontB,
() => 400,
() => 48,
);
const state = comparison.getCharState(0, 50, undefined, undefined);
expect(state.proximity).toBe(0);
expect(state.isPast).toBe(false);
});
it('should return zero proximity when charElement is not found', () => {
const comparison = createCharacterComparison(
() => 'test',
() => fontA,
() => fontB,
() => 400,
() => 48,
);
// First break lines to set containerWidth
comparison.breakIntoLines(createMockContainer(500), createMockCanvas(10));
const lineEl = { children: [] } as unknown as HTMLElement;
const container = createMockContainer(500);
const state = comparison.getCharState(0, 50, lineEl, container);
expect(state.proximity).toBe(0);
expect(state.isPast).toBe(false);
});
it('should calculate proximity based on distance from slider', () => {
const comparison = createCharacterComparison(
() => 'test',
() => fontA,
() => fontB,
() => 400,
() => 48,
);
comparison.breakIntoLines(createMockContainer(500), createMockCanvas(10));
// Character centered at 250px in a 500px container = 50%
const charEl = {
getBoundingClientRect: () => ({ left: 240, width: 20 }),
};
const lineEl = { children: [charEl] } as unknown as HTMLElement;
const container = createMockContainer(500);
// Slider at 50% => charCenter at 250px => charGlobalPercent = 50%
// distance = |50 - 50| = 0, proximity = max(0, 1 - 0/5) = 1
const state = comparison.getCharState(0, 50, lineEl, container);
expect(state.proximity).toBe(1);
expect(state.isPast).toBe(false);
});
it('should return isPast=true when slider is past the character', () => {
const comparison = createCharacterComparison(
() => 'test',
() => fontA,
() => fontB,
() => 400,
() => 48,
);
comparison.breakIntoLines(createMockContainer(500), createMockCanvas(10));
// Character centered at 100px => 20% of 500px
const charEl = {
getBoundingClientRect: () => ({ left: 90, width: 20 }),
};
const lineEl = { children: [charEl] } as unknown as HTMLElement;
const container = createMockContainer(500);
// Slider at 80% => past the character at 20%
const state = comparison.getCharState(0, 80, lineEl, container);
expect(state.isPast).toBe(true);
});
it('should return zero proximity when character is far from slider', () => {
const comparison = createCharacterComparison(
() => 'test',
() => fontA,
() => fontB,
() => 400,
() => 48,
);
comparison.breakIntoLines(createMockContainer(500), createMockCanvas(10));
// Character at 10% of container, slider at 90% => distance = 80%, range = 5%
const charEl = {
getBoundingClientRect: () => ({ left: 45, width: 10 }),
};
const lineEl = { children: [charEl] } as unknown as HTMLElement;
const container = createMockContainer(500);
const state = comparison.getCharState(0, 90, lineEl, container);
expect(state.proximity).toBe(0);
});
});
});

View File

@@ -50,6 +50,14 @@ export interface VirtualizerOptions {
/** /**
* Function to estimate the size of an item at a given index. * Function to estimate the size of an item at a given index.
* Used for initial layout before actual measurements are available. * Used for initial layout before actual measurements are available.
*
* Called inside a `$derived.by` block. Any `$state` or `$derived` value
* read within this function is automatically tracked as a dependency —
* when those values change, `offsets` and `totalSize` recompute instantly.
*
* For font preview rows, pass a closure that reads
* `appliedFontsManager.statuses` so the virtualizer recalculates heights
* as fonts finish loading, eliminating the DOM-measurement snap on load.
*/ */
estimateSize: (index: number) => number; estimateSize: (index: number) => number;
/** Number of extra items to render outside viewport for smoother scrolling (default: 5) */ /** Number of extra items to render outside viewport for smoother scrolling (default: 5) */
@@ -71,6 +79,18 @@ export interface VirtualizerOptions {
useWindowScroll?: boolean; useWindowScroll?: boolean;
} }
/**
* A height resolver for a single virtual-list row.
*
* When this function reads reactive state (e.g. `SvelteMap.get()`), calling
* it inside a `$derived.by` block automatically subscribes to that state.
* Return `fallbackHeight` whenever the true height is not yet known.
*
* @param rowIndex Zero-based row index within the data array.
* @returns Row height in pixels, excluding the list gap.
*/
export type ItemSizeResolver = (rowIndex: number) => number;
/** /**
* Creates a reactive virtualizer for efficiently rendering large lists by only rendering visible items. * Creates a reactive virtualizer for efficiently rendering large lists by only rendering visible items.
* *

View File

@@ -52,10 +52,16 @@ export {
} from './createEntityStore/createEntityStore.svelte'; } from './createEntityStore/createEntityStore.svelte';
export { export {
type CharacterComparison, CharacterComparisonEngine,
createCharacterComparison, type ComparisonLine,
type LineData, type ComparisonResult,
} from './createCharacterComparison/createCharacterComparison.svelte'; } from './CharacterComparisonEngine/CharacterComparisonEngine.svelte';
export {
type LayoutLine as TextLayoutLine,
type LayoutResult as TextLayoutResult,
TextLayoutEngine,
} from './TextLayoutEngine/TextLayoutEngine.svelte';
export { export {
createPersistentStore, createPersistentStore,

View File

@@ -5,10 +5,11 @@
*/ */
export { export {
type CharacterComparison, CharacterComparisonEngine,
type ComparisonLine,
type ComparisonResult,
type ControlDataModel, type ControlDataModel,
type ControlModel, type ControlModel,
createCharacterComparison,
createDebouncedState, createDebouncedState,
createEntityStore, createEntityStore,
createFilter, createFilter,
@@ -21,12 +22,14 @@ export {
type EntityStore, type EntityStore,
type Filter, type Filter,
type FilterModel, type FilterModel,
type LineData,
type PersistentStore, type PersistentStore,
type PerspectiveManager, type PerspectiveManager,
type Property, type Property,
type ResponsiveManager, type ResponsiveManager,
responsiveManager, responsiveManager,
TextLayoutEngine,
type TextLayoutLine,
type TextLayoutResult,
type TypographyControl, type TypographyControl,
type VirtualItem, type VirtualItem,
type Virtualizer, type Virtualizer,

View File

@@ -1,5 +1,5 @@
/** /**
* Font comparison store for side-by-side font comparison * Font comparison store — TanStack Query refactor
* *
* Manages the state for comparing two fonts character by character. * Manages the state for comparing two fonts character by character.
* Persists font selection to localStorage and handles font loading * Persists font selection to localStorage and handles font loading
@@ -7,22 +7,23 @@
* *
* Features: * Features:
* - Persistent font selection (survives page refresh) * - Persistent font selection (survives page refresh)
* - Font loading state tracking * - Font loading state tracking via BatchFontStore + TanStack Query
* - Sample text management * - Sample text management
* - Typography controls (size, weight, line height, spacing) * - Typography controls (size, weight, line height, spacing)
* - Slider position for character-by-character morphing * - Slider position for character-by-character morphing
*/ */
import { import {
BatchFontStore,
type FontLoadRequestConfig,
type UnifiedFont, type UnifiedFont,
fetchFontsByIds, appliedFontsManager,
fontStore, fontStore,
getFontUrl,
} from '$entities/Font'; } from '$entities/Font';
import { import { typographySettingsStore } from '$features/SetupFont/model';
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
createTypographyControlManager,
} from '$features/SetupFont';
import { createPersistentStore } from '$shared/lib'; import { createPersistentStore } from '$shared/lib';
import { untrack } from 'svelte';
/** /**
* Storage schema for comparison state * Storage schema for comparison state
@@ -43,11 +44,13 @@ const storage = createPersistentStore<ComparisonState>('glyphdiff:comparison', {
}); });
/** /**
* Store for managing font comparison state * Store for managing font comparison state.
* *
* Handles font selection persistence, fetching, and loading state tracking. * Uses BatchFontStore (TanStack Query) to fetch fonts by ID, replacing
* Uses the CSS Font Loading API to ensure fonts are loaded before * the previous hand-rolled async fetch approach. Three reactive effects
* showing the comparison interface. * handle: (1) syncing batch results into fontA/fontB, (2) triggering the
* CSS Font Loading API, and (3) falling back to default fonts when
* storage is empty.
*/ */
export class ComparisonStore { export class ComparisonStore {
/** Font for side A */ /** Font for side A */
@@ -56,50 +59,99 @@ export class ComparisonStore {
#fontB = $state<UnifiedFont | undefined>(); #fontB = $state<UnifiedFont | undefined>();
/** Sample text to display */ /** Sample text to display */
#sampleText = $state('The quick brown fox jumps over the lazy dog'); #sampleText = $state('The quick brown fox jumps over the lazy dog');
/** Whether currently restoring from storage */
#isRestoring = $state(true);
/** Whether fonts are loaded and ready to display */ /** Whether fonts are loaded and ready to display */
#fontsReady = $state(false); #fontsReady = $state(false);
/** Active side for single-font operations */ /** Active side for single-font operations */
#side = $state<Side>('A'); #side = $state<Side>('A');
/** Slider position for character morphing (0-100) */ /** Slider position for character morphing (0-100) */
#sliderPosition = $state(50); #sliderPosition = $state(50);
/** Typography controls for this comparison */ // /** Typography controls for this comparison */
#typography = createTypographyControlManager(DEFAULT_TYPOGRAPHY_CONTROLS_DATA, 'glyphdiff:comparison:typography'); // #typography = createTypographyControlManager(DEFAULT_TYPOGRAPHY_CONTROLS_DATA, 'glyphdiff:comparison:typography');
/** TanStack Query-backed batch font fetcher */
#batchStore: BatchFontStore;
constructor() { constructor() {
this.restoreFromStorage(); // Synchronously seed the batch store with any IDs already in storage
const { fontAId, fontBId } = storage.value;
this.#batchStore = new BatchFontStore(fontAId && fontBId ? [fontAId, fontBId] : []);
// Reactively set defaults if we aren't restoring and have no selection
$effect.root(() => { $effect.root(() => {
// Effect 1: Sync batch results → fontA / fontB
$effect(() => { $effect(() => {
// Wait until we are done checking storage const fonts = this.#batchStore.fonts;
if (this.#isRestoring) { if (fonts.length === 0) return;
return;
}
// If we already have a selection, do nothing const { fontAId: aId, fontBId: bId } = storage.value;
if (this.#fontA && this.#fontB) { if (aId) {
const fa = fonts.find(f => f.id === aId);
if (fa) this.#fontA = fa;
}
if (bId) {
const fb = fonts.find(f => f.id === bId);
if (fb) this.#fontB = fb;
}
});
// Effect 2: Trigger font loading whenever selection or weight changes
$effect(() => {
const fa = this.#fontA;
const fb = this.#fontB;
const weight = typographySettingsStore.weight;
if (!fa || !fb) return;
const configs: FontLoadRequestConfig[] = [];
[fa, fb].forEach(f => {
const url = getFontUrl(f, weight);
if (url) {
configs.push({
id: f.id,
name: f.name,
weight,
url,
isVariable: f.features?.isVariable,
});
}
});
if (configs.length > 0) {
appliedFontsManager.touch(configs);
this.#checkFontsLoaded(); this.#checkFontsLoaded();
return;
} }
});
// Effect 3: Set default fonts when storage is empty
$effect(() => {
if (this.#fontA && this.#fontB) return;
// Check if fonts are available to set as defaults
const fonts = fontStore.fonts; const fonts = fontStore.fonts;
if (fonts.length >= 2) { if (fonts.length >= 2) {
// Only set if we really have nothing (fallback) untrack(() => {
if (!this.#fontA) this.#fontA = fonts[0]; const id1 = fonts[0].id;
if (!this.#fontB) this.#fontB = fonts[fonts.length - 1]; const id2 = fonts[fonts.length - 1].id;
storage.value = { fontAId: id1, fontBId: id2 };
// Sync defaults to storage so they persist if the user leaves this.#batchStore.setIds([id1, id2]);
this.updateStorage(); });
} }
}); });
// Effect 4: Pin fontA/fontB so eviction never removes on-screen fonts
$effect(() => {
const fa = this.#fontA;
const fb = this.#fontB;
const w = typographySettingsStore.weight;
if (fa) appliedFontsManager.pin(fa.id, w, fa.features?.isVariable);
if (fb) appliedFontsManager.pin(fb.id, w, fb.features?.isVariable);
return () => {
if (fa) appliedFontsManager.unpin(fa.id, w, fa.features?.isVariable);
if (fb) appliedFontsManager.unpin(fb.id, w, fb.features?.isVariable);
};
});
}); });
} }
/** /**
* Checks if fonts are actually loaded in the browser at current weight * Checks if fonts are actually loaded in the browser at current weight.
* *
* Uses CSS Font Loading API to prevent FOUT. Waits for fonts to load * Uses CSS Font Loading API to prevent FOUT. Waits for fonts to load
* and forces a layout/paint cycle before marking as ready. * and forces a layout/paint cycle before marking as ready.
@@ -110,8 +162,8 @@ export class ComparisonStore {
return; return;
} }
const weight = this.#typography.weight; const weight = typographySettingsStore.weight;
const size = this.#typography.renderedSize; const size = typographySettingsStore.renderedSize;
const fontAName = this.#fontA?.name; const fontAName = this.#fontA?.name;
const fontBName = this.#fontB?.name; const fontBName = this.#fontB?.name;
@@ -132,75 +184,39 @@ export class ComparisonStore {
this.#fontsReady = false; this.#fontsReady = false;
try { try {
// Step 1: Load fonts into memory
await Promise.all([ await Promise.all([
document.fonts.load(fontAString), document.fonts.load(fontAString),
document.fonts.load(fontBString), document.fonts.load(fontBString),
]); ]);
// Step 2: Wait for browser to be ready to render
await document.fonts.ready; await document.fonts.ready;
// Step 3: Force a layout/paint cycle (critical!)
await new Promise(resolve => { await new Promise(resolve => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
requestAnimationFrame(resolve); // Double rAF ensures paint completes requestAnimationFrame(resolve);
}); });
}); });
this.#fontsReady = true; this.#fontsReady = true;
} catch (error) { } catch (error) {
console.warn('[ComparisonStore] Font loading failed:', error); console.warn('[ComparisonStore] Font loading failed:', error);
setTimeout(() => this.#fontsReady = true, 1000); setTimeout(() => (this.#fontsReady = true), 1000);
} }
} }
/** /**
* Restore state from persistent storage * Updates persistent storage with the current font selection.
*
* Fetches saved fonts from the API and restores them to the store.
*/
async restoreFromStorage() {
this.#isRestoring = true;
const { fontAId, fontBId } = storage.value;
if (fontAId && fontBId) {
try {
// Batch fetch the saved fonts
const fonts = await fetchFontsByIds([fontAId, fontBId]);
const loadedFontA = fonts.find((f: UnifiedFont) => f.id === fontAId);
const loadedFontB = fonts.find((f: UnifiedFont) => f.id === fontBId);
if (loadedFontA && loadedFontB) {
this.#fontA = loadedFontA;
this.#fontB = loadedFontB;
}
} catch (error) {
console.warn('[ComparisonStore] Failed to restore fonts:', error);
}
}
// Mark restoration as complete (whether success or fail)
this.#isRestoring = false;
}
/**
* Update storage with current state
*/ */
private updateStorage() { private updateStorage() {
// Don't save if we are currently restoring (avoid race)
if (this.#isRestoring) return;
storage.value = { storage.value = {
fontAId: this.#fontA?.id ?? null, fontAId: this.#fontA?.id ?? null,
fontBId: this.#fontB?.id ?? null, fontBId: this.#fontB?.id ?? null,
}; };
} }
/** Typography control manager */ // // ── Getters / Setters ─────────────────────────────────────────────────────
get typography() {
return this.#typography; // /** Typography control manager */
} // get typography() {
// return typographySettingsStore;
// }
/** Font for side A */ /** Font for side A */
get fontA() { get fontA() {
@@ -249,35 +265,25 @@ export class ComparisonStore {
this.#sliderPosition = value; this.#sliderPosition = value;
} }
/** /** Whether both fonts are selected and loaded */
* Check if both fonts are selected and loaded
*/
get isReady() { get isReady() {
return !!this.#fontA && !!this.#fontB && this.#fontsReady; return !!this.#fontA && !!this.#fontB && this.#fontsReady;
} }
/** Whether currently loading or restoring */ /** Whether currently loading (batch fetch in flight or fonts not yet painted) */
get isLoading() { get isLoading() {
return this.#isRestoring || !this.#fontsReady; return this.#batchStore.isLoading || !this.#fontsReady;
} }
/** /**
* Public initializer (optional, as constructor starts it) * Resets all state, clears storage, and disables the batch query.
*/
initialize() {
if (!this.#isRestoring && !this.#fontA && !this.#fontB) {
this.restoreFromStorage();
}
}
/**
* Reset all state and clear storage
*/ */
resetAll() { resetAll() {
this.#fontA = undefined; this.#fontA = undefined;
this.#fontB = undefined; this.#fontB = undefined;
this.#batchStore.setIds([]);
storage.clear(); storage.clear();
this.#typography.reset(); typographySettingsStore.reset();
} }
} }

View File

@@ -1,20 +1,16 @@
/** /**
* Unit tests for ComparisonStore * Unit tests for ComparisonStore (TanStack Query refactor)
* *
* Tests the font comparison store functionality including: * Uses the real BatchFontStore so Svelte $state reactivity works correctly.
* - Font loading via CSS Font Loading API * Controls network behaviour via vi.spyOn on the proxyFonts API layer.
* - Storage synchronization when fonts change
* - Default values from fontStore
* - Reset functionality
* - isReady computed state
*/ */
/** @vitest-environment jsdom */ /** @vitest-environment jsdom */
import type { UnifiedFont } from '$entities/Font'; import type { UnifiedFont } from '$entities/Font';
import { UNIFIED_FONTS } from '$entities/Font/lib/mocks'; import { UNIFIED_FONTS } from '$entities/Font/lib/mocks';
import { queryClient } from '$shared/api/queryClient';
import { import {
afterEach,
beforeEach, beforeEach,
describe, describe,
expect, expect,
@@ -22,74 +18,13 @@ import {
vi, vi,
} from 'vitest'; } from 'vitest';
// Mock all dependencies // ── Persistent-store mock ─────────────────────────────────────────────────────
vi.mock('$entities/Font', () => ({
fetchFontsByIds: vi.fn(),
fontStore: { fonts: [] },
}));
vi.mock('$features/SetupFont', () => ({
DEFAULT_TYPOGRAPHY_CONTROLS_DATA: [
{
id: 'font_size',
value: 48,
min: 8,
max: 100,
step: 1,
increaseLabel: 'Increase Font Size',
decreaseLabel: 'Decrease Font Size',
controlLabel: 'Size',
},
{
id: 'font_weight',
value: 400,
min: 100,
max: 900,
step: 100,
increaseLabel: 'Increase Font Weight',
decreaseLabel: 'Decrease Font Weight',
controlLabel: 'Weight',
},
{
id: 'line_height',
value: 1.5,
min: 1,
max: 2,
step: 0.05,
increaseLabel: 'Increase Line Height',
decreaseLabel: 'Decrease Line Height',
controlLabel: 'Leading',
},
{
id: 'letter_spacing',
value: 0,
min: -0.1,
max: 0.5,
step: 0.01,
increaseLabel: 'Increase Letter Spacing',
decreaseLabel: 'Decrease Letter Spacing',
controlLabel: 'Tracking',
},
],
createTypographyControlManager: vi.fn(() => ({
weight: 400,
renderedSize: 48,
reset: vi.fn(),
})),
}));
// Create mock storage accessible from both vi.mock factory and tests
const mockStorage = vi.hoisted(() => { const mockStorage = vi.hoisted(() => {
const storage: any = {}; const storage: any = {};
storage._value = { storage._value = { fontAId: null, fontBId: null };
fontAId: null as string | null,
fontBId: null as string | null,
};
storage._clear = vi.fn(() => { storage._clear = vi.fn(() => {
storage._value = { storage._value = { fontAId: null, fontBId: null };
fontAId: null,
fontBId: null,
};
}); });
Object.defineProperty(storage, 'value', { Object.defineProperty(storage, 'value', {
@@ -116,71 +51,73 @@ vi.mock('$shared/lib/helpers/createPersistentStore/createPersistentStore.svelte'
createPersistentStore: vi.fn(() => mockStorage), createPersistentStore: vi.fn(() => mockStorage),
})); }));
// Import after mocks // ── $entities/Font mock — keep real BatchFontStore, stub singletons ───────────
import {
fetchFontsByIds,
fontStore,
} from '$entities/Font';
import { createTypographyControlManager } from '$features/SetupFont';
import { ComparisonStore } from './comparisonStore.svelte';
describe('ComparisonStore', () => { vi.mock('$entities/Font', async importOriginal => {
// Mock fonts const actual = await importOriginal<typeof import('$entities/Font')>();
const mockFontA: UnifiedFont = UNIFIED_FONTS.roboto; const { BatchFontStore } = await import(
const mockFontB: UnifiedFont = UNIFIED_FONTS.openSans; '$entities/Font/model/store/batchFontStore.svelte'
);
// Mock document.fonts return {
let mockFontFaceSet: { ...actual,
check: ReturnType<typeof vi.fn>; BatchFontStore,
load: ReturnType<typeof vi.fn>; fontStore: { fonts: [] },
ready: Promise<FontFaceSet>; appliedFontsManager: {
touch: vi.fn(),
pin: vi.fn(),
unpin: vi.fn(),
getFontStatus: vi.fn(),
ready: vi.fn(() => Promise.resolve()),
},
getFontUrl: vi.fn(() => 'https://example.com/font.woff2'),
}; };
});
beforeEach(() => { // ── $features/SetupFont mock ──────────────────────────────────────────────────
// Clear all mocks
vi.clearAllMocks();
// Clear localStorage vi.mock('$features/SetupFont', () => ({
localStorage.clear(); DEFAULT_TYPOGRAPHY_CONTROLS_DATA: [],
createTypographyControlManager: vi.fn(() => ({
// Reset mock storage value via the helper
mockStorage._value = {
fontAId: null,
fontBId: null,
};
mockStorage._clear.mockClear();
// Setup mock fontStore
(fontStore as any).fonts = [];
// Setup mock fetchFontsByIds
vi.mocked(fetchFontsByIds).mockResolvedValue([]);
// Setup mock createTypographyControlManager
vi.mocked(createTypographyControlManager).mockReturnValue({
weight: 400, weight: 400,
renderedSize: 48, renderedSize: 48,
reset: vi.fn(), reset: vi.fn(),
} as any); })),
}));
// Setup mock document.fonts vi.mock('$features/SetupFont/model', () => ({
mockFontFaceSet = { typographySettingsStore: {
check: vi.fn(() => true), weight: 400,
load: vi.fn(() => Promise.resolve()), renderedSize: 48,
ready: Promise.resolve({} as FontFaceSet), reset: vi.fn(),
}; },
}));
Object.defineProperty(document, 'fonts', { // ── Imports (after mocks) ─────────────────────────────────────────────────────
value: mockFontFaceSet,
writable: true,
configurable: true,
});
});
afterEach(() => { import {
// Ensure document.fonts is always reset to a valid mock appliedFontsManager,
// This prevents issues when tests delete or undefined document.fonts fontStore,
if (!document.fonts || typeof document.fonts.check !== 'function') { } from '$entities/Font';
import * as proxyFonts from '$entities/Font/api/proxy/proxyFonts';
import { ComparisonStore } from './comparisonStore.svelte';
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('ComparisonStore', () => {
const mockFontA: UnifiedFont = UNIFIED_FONTS.roboto; // id: 'roboto'
const mockFontB: UnifiedFont = UNIFIED_FONTS.openSans; // id: 'open-sans'
beforeEach(() => {
queryClient.clear();
vi.clearAllMocks();
mockStorage._value = { fontAId: null, fontBId: null };
mockStorage._clear.mockClear();
(fontStore as any).fonts = [];
// Default: fetchFontsByIds returns empty so tests that don't care don't hang
vi.spyOn(proxyFonts, 'fetchFontsByIds').mockResolvedValue([]);
// document.fonts: check returns true so #checkFontsLoaded resolves immediately
Object.defineProperty(document, 'fonts', { Object.defineProperty(document, 'fonts', {
value: { value: {
check: vi.fn(() => true), check: vi.fn(() => true),
@@ -190,397 +127,152 @@ describe('ComparisonStore', () => {
writable: true, writable: true,
configurable: true, configurable: true,
}); });
}
}); });
// ── Initialization ────────────────────────────────────────────────────────
describe('Initialization', () => { describe('Initialization', () => {
it('should create store with initial empty state', () => { it('should create store with initial empty state', () => {
const store = new ComparisonStore(); const store = new ComparisonStore();
expect(store.fontA).toBeUndefined();
expect(store.fontB).toBeUndefined();
expect(store.text).toBe('The quick brown fox jumps over the lazy dog');
expect(store.side).toBe('A');
expect(store.sliderPosition).toBe(50);
});
it('should initialize with default sample text', () => {
const store = new ComparisonStore();
expect(store.text).toBe('The quick brown fox jumps over the lazy dog');
});
it('should have typography manager attached', () => {
const store = new ComparisonStore();
expect(store.typography).toBeDefined();
});
});
describe('Storage Synchronization', () => {
it('should update storage when fontA is set', () => {
const store = new ComparisonStore();
store.fontA = mockFontA;
expect(mockStorage._value.fontAId).toBe(mockFontA.id);
});
it('should update storage when fontB is set', () => {
const store = new ComparisonStore();
store.fontB = mockFontB;
expect(mockStorage._value.fontBId).toBe(mockFontB.id);
});
it('should update storage when both fonts are set', () => {
const store = new ComparisonStore();
store.fontA = mockFontA;
store.fontB = mockFontB;
expect(mockStorage._value.fontAId).toBe(mockFontA.id);
expect(mockStorage._value.fontBId).toBe(mockFontB.id);
});
it('should set storage to null when font is set to undefined', () => {
const store = new ComparisonStore();
store.fontA = mockFontA;
expect(mockStorage._value.fontAId).toBe(mockFontA.id);
store.fontA = undefined;
expect(mockStorage._value.fontAId).toBeNull();
});
});
describe('Restore from Storage', () => {
it('should restore fonts from storage when both IDs exist', async () => {
mockStorage._value.fontAId = mockFontA.id;
mockStorage._value.fontBId = mockFontB.id;
vi.mocked(fetchFontsByIds).mockResolvedValue([mockFontA, mockFontB]);
const store = new ComparisonStore();
await store.restoreFromStorage();
expect(fetchFontsByIds).toHaveBeenCalledWith([mockFontA.id, mockFontB.id]);
expect(store.fontA).toEqual(mockFontA);
expect(store.fontB).toEqual(mockFontB);
});
it('should not restore when storage has null IDs', async () => {
mockStorage._value.fontAId = null;
mockStorage._value.fontBId = null;
const store = new ComparisonStore();
await store.restoreFromStorage();
expect(fetchFontsByIds).not.toHaveBeenCalled();
expect(store.fontA).toBeUndefined(); expect(store.fontA).toBeUndefined();
expect(store.fontB).toBeUndefined(); expect(store.fontB).toBeUndefined();
}); });
});
it('should handle fetch errors gracefully when restoring', async () => { // ── Restoration from Storage ──────────────────────────────────────────────
describe('Restoration from Storage (via BatchFontStore)', () => {
it('should restore fontA and fontB from stored IDs', async () => {
mockStorage._value.fontAId = mockFontA.id; mockStorage._value.fontAId = mockFontA.id;
mockStorage._value.fontBId = mockFontB.id; mockStorage._value.fontBId = mockFontB.id;
vi.spyOn(proxyFonts, 'fetchFontsByIds').mockResolvedValue([mockFontA, mockFontB]);
vi.mocked(fetchFontsByIds).mockRejectedValue(new Error('Network error'));
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const store = new ComparisonStore();
await store.restoreFromStorage();
expect(consoleSpy).toHaveBeenCalled();
expect(store.fontA).toBeUndefined();
expect(store.fontB).toBeUndefined();
consoleSpy.mockRestore();
});
it('should handle partial restoration when only one font is found', async () => {
// Ensure fontStore is empty so $effect doesn't interfere
(fontStore as any).fonts = [];
mockStorage._value.fontAId = mockFontA.id;
mockStorage._value.fontBId = mockFontB.id;
// Only return fontA (simulating partial data from API)
vi.mocked(fetchFontsByIds).mockResolvedValue([mockFontA]);
const store = new ComparisonStore();
// Wait for async restoration from constructor
await new Promise(resolve => setTimeout(resolve, 10));
// The store should call fetchFontsByIds with both IDs
expect(fetchFontsByIds).toHaveBeenCalledWith([mockFontA.id, mockFontB.id]);
// When only one font is found, the store handles it gracefully
// (both fonts need to be found for restoration to set them)
// The key behavior tested here is that partial fetch doesn't crash
// and the store remains functional
// Store should not have crashed and should be in a valid state
expect(store).toBeDefined();
});
});
describe('Font Loading with CSS Font Loading API', () => {
it('should construct correct font strings for checking', async () => {
mockFontFaceSet.check.mockReturnValue(false);
(fontStore as any).fonts = [mockFontA, mockFontB];
vi.mocked(fetchFontsByIds).mockResolvedValue([mockFontA, mockFontB]);
const store = new ComparisonStore();
store.fontA = mockFontA;
store.fontB = mockFontB;
// Wait for async operations
await new Promise(resolve => setTimeout(resolve, 0));
// Check that font strings are constructed correctly
const expectedFontAString = '400 48px "Roboto"';
const expectedFontBString = '400 48px "Open Sans"';
expect(mockFontFaceSet.load).toHaveBeenCalledWith(expectedFontAString);
expect(mockFontFaceSet.load).toHaveBeenCalledWith(expectedFontBString);
});
it('should handle missing document.fonts API gracefully', () => {
// Delete the fonts property entirely to simulate missing API
delete (document as any).fonts;
const store = new ComparisonStore();
store.fontA = mockFontA;
store.fontB = mockFontB;
// Should not throw and should still work
expect(store.fontA).toStrictEqual(mockFontA);
expect(store.fontB).toStrictEqual(mockFontB);
});
it('should handle font loading errors gracefully', async () => {
// Mock check to return false (fonts not loaded)
mockFontFaceSet.check.mockReturnValue(false);
// Mock load to fail
mockFontFaceSet.load.mockRejectedValue(new Error('Font load failed'));
(fontStore as any).fonts = [mockFontA, mockFontB];
vi.mocked(fetchFontsByIds).mockResolvedValue([mockFontA, mockFontB]);
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const store = new ComparisonStore();
store.fontA = mockFontA;
store.fontB = mockFontB;
// Wait for async operations and timeout fallback
await new Promise(resolve => setTimeout(resolve, 1100));
expect(consoleSpy).toHaveBeenCalled();
consoleSpy.mockRestore();
});
});
describe('Default Values from fontStore', () => {
it('should set default fonts from fontStore when available', () => {
// Note: This test relies on Svelte 5's $effect which may not work
// reliably in the test environment. We test the logic path instead.
(fontStore as any).fonts = [mockFontA, mockFontB];
const store = new ComparisonStore(); const store = new ComparisonStore();
// The default fonts should be set when storage is empty await vi.waitFor(() => {
// In the actual app, this happens via $effect in the constructor
// In tests, we verify the store can have fonts set manually
store.fontA = mockFontA;
store.fontB = mockFontB;
expect(store.fontA).toBeDefined();
expect(store.fontB).toBeDefined();
});
it('should use first and last font from fontStore as defaults', () => {
const mockFontC = UNIFIED_FONTS.lato;
(fontStore as any).fonts = [mockFontA, mockFontC, mockFontB];
const store = new ComparisonStore();
// Manually set the first font to test the logic
store.fontA = mockFontA;
expect(store.fontA?.id).toBe(mockFontA.id); expect(store.fontA?.id).toBe(mockFontA.id);
expect(store.fontB?.id).toBe(mockFontB.id);
}, { timeout: 2000 });
});
it('should handle fetch errors during restoration gracefully', async () => {
mockStorage._value.fontAId = mockFontA.id;
mockStorage._value.fontBId = mockFontB.id;
vi.spyOn(proxyFonts, 'fetchFontsByIds').mockRejectedValue(new Error('Network fail'));
const store = new ComparisonStore();
// Store stays in valid state — no throw, fonts remain undefined
await vi.waitFor(() => expect(store.isLoading).toBe(true)); // stuck loading since no fonts
expect(store.fontA).toBeUndefined();
expect(store.fontB).toBeUndefined();
}); });
}); });
// ── Default Fallbacks ─────────────────────────────────────────────────────
describe('Default Fallbacks', () => {
it('should update storage with default IDs when storage is empty', async () => {
(fontStore as any).fonts = [mockFontA, mockFontB];
vi.spyOn(proxyFonts, 'fetchFontsByIds').mockResolvedValue([mockFontA, mockFontB]);
new ComparisonStore();
await vi.waitFor(() => {
expect(mockStorage._value.fontAId).toBe(mockFontA.id);
expect(mockStorage._value.fontBId).toBe(mockFontB.id);
});
});
});
// ── Loading State ─────────────────────────────────────────────────────────
describe('Aggregate Loading State', () => {
it('should be loading initially when storage has IDs', async () => {
mockStorage._value.fontAId = mockFontA.id;
mockStorage._value.fontBId = mockFontB.id;
vi.spyOn(proxyFonts, 'fetchFontsByIds').mockImplementation(
() => new Promise(r => setTimeout(() => r([mockFontA, mockFontB]), 50)),
);
const store = new ComparisonStore();
expect(store.isLoading).toBe(true);
await vi.waitFor(() => expect(store.isLoading).toBe(false), { timeout: 2000 });
});
});
// ── Reset ─────────────────────────────────────────────────────────────────
describe('Reset Functionality', () => { describe('Reset Functionality', () => {
it('should reset all state and clear storage', () => { it('should reset all state and clear storage', () => {
const store = new ComparisonStore(); const store = new ComparisonStore();
// Set some values
store.fontA = mockFontA;
store.fontB = mockFontB;
store.text = 'Custom text';
store.side = 'B';
store.sliderPosition = 75;
// Reset
store.resetAll(); store.resetAll();
// Check all state is cleared
expect(store.fontA).toBeUndefined();
expect(store.fontB).toBeUndefined();
expect(mockStorage._clear).toHaveBeenCalled(); expect(mockStorage._clear).toHaveBeenCalled();
}); });
it('should reset typography controls when resetAll is called', () => { it('should clear fontA and fontB on reset', async () => {
const mockReset = vi.fn();
vi.mocked(createTypographyControlManager).mockReturnValue({
weight: 400,
renderedSize: 48,
reset: mockReset,
} as any);
const store = new ComparisonStore();
store.resetAll();
expect(mockReset).toHaveBeenCalled();
});
it('should not affect text property on reset', () => {
const store = new ComparisonStore();
store.text = 'Custom text';
store.resetAll();
// Text is not reset by resetAll
expect(store.text).toBe('Custom text');
});
});
describe('isReady Computed State', () => {
it('should return false when fonts are not set', () => {
const store = new ComparisonStore();
expect(store.isReady).toBe(false);
});
it('should return false when only fontA is set', () => {
const store = new ComparisonStore();
store.fontA = mockFontA;
expect(store.isReady).toBe(false);
});
it('should return false when only fontB is set', () => {
const store = new ComparisonStore();
store.fontB = mockFontB;
expect(store.isReady).toBe(false);
});
it('should return true when both fonts are set', () => {
const store = new ComparisonStore();
// Manually set fonts
store.fontA = mockFontA;
store.fontB = mockFontB;
// After setting both fonts, isReady should eventually be true
// Note: In actual testing with Svelte 5 runes, the reactivity
// may not work in Node.js environment, so this tests the logic path
expect(store.fontA).toBeDefined();
expect(store.fontB).toBeDefined();
});
});
describe('isLoading State', () => {
it('should return true when restoring from storage', async () => {
mockStorage._value.fontAId = mockFontA.id; mockStorage._value.fontAId = mockFontA.id;
mockStorage._value.fontBId = mockFontB.id; mockStorage._value.fontBId = mockFontB.id;
vi.spyOn(proxyFonts, 'fetchFontsByIds').mockResolvedValue([mockFontA, mockFontB]);
// Make fetch take some time const store = new ComparisonStore();
vi.mocked(fetchFontsByIds).mockImplementation( await vi.waitFor(() => expect(store.fontA?.id).toBe(mockFontA.id), { timeout: 2000 });
() => new Promise(resolve => setTimeout(() => resolve([mockFontA, mockFontB]), 10)),
store.resetAll();
expect(store.fontA).toBeUndefined();
expect(store.fontB).toBeUndefined();
});
});
// ── Pin / Unpin ───────────────────────────────────────────────────────────
describe('Pin / Unpin (eviction guard)', () => {
it('pins fontA and fontB when they are loaded', async () => {
mockStorage._value.fontAId = mockFontA.id;
mockStorage._value.fontBId = mockFontB.id;
vi.spyOn(proxyFonts, 'fetchFontsByIds').mockResolvedValue([mockFontA, mockFontB]);
new ComparisonStore();
await vi.waitFor(() => {
expect(appliedFontsManager.pin).toHaveBeenCalledWith(
mockFontA.id,
400,
mockFontA.features?.isVariable,
); );
expect(appliedFontsManager.pin).toHaveBeenCalledWith(
mockFontB.id,
400,
mockFontB.features?.isVariable,
);
}, { timeout: 2000 });
});
it('unpins the old font when fontA is replaced', async () => {
mockStorage._value.fontAId = mockFontA.id;
mockStorage._value.fontBId = mockFontB.id;
vi.spyOn(proxyFonts, 'fetchFontsByIds').mockResolvedValue([mockFontA, mockFontB]);
const store = new ComparisonStore(); const store = new ComparisonStore();
const restorePromise = store.restoreFromStorage(); await vi.waitFor(() => expect(store.fontA?.id).toBe(mockFontA.id), { timeout: 2000 });
// While restoring, isLoading should be true const mockFontC: typeof mockFontA = { ...mockFontA, id: 'playfair', name: 'Playfair Display' };
expect(store.isLoading).toBe(true); vi.spyOn(proxyFonts, 'fetchFontsByIds').mockResolvedValue([mockFontC, mockFontB]);
store.fontA = mockFontC;
await restorePromise; await vi.waitFor(() => {
expect(appliedFontsManager.unpin).toHaveBeenCalledWith(
// After restoration, isLoading should be false mockFontA.id,
expect(store.isLoading).toBe(false); 400,
}); mockFontA.features?.isVariable,
}); );
expect(appliedFontsManager.pin).toHaveBeenCalledWith(
describe('Getters and Setters', () => { mockFontC.id,
it('should allow getting and setting sample text', () => { 400,
const store = new ComparisonStore(); mockFontC.features?.isVariable,
);
store.text = 'Hello World'; }, { timeout: 2000 });
expect(store.text).toBe('Hello World');
});
it('should allow getting and setting side', () => {
const store = new ComparisonStore();
expect(store.side).toBe('A');
store.side = 'B';
expect(store.side).toBe('B');
});
it('should allow getting and setting slider position', () => {
const store = new ComparisonStore();
store.sliderPosition = 75;
expect(store.sliderPosition).toBe(75);
});
it('should allow getting typography manager', () => {
const store = new ComparisonStore();
expect(store.typography).toBeDefined();
});
});
describe('Edge Cases', () => {
it('should handle empty font names gracefully', () => {
const emptyFont = { ...mockFontA, name: '' };
const store = new ComparisonStore();
store.fontA = emptyFont;
store.fontB = mockFontB;
// Should not throw
expect(store.fontA).toEqual(emptyFont);
});
it('should handle fontA with undefined name', () => {
const noNameFont = { ...mockFontA, name: undefined as any };
const store = new ComparisonStore();
store.fontA = noNameFont;
expect(store.fontA).toEqual(noNameFont);
});
it('should handle setSide with both valid values', () => {
const store = new ComparisonStore();
store.side = 'A';
expect(store.side).toBe('A');
store.side = 'B';
expect(store.side).toBe('B');
}); });
}); });
}); });

View File

@@ -3,6 +3,7 @@
Renders a single character with morphing animation Renders a single character with morphing animation
--> -->
<script lang="ts"> <script lang="ts">
import { typographySettingsStore } from '$features/SetupFont';
import { cn } from '$shared/shadcn/utils/shadcn-utils'; import { cn } from '$shared/shadcn/utils/shadcn-utils';
import { comparisonStore } from '../../model'; import { comparisonStore } from '../../model';
@@ -25,7 +26,7 @@ let { char, proximity, isPast }: Props = $props();
const fontA = $derived(comparisonStore.fontA); const fontA = $derived(comparisonStore.fontA);
const fontB = $derived(comparisonStore.fontB); const fontB = $derived(comparisonStore.fontB);
const typography = $derived(comparisonStore.typography); const typography = $derived(typographySettingsStore);
let slot = $state<0 | 1>(0); let slot = $state<0 | 1>(0);
let slotFonts = $state<[string, string]>(['', '']); let slotFonts = $state<[string, string]>(['', '']);

View File

@@ -15,14 +15,13 @@ import {
getContext, getContext,
untrack, untrack,
} from 'svelte'; } from 'svelte';
import { comparisonStore } from '../../model';
import FontList from '../FontList/FontList.svelte'; import FontList from '../FontList/FontList.svelte';
import Header from '../Header/Header.svelte'; import Header from '../Header/Header.svelte';
import Sidebar from '../Sidebar/Sidebar.svelte'; import Sidebar from '../Sidebar/Sidebar.svelte';
import SliderArea from '../SliderArea/SliderArea.svelte'; import SliderArea from '../SliderArea/SliderArea.svelte';
const responsive = getContext<ResponsiveManager>('responsive'); const responsive = getContext<ResponsiveManager>('responsive');
const typography = $derived(comparisonStore.typography); // const typography = $derived(comparisonStore.typography);
const isMobileOrTabletPortrait = $derived(responsive.isMobile || responsive.isTabletPortrait); const isMobileOrTabletPortrait = $derived(responsive.isMobile || responsive.isTabletPortrait);
let isSidebarOpen = $state(!isMobileOrTabletPortrait); let isSidebarOpen = $state(!isMobileOrTabletPortrait);
@@ -45,7 +44,7 @@ $effect(() => {
{#snippet main()} {#snippet main()}
<FontList /> <FontList />
{/snippet} {/snippet}
<!--
{#snippet controls()} {#snippet controls()}
{#if typography.sizeControl && typography.weightControl && typography.heightControl && typography.spacingControl} {#if typography.sizeControl && typography.weightControl && typography.heightControl && typography.spacingControl}
<ControlGroup label="Size"> <ControlGroup label="Size">
@@ -89,6 +88,7 @@ $effect(() => {
</div> </div>
{/if} {/if}
{/snippet} {/snippet}
-->
</Sidebar> </Sidebar>
{/snippet} {/snippet}
</SidebarContainer> </SidebarContainer>

View File

@@ -4,6 +4,7 @@
--> -->
<script lang="ts"> <script lang="ts">
import { import {
DEFAULT_FONT_WEIGHT,
FontApplicator, FontApplicator,
FontVirtualList, FontVirtualList,
type UnifiedFont, type UnifiedFont,
@@ -18,8 +19,6 @@ import { crossfade } from 'svelte/transition';
import { comparisonStore } from '../../model'; import { comparisonStore } from '../../model';
const side = $derived(comparisonStore.side); const side = $derived(comparisonStore.side);
const typography = $derived(comparisonStore.typography);
let prevIndexA: number | null = null; let prevIndexA: number | null = null;
let prevIndexB: number | null = null; let prevIndexB: number | null = null;
let selectedIndexA: number | null = null; let selectedIndexA: number | null = null;
@@ -79,7 +78,7 @@ $effect(() => {
</div> </div>
<FontVirtualList <FontVirtualList
data-font-list data-font-list
weight={typography.weight} weight={DEFAULT_FONT_WEIGHT}
itemHeight={45} itemHeight={45}
class="bg-transparent min-h-0 h-full scroll-stable pr-4" class="bg-transparent min-h-0 h-full scroll-stable pr-4"
> >
@@ -95,7 +94,7 @@ $effect(() => {
class="w-full px-3 md:px-4 py-2.5 md:py-3 justify-between text-left text-sm flex" class="w-full px-3 md:px-4 py-2.5 md:py-3 justify-between text-left text-sm flex"
iconPosition="right" iconPosition="right"
> >
<FontApplicator {font} weight={typography.weight}>{font.name}</FontApplicator> <FontApplicator {font}>{font.name}</FontApplicator>
{#snippet icon()} {#snippet icon()}
{#if active} {#if active}

View File

@@ -3,37 +3,40 @@
Renders a line of text in the SliderArea Renders a line of text in the SliderArea
--> -->
<script lang="ts"> <script lang="ts">
import { typographySettingsStore } from '$features/SetupFont';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import { comparisonStore } from '../../model';
interface LineChar {
char: string;
xA: number;
widthA: number;
xB: number;
widthB: number;
}
interface Props { interface Props {
/** /**
* Line text * Pre-computed grapheme array from CharacterComparisonEngine.
* Using the engine's chars array (rather than splitting line.text) ensures
* correct grapheme-cluster boundaries for emoji and multi-codepoint characters.
*/ */
text: string; chars: LineChar[];
/**
* DOM element reference
*/
element?: HTMLElement;
/** /**
* Character render snippet * Character render snippet
*/ */
character: Snippet<[{ char: string; index: number }]>; character: Snippet<[{ char: string; index: number }]>;
} }
const typography = $derived(comparisonStore.typography); const typography = $derived(typographySettingsStore);
let { text, element = $bindable<HTMLElement>(), character }: Props = $props(); let { chars, character }: Props = $props();
const characters = $derived(text.split(''));
</script> </script>
<div <div
bind:this={element}
class="relative flex w-full justify-center items-center whitespace-nowrap" class="relative flex w-full justify-center items-center whitespace-nowrap"
style:height="{typography.height}em" style:height="{typography.height}em"
style:line-height="{typography.height}em" style:line-height="{typography.height}em"
> >
{#each characters as char, index} {#each chars as c, index}
{@render character?.({ char, index })} {@render character?.({ char: c.char, index })}
{/each} {/each}
</div> </div>

View File

@@ -8,12 +8,15 @@
- Performance optimized using offscreen canvas for measurements and transform-based animations. - Performance optimized using offscreen canvas for measurements and transform-based animations.
--> -->
<script lang="ts"> <script lang="ts">
import { TypographyMenu } from '$features/SetupFont';
import { typographySettingsStore } from '$features/SetupFont/model';
import { import {
type CharacterComparison,
type ResponsiveManager, type ResponsiveManager,
createCharacterComparison,
debounce, debounce,
} from '$shared/lib'; } from '$shared/lib';
import {
CharacterComparisonEngine,
} from '$shared/lib/helpers/CharacterComparisonEngine/CharacterComparisonEngine.svelte';
import { cn } from '$shared/shadcn/utils/shadcn-utils'; import { cn } from '$shared/shadcn/utils/shadcn-utils';
import { Loader } from '$shared/ui'; import { Loader } from '$shared/ui';
import { getContext } from 'svelte'; import { getContext } from 'svelte';
@@ -41,25 +44,19 @@ let { isSidebarOpen = false, class: className }: Props = $props();
const fontA = $derived(comparisonStore.fontA); const fontA = $derived(comparisonStore.fontA);
const fontB = $derived(comparisonStore.fontB); const fontB = $derived(comparisonStore.fontB);
const isLoading = $derived(comparisonStore.isLoading || !comparisonStore.isReady); const isLoading = $derived(comparisonStore.isLoading || !comparisonStore.isReady);
const typography = $derived(comparisonStore.typography); const typography = $derived(typographySettingsStore);
let container = $state<HTMLElement>(); let container = $state<HTMLElement>();
let measureCanvas = $state<HTMLCanvasElement>();
const responsive = getContext<ResponsiveManager>('responsive'); const responsive = getContext<ResponsiveManager>('responsive');
const isMobile = $derived(responsive?.isMobile ?? false); const isMobile = $derived(responsive?.isMobile ?? false);
let isDragging = $state(false); let isDragging = $state(false);
const charComparison: CharacterComparison = createCharacterComparison( // New high-performance layout engine
() => comparisonStore.text, const comparisonEngine = new CharacterComparisonEngine();
() => fontA,
() => fontB,
() => typography.weight,
() => typography.renderedSize,
);
let lineElements = $state<(HTMLElement | undefined)[]>([]); let layoutResult = $state<ReturnType<typeof comparisonEngine.layout>>({ lines: [], totalHeight: 0 });
const sliderSpring = new Spring(50, { const sliderSpring = new Spring(50, {
stiffness: 0.2, stiffness: 0.2,
@@ -123,18 +120,41 @@ $effect(() => {
const _weight = typography.weight; const _weight = typography.weight;
const _size = typography.renderedSize; const _size = typography.renderedSize;
const _height = typography.height; const _height = typography.height;
if (container && measureCanvas && fontA && fontB) {
requestAnimationFrame(() => { if (container && fontA && fontB) {
charComparison.breakIntoLines(container, measureCanvas); // PRETEXT API strings: "weight sizepx family"
}); const fontAStr = `${_weight} ${_size}px "${fontA.name}"`;
const fontBStr = `${_weight} ${_size}px "${fontB.name}"`;
// Use offsetWidth to avoid transform scaling issues
const width = container.offsetWidth;
const padding = isMobile ? 48 : 96;
const availableWidth = width - padding;
const lineHeight = _size * 1.2; // Approximate
layoutResult = comparisonEngine.layout(
_text,
fontAStr,
fontBStr,
availableWidth,
lineHeight,
);
} }
}); });
$effect(() => { $effect(() => {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
const handleResize = () => { const handleResize = () => {
if (container && measureCanvas) { if (container && fontA && fontB) {
charComparison.breakIntoLines(container, measureCanvas); const width = container.offsetWidth;
const padding = isMobile ? 48 : 96;
layoutResult = comparisonEngine.layout(
comparisonStore.text,
`${typography.weight} ${typography.renderedSize}px "${fontA.name}"`,
`${typography.weight} ${typography.renderedSize}px "${fontB.name}"`,
width - padding,
typography.renderedSize * 1.2,
);
} }
}; };
window.addEventListener('resize', handleResize); window.addEventListener('resize', handleResize);
@@ -156,20 +176,12 @@ const scaleClass = $derived(
); );
</script> </script>
<!-- Hidden measurement canvas -->
<canvas bind:this={measureCanvas} class="hidden" width="1" height="1"></canvas>
<!-- <!--
Outer flex container — fills parent. Outer flex container — fills parent.
The paper div inside scales down when the sidebar opens on desktop. The paper div inside scales down when the sidebar opens on desktop.
--> -->
<div class={cn('flex-1 relative flex items-center justify-center p-0 overflow-hidden bg-surface dark:bg-dark-bg', className)}> <div class={cn('flex-1 relative flex items-center justify-center p-0 overflow-hidden bg-surface dark:bg-dark-bg', className)}>
<!-- <!-- Paper surface -->
Paper surface.
Replaces the old glassmorphism card with a clean white/dark sheet.
Scale transition replaces motion.div spring — CSS transition-transform
is smooth enough here; a JS spring would add ~4kb for minimal gain.
-->
<div <div
class={cn( class={cn(
'w-full h-full flex flex-col items-center justify-center relative', 'w-full h-full flex flex-col items-center justify-center relative',
@@ -218,10 +230,10 @@ const scaleClass = $derived(
my-auto my-auto
" "
> >
{#each charComparison.lines as line, lineIndex} {#each layoutResult.lines as line, lineIndex}
<Line bind:element={lineElements[lineIndex]} text={line.text}> <Line chars={line.chars}>
{#snippet character({ char, index })} {#snippet character({ char, index })}
{@const { proximity, isPast } = charComparison.getCharState(index, sliderPos, lineElements[lineIndex], container)} {@const { proximity, isPast } = comparisonEngine.getCharState(lineIndex, index, sliderPos, container?.offsetWidth ?? 0)}
<Character {char} {proximity} {isPast} /> <Character {char} {proximity} {isPast} />
{/snippet} {/snippet}
</Line> </Line>
@@ -233,4 +245,10 @@ const scaleClass = $derived(
{/if} {/if}
</div> </div>
</div> </div>
<TypographyMenu
class={cn(
'absolute bottom-4 sm:bottom-5 right-4 sm:left-1/2 sm:right-[unset] sm:-translate-x-1/2 z-50',
)}
/>
</div> </div>

View File

@@ -5,22 +5,45 @@
- Provides a typography menu for font setup. - Provides a typography menu for font setup.
--> -->
<script lang="ts"> <script lang="ts">
import { FontVirtualList } from '$entities/Font'; import {
FontVirtualList,
appliedFontsManager,
createFontRowSizeResolver,
fontStore,
} from '$entities/Font';
import { FontSampler } from '$features/DisplayFont'; import { FontSampler } from '$features/DisplayFont';
import { import {
TypographyMenu, TypographyMenu,
controlManager, typographySettingsStore,
} from '$features/SetupFont'; } from '$features/SetupFont';
import { throttle } from '$shared/lib/utils'; import { throttle } from '$shared/lib/utils';
import { Skeleton } from '$shared/ui'; import { Skeleton } from '$shared/ui';
import { layoutManager } from '../../model'; import { layoutManager } from '../../model';
// FontSampler chrome heights — derived from Tailwind classes in FontSampler.svelte.
// Header: py-3 (12+12px padding) + ~32px content row ≈ 56px.
// Only the header is counted; the mobile footer (md:hidden) is excluded because
// on desktop, where container widths are wide and estimates matter most, it is invisible.
// Over-estimating chrome is safe (row is slightly taller than text needs, never cut off).
const SAMPLER_CHROME_HEIGHT = 56;
// p-4 = 16px per side = 32px total horizontal padding in FontSampler's content area.
// Using the smallest breakpoint (mobile) ensures contentWidth is never over-estimated:
// wider actual padding → more text wrapping → pretext height ≥ rendered height → safe.
const SAMPLER_CONTENT_PADDING_X = 32;
// Fallback row height used when the font has not loaded yet.
// Matches the previous hardcoded itemHeight={220} value to avoid regressions.
const SAMPLER_FALLBACK_HEIGHT = 220;
let text = $state('The quick brown fox jumps over the lazy dog...'); let text = $state('The quick brown fox jumps over the lazy dog...');
let wrapper = $state<HTMLDivElement | null>(null); let wrapper = $state<HTMLDivElement | null>(null);
// Binds to the actual window height // Binds to the actual window height
let innerHeight = $state(0); let innerHeight = $state(0);
// Is the component above the middle of the viewport? // Is the component above the middle of the viewport?
let isAboveMiddle = $state(false); let isAboveMiddle = $state(false);
// Inner width of the wrapper div — updated by bind:clientWidth on mount and resize.
let containerWidth = $state(0);
const checkPosition = throttle(() => { const checkPosition = throttle(() => {
if (!wrapper) return; if (!wrapper) return;
@@ -30,6 +53,24 @@ const checkPosition = throttle(() => {
isAboveMiddle = rect.top < viewportMiddle; isAboveMiddle = rect.top < viewportMiddle;
}, 100); }, 100);
// Resolver recreated when typography values change. The returned closure reads
// appliedFontsManager.statuses (a SvelteMap) on every call, so any font status
// change triggers a full offsets recompute in createVirtualizer — no DOM snap.
const fontRowHeight = $derived.by(() =>
createFontRowSizeResolver({
getFonts: () => fontStore.fonts,
getWeight: () => typographySettingsStore.weight,
getPreviewText: () => text,
getContainerWidth: () => containerWidth,
getFontSizePx: () => typographySettingsStore.renderedSize,
getLineHeightPx: () => typographySettingsStore.height * typographySettingsStore.renderedSize,
getStatus: key => appliedFontsManager.statuses.get(key),
contentHorizontalPadding: SAMPLER_CONTENT_PADDING_X,
chromeHeight: SAMPLER_CHROME_HEIGHT,
fallbackHeight: SAMPLER_FALLBACK_HEIGHT,
})
);
</script> </script>
{#snippet skeleton()} {#snippet skeleton()}
@@ -52,11 +93,11 @@ const checkPosition = throttle(() => {
onresize={checkPosition} onresize={checkPosition}
/> />
<div bind:this={wrapper}> <div bind:this={wrapper} bind:clientWidth={containerWidth}>
<FontVirtualList <FontVirtualList
itemHeight={220} itemHeight={fontRowHeight}
useWindowScroll={true} useWindowScroll={true}
weight={controlManager.weight} weight={typographySettingsStore.weight}
columns={layoutManager.columns} columns={layoutManager.columns}
gap={layoutManager.gap} gap={layoutManager.gap}
{skeleton} {skeleton}

View File

@@ -122,6 +122,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@chenglou/pretext@npm:^0.0.5":
version: 0.0.5
resolution: "@chenglou/pretext@npm:0.0.5"
checksum: 10c0/5139b39a166fbe7d1e0cf31c95f83125cc0658d8951b19dff3ac14b94d08c2bb53e954801c0325dac79c5b2b21157fa7763e0c561d46773baa37253f1a526242
languageName: node
linkType: hard
"@chromatic-com/storybook@npm:^4.1.3": "@chromatic-com/storybook@npm:^4.1.3":
version: 4.1.3 version: 4.1.3
resolution: "@chromatic-com/storybook@npm:4.1.3" resolution: "@chromatic-com/storybook@npm:4.1.3"
@@ -2436,6 +2443,7 @@ __metadata:
version: 0.0.0-use.local version: 0.0.0-use.local
resolution: "glyphdiff@workspace:." resolution: "glyphdiff@workspace:."
dependencies: dependencies:
"@chenglou/pretext": "npm:^0.0.5"
"@chromatic-com/storybook": "npm:^4.1.3" "@chromatic-com/storybook": "npm:^4.1.3"
"@internationalized/date": "npm:^3.10.0" "@internationalized/date": "npm:^3.10.0"
"@lucide/svelte": "npm:^0.561.0" "@lucide/svelte": "npm:^0.561.0"