chore: follow the general comments style

This commit is contained in:
Ilia Mashkov
2026-04-17 12:14:55 +03:00
parent 0ebf75b24e
commit cfaff46d59
56 changed files with 1600 additions and 499 deletions

View File

@@ -97,16 +97,24 @@ export interface ProxyFontsParams extends QueryParams {
* Includes pagination metadata alongside font data
*/
export interface ProxyFontsResponse {
/** Array of unified font objects */
/**
* List of font objects returned by the proxy
*/
fonts: UnifiedFont[];
/** Total number of fonts matching the query */
/**
* Total number of matching fonts (ignoring limit/offset)
*/
total: number;
/** Limit used for this request */
/**
* Page size used for the request
*/
limit: number;
/** Offset used for this request */
/**
* Start index for the result set
*/
offset: number;
}

View File

@@ -3,7 +3,9 @@ import type {
UnifiedFont,
} from '../../model';
/** Valid font weight values (100-900 in increments of 100) */
/**
* Valid font weight values (100-900 in increments of 100)
*/
const SIZES = [100, 200, 300, 400, 500, 600, 700, 800, 900];
/**

View File

@@ -1,31 +1,3 @@
/**
* Mock font filter data
*
* Factory functions and preset mock data for font-related filters.
* Used in Storybook stories for font filtering components.
*
* ## Usage
*
* ```ts
* import {
* createMockFilter,
* MOCK_FILTERS,
* } from '$entities/Font/lib/mocks';
*
* // Create a custom filter
* const customFilter = createMockFilter({
* properties: [
* { id: 'option1', name: 'Option 1', value: 'option1' },
* { id: 'option2', name: 'Option 2', value: 'option2', selected: true },
* ],
* });
*
* // Use preset filters
* const categoriesFilter = MOCK_FILTERS.categories;
* const subsetsFilter = MOCK_FILTERS.subsets;
* ```
*/
import type {
FontCategory,
FontProvider,
@@ -34,13 +6,13 @@ import type {
import type { Property } from '$shared/lib';
import { createFilter } from '$shared/lib';
// TYPE DEFINITIONS
/**
* Options for creating a mock filter
*/
export interface MockFilterOptions {
/** Filter properties */
/**
* Initial set of properties for the mock filter
*/
properties: Property<string>[];
}
@@ -48,16 +20,20 @@ export interface MockFilterOptions {
* Preset mock filters for font filtering
*/
export interface MockFilters {
/** Provider filter (Google, Fontshare) */
/**
* Provider filter (Google, Fontshare)
*/
providers: ReturnType<typeof createFilter<'google' | 'fontshare'>>;
/** Category filter (sans-serif, serif, display, etc.) */
/**
* Category filter (sans-serif, serif, display, etc.)
*/
categories: ReturnType<typeof createFilter<FontCategory>>;
/** Subset filter (latin, latin-ext, cyrillic, etc.) */
/**
* Subset filter (latin, latin-ext, cyrillic, etc.)
*/
subsets: ReturnType<typeof createFilter<FontSubset>>;
}
// FONT CATEGORIES
/**
* Unified categories (combines both providers)
*/
@@ -71,8 +47,6 @@ export const UNIFIED_CATEGORIES: Property<FontCategory>[] = [
{ id: 'script', name: 'Script', value: 'script' },
];
// FONT SUBSETS
/**
* Common font subsets
*/
@@ -85,8 +59,6 @@ export const FONT_SUBSETS: Property<FontSubset>[] = [
{ id: 'devanagari', name: 'Devanagari', value: 'devanagari' },
];
// FONT PROVIDERS
/**
* Font providers
*/
@@ -95,8 +67,6 @@ export const FONT_PROVIDERS: Property<FontProvider>[] = [
{ id: 'fontshare', name: 'Fontshare', value: 'fontshare' },
];
// FILTER FACTORIES
/**
* Create a mock filter from properties
*/
@@ -139,8 +109,6 @@ export function createProvidersFilter(options?: { selected?: FontProvider[] }) {
return createFilter<FontProvider>({ properties });
}
// PRESET FILTERS
/**
* Preset mock filters - use these directly in stories
*/
@@ -216,8 +184,6 @@ export const MOCK_FILTERS_ALL_SELECTED: MockFilters = {
}),
};
// GENERIC FILTER MOCKS
/**
* Create a mock filter with generic string properties
* Useful for testing generic filter components
@@ -239,7 +205,9 @@ export function createGenericFilter(
* Preset generic filters for testing
*/
export const GENERIC_FILTERS = {
/** Small filter with 3 items */
/**
* Small filter with 3 items
*/
small: createFilter({
properties: [
{ id: 'option-1', name: 'Option 1', value: 'option-1' },
@@ -247,7 +215,9 @@ export const GENERIC_FILTERS = {
{ id: 'option-3', name: 'Option 3', value: 'option-3' },
],
}),
/** Medium filter with 6 items */
/**
* Medium filter with 6 items
*/
medium: createFilter({
properties: [
{ id: 'alpha', name: 'Alpha', value: 'alpha' },
@@ -258,7 +228,9 @@ export const GENERIC_FILTERS = {
{ id: 'zeta', name: 'Zeta', value: 'zeta' },
],
}),
/** Large filter with 12 items */
/**
* Large filter with 12 items
*/
large: createFilter({
properties: [
{ id: 'jan', name: 'January', value: 'jan' },
@@ -275,7 +247,9 @@ export const GENERIC_FILTERS = {
{ id: 'dec', name: 'December', value: 'dec' },
],
}),
/** Filter with some pre-selected items */
/**
* Filter with some pre-selected items
*/
partial: createFilter({
properties: [
{ id: 'red', name: 'Red', value: 'red', selected: true },
@@ -284,7 +258,9 @@ export const GENERIC_FILTERS = {
{ id: 'yellow', name: 'Yellow', value: 'yellow', selected: false },
],
}),
/** Filter with all items selected */
/**
* Filter with all items selected
*/
allSelected: createFilter({
properties: [
{ id: 'cat', name: 'Cat', value: 'cat', selected: true },
@@ -292,7 +268,9 @@ export const GENERIC_FILTERS = {
{ id: 'bird', name: 'Bird', value: 'bird', selected: true },
],
}),
/** Empty filter (no items) */
/**
* Empty filter (no items)
*/
empty: createFilter({
properties: [],
}),

View File

@@ -51,23 +51,41 @@ import type {
* Options for creating a mock UnifiedFont
*/
export interface MockUnifiedFontOptions {
/** Unique identifier (default: derived from name) */
/**
* Unique identifier (default: derived from name)
*/
id?: string;
/** Font display name (default: 'Mock Font') */
/**
* Font display name (default: 'Mock Font')
*/
name?: string;
/** Font provider (default: 'google') */
/**
* Font provider (default: 'google')
*/
provider?: FontProvider;
/** Font category (default: 'sans-serif') */
/**
* Font category (default: 'sans-serif')
*/
category?: FontCategory;
/** Font subsets (default: ['latin']) */
/**
* Font subsets (default: ['latin'])
*/
subsets?: FontSubset[];
/** Font variants (default: ['regular', '700', 'italic', '700italic']) */
/**
* Font variants (default: ['regular', '700', 'italic', '700italic'])
*/
variants?: FontVariant[];
/** Style URLs (if not provided, mock URLs are generated) */
/**
* Style URLs (if not provided, mock URLs are generated)
*/
styles?: FontStyleUrls;
/** Metadata overrides */
/**
* Metadata overrides
*/
metadata?: Partial<FontMetadata>;
/** Features overrides */
/**
* Features overrides
*/
features?: Partial<FontFeatures>;
}

View File

@@ -1,8 +1,4 @@
/**
* ============================================================================
* MOCK FONT STORE HELPERS
* ============================================================================
*
* Factory functions and preset mock data for TanStack Query stores and state management.
* Used in Storybook stories for components that use reactive stores.
*
@@ -35,27 +31,73 @@ import {
generateMockFonts,
} from './fonts.mock';
// TANSTACK QUERY MOCK TYPES
/**
* Mock TanStack Query state
*/
export interface MockQueryState<TData = unknown, TError = Error> {
/**
* Primary query status (pending, success, error)
*/
status: QueryStatus;
/**
* Payload data (present on success)
*/
data?: TData;
/**
* Caught error object (present on error)
*/
error?: TError;
/**
* True if initial load is in progress
*/
isLoading?: boolean;
/**
* True if background fetch is in progress
*/
isFetching?: boolean;
/**
* True if query resolved successfully
*/
isSuccess?: boolean;
/**
* True if query failed
*/
isError?: boolean;
/**
* True if query is waiting to be executed
*/
isPending?: boolean;
/**
* Timestamp of last successful data retrieval
*/
dataUpdatedAt?: number;
/**
* Timestamp of last recorded error
*/
errorUpdatedAt?: number;
/**
* Total number of consecutive failures
*/
failureCount?: number;
/**
* Detailed reason for the last failure
*/
failureReason?: TError;
/**
* Number of times an error has been caught
*/
errorUpdateCount?: number;
/**
* True if currently refetching in background
*/
isRefetching?: boolean;
/**
* True if refetch attempt failed
*/
isRefetchError?: boolean;
/**
* True if query is paused (e.g. offline)
*/
isPaused?: boolean;
}
@@ -63,26 +105,72 @@ export interface MockQueryState<TData = unknown, TError = Error> {
* Mock TanStack Query observer result
*/
export interface MockQueryObserverResult<TData = unknown, TError = Error> {
/**
* Current observer status
*/
status?: QueryStatus;
/**
* Cached or active data payload
*/
data?: TData;
/**
* Caught error from the observer
*/
error?: TError;
/**
* Loading flag for the observer
*/
isLoading?: boolean;
/**
* Fetching flag for the observer
*/
isFetching?: boolean;
/**
* Success flag for the observer
*/
isSuccess?: boolean;
/**
* Error flag for the observer
*/
isError?: boolean;
/**
* Pending flag for the observer
*/
isPending?: boolean;
/**
* Last update time for data
*/
dataUpdatedAt?: number;
/**
* Last update time for error
*/
errorUpdatedAt?: number;
/**
* Consecutive failure count
*/
failureCount?: number;
/**
* Failure reason object
*/
failureReason?: TError;
/**
* Error count for the observer
*/
errorUpdateCount?: number;
/**
* Refetching flag
*/
isRefetching?: boolean;
/**
* Refetch error flag
*/
isRefetchError?: boolean;
/**
* Paused flag
*/
isPaused?: boolean;
}
// TANSTACK QUERY MOCK FACTORIES
/**
* Create a mock query state for TanStack Query
*/
@@ -138,33 +226,53 @@ export function createSuccessState<TData>(data: TData): MockQueryObserverResult<
return createMockQueryState<TData>({ status: 'success', data, error: undefined });
}
// FONT STORE MOCKS
/**
* Mock UnifiedFontStore state
*/
export interface MockFontStoreState {
/** All cached fonts */
/**
* Map of mock fonts indexed by ID
*/
fonts: Record<string, UnifiedFont>;
/** Current page */
/**
* Currently active page number
*/
page: number;
/** Total pages available */
/**
* Total number of pages calculated from limit
*/
totalPages: number;
/** Items per page */
/**
* Number of items per page
*/
limit: number;
/** Total font count */
/**
* Total number of available fonts
*/
total: number;
/** Loading state */
/**
* Store-level loading status
*/
isLoading: boolean;
/** Error state */
/**
* Caught error object
*/
error: Error | null;
/** Search query */
/**
* Mock search filter string
*/
searchQuery: string;
/** Selected provider */
/**
* Mock provider filter selection
*/
provider: 'google' | 'fontshare' | 'all';
/** Selected category */
/**
* Mock category filter selection
*/
category: string | null;
/** Selected subset */
/**
* Mock subset filter selection
*/
subset: string | null;
}
@@ -210,10 +318,12 @@ export function createMockFontStoreState(
}
/**
* Preset font store states
* Preset font store states for UI testing
*/
export const MOCK_FONT_STORE_STATES = {
/** Initial loading state */
/**
* Initial loading state with no data
*/
loading: createMockFontStoreState({
isLoading: true,
fonts: {},
@@ -221,7 +331,9 @@ export const MOCK_FONT_STORE_STATES = {
page: 1,
}),
/** Empty state (no fonts found) */
/**
* State with no fonts matching filters
*/
empty: createMockFontStoreState({
fonts: {},
total: 0,
@@ -229,7 +341,9 @@ export const MOCK_FONT_STORE_STATES = {
isLoading: false,
}),
/** First page with fonts */
/**
* First page of results (10 items)
*/
firstPage: createMockFontStoreState({
fonts: Object.fromEntries(
Object.values(UNIFIED_FONTS).slice(0, 10).map(font => [font.id, font]),
@@ -241,7 +355,9 @@ export const MOCK_FONT_STORE_STATES = {
isLoading: false,
}),
/** Second page with fonts */
/**
* Second page of results (10 items)
*/
secondPage: createMockFontStoreState({
fonts: Object.fromEntries(
Object.values(UNIFIED_FONTS).slice(10, 20).map(font => [font.id, font]),
@@ -253,7 +369,9 @@ export const MOCK_FONT_STORE_STATES = {
isLoading: false,
}),
/** Last page with fonts */
/**
* Final page of results (5 items)
*/
lastPage: createMockFontStoreState({
fonts: Object.fromEntries(
Object.values(UNIFIED_FONTS).slice(0, 5).map(font => [font.id, font]),
@@ -265,7 +383,9 @@ export const MOCK_FONT_STORE_STATES = {
isLoading: false,
}),
/** Error state */
/**
* Terminal failure state
*/
error: createMockFontStoreState({
fonts: {},
error: new Error('Failed to load fonts'),
@@ -274,7 +394,9 @@ export const MOCK_FONT_STORE_STATES = {
isLoading: false,
}),
/** With search query */
/**
* State with active search query
*/
withSearch: createMockFontStoreState({
fonts: Object.fromEntries(
Object.values(UNIFIED_FONTS).slice(0, 3).map(font => [font.id, font]),
@@ -285,7 +407,9 @@ export const MOCK_FONT_STORE_STATES = {
searchQuery: 'Roboto',
}),
/** Filtered by category */
/**
* State with active category filter
*/
filteredByCategory: createMockFontStoreState({
fonts: Object.fromEntries(
Object.values(UNIFIED_FONTS)
@@ -299,7 +423,9 @@ export const MOCK_FONT_STORE_STATES = {
category: 'serif',
}),
/** Filtered by provider */
/**
* State with active provider filter
*/
filteredByProvider: createMockFontStoreState({
fonts: Object.fromEntries(
Object.values(UNIFIED_FONTS)
@@ -313,7 +439,9 @@ export const MOCK_FONT_STORE_STATES = {
provider: 'google',
}),
/** Large dataset */
/**
* Large collection for performance testing (50 items)
*/
largeDataset: createMockFontStoreState({
fonts: Object.fromEntries(
generateMockFonts(50).map(font => [font.id, font]),
@@ -326,17 +454,30 @@ export const MOCK_FONT_STORE_STATES = {
}),
};
// MOCK STORE OBJECT
/**
* Create a mock store object that mimics TanStack Query behavior
* Useful for components that subscribe to store properties
*/
export function createMockStore<T>(config: {
/**
* Reactive data payload
*/
data?: T;
/**
* Loading status flag
*/
isLoading?: boolean;
/**
* Error status flag
*/
isError?: boolean;
/**
* Catch-all error object
*/
error?: Error;
/**
* Background fetching flag
*/
isFetching?: boolean;
}) {
const {
@@ -348,24 +489,45 @@ export function createMockStore<T>(config: {
} = config;
return {
/**
* Returns the active data payload
*/
get data() {
return data;
},
/**
* True if initially loading
*/
get isLoading() {
return isLoading;
},
/**
* True if last request failed
*/
get isError() {
return isError;
},
/**
* Returns the caught error object
*/
get error() {
return error;
},
/**
* True if fetching in background
*/
get isFetching() {
return isFetching;
},
/**
* True if query is stable and has data
*/
get isSuccess() {
return !isLoading && !isError && data !== undefined;
},
/**
* Returns semantic status string
*/
get status() {
if (isLoading) return 'pending';
if (isError) return 'error';
@@ -375,23 +537,29 @@ export function createMockStore<T>(config: {
}
/**
* Preset mock stores
* Preset mock stores for common UI states
*/
export const MOCK_STORES = {
/** Font store in loading state */
/**
* Initial loading state
*/
loadingFontStore: createMockStore<UnifiedFont[]>({
isLoading: true,
data: undefined,
}),
/** Font store with fonts loaded */
/**
* Successful data load state
*/
successFontStore: createMockStore<UnifiedFont[]>({
data: Object.values(UNIFIED_FONTS),
isLoading: false,
isError: false,
}),
/** Font store with error */
/**
* API error state
*/
errorFontStore: createMockStore<UnifiedFont[]>({
data: undefined,
isLoading: false,
@@ -399,7 +567,9 @@ export const MOCK_STORES = {
error: new Error('Failed to load fonts'),
}),
/** Font store with empty results */
/**
* Empty result set state
*/
emptyFontStore: createMockStore<UnifiedFont[]>({
data: [],
isLoading: false,
@@ -414,36 +584,69 @@ export const MOCK_STORES = {
const mockState = createMockFontStoreState(state);
return {
// State properties
/**
* Collection of mock fonts
*/
get fonts() {
return mockState.fonts;
},
/**
* Current mock page
*/
get page() {
return mockState.page;
},
/**
* Total mock pages
*/
get totalPages() {
return mockState.totalPages;
},
/**
* Mock items per page
*/
get limit() {
return mockState.limit;
},
/**
* Total mock items
*/
get total() {
return mockState.total;
},
/**
* Mock loading status
*/
get isLoading() {
return mockState.isLoading;
},
/**
* Mock error status
*/
get error() {
return mockState.error;
},
/**
* Mock search string
*/
get searchQuery() {
return mockState.searchQuery;
},
/**
* Mock provider filter
*/
get provider() {
return mockState.provider;
},
/**
* Mock category filter
*/
get category() {
return mockState.category;
},
/**
* Mock subset filter
*/
get subset() {
return mockState.subset;
},
@@ -464,15 +667,45 @@ export const MOCK_STORES = {
* Matches FontStore's public API for Storybook use
*/
fontStore: (config: {
/**
* Preset font list
*/
fonts?: UnifiedFont[];
/**
* Total item count
*/
total?: number;
/**
* Items per page
*/
limit?: number;
/**
* Pagination offset
*/
offset?: number;
/**
* Loading flag
*/
isLoading?: boolean;
/**
* Fetching flag
*/
isFetching?: boolean;
/**
* Error flag
*/
isError?: boolean;
/**
* Catch-all error object
*/
error?: Error | null;
/**
* Has more pages flag
*/
hasMore?: boolean;
/**
* Current page number
*/
page?: number;
} = {}) => {
const {
@@ -495,27 +728,51 @@ export const MOCK_STORES = {
return {
// State getters
/**
* Current mock parameters
*/
get params() {
return state.params;
},
/**
* Mock font list
*/
get fonts() {
return mockFonts;
},
/**
* Mock loading state
*/
get isLoading() {
return isLoading;
},
/**
* Mock fetching state
*/
get isFetching() {
return isFetching;
},
/**
* Mock error state
*/
get isError() {
return isError;
},
/**
* Mock error object
*/
get error() {
return error;
},
/**
* Mock empty state check
*/
get isEmpty() {
return !isLoading && !isFetching && mockFonts.length === 0;
},
/**
* Mock pagination metadata
*/
get pagination() {
return {
total: mockTotal,
@@ -527,18 +784,33 @@ export const MOCK_STORES = {
};
},
// Category getters
/**
* Derived sans-serif filter
*/
get sansSerifFonts() {
return mockFonts.filter(f => f.category === 'sans-serif');
},
/**
* Derived serif filter
*/
get serifFonts() {
return mockFonts.filter(f => f.category === 'serif');
},
/**
* Derived display filter
*/
get displayFonts() {
return mockFonts.filter(f => f.category === 'display');
},
/**
* Derived handwriting filter
*/
get handwritingFonts() {
return mockFonts.filter(f => f.category === 'handwriting');
},
/**
* Derived monospace filter
*/
get monospaceFonts() {
return mockFonts.filter(f => f.category === 'monospace');
},

View File

@@ -13,15 +13,25 @@ import type {
* (e.g. `SvelteMap.get()`) is automatically tracked as a dependency.
*/
export interface FontRowSizeResolverOptions {
/** Returns the current fonts array. Index `i` corresponds to row `i`. */
/**
* Returns the current fonts array. Index `i` corresponds to row `i`.
*/
getFonts: () => UnifiedFont[];
/** Returns the active font weight (e.g. 400). */
/**
* Returns the active font weight (e.g. 400).
*/
getWeight: () => number;
/** Returns the preview text string. */
/**
* Returns the preview text string.
*/
getPreviewText: () => string;
/** Returns the scroll container's inner width in pixels. Returns 0 before mount. */
/**
* Returns the scroll container's inner width in pixels. Returns 0 before mount.
*/
getContainerWidth: () => number;
/** Returns the font size in pixels (e.g. `controlManager.renderedSize`). */
/**
* Returns the font size in pixels (e.g. `controlManager.renderedSize`).
*/
getFontSizePx: () => number;
/**
* Returns the computed line height in pixels.
@@ -44,9 +54,13 @@ export interface FontRowSizeResolverOptions {
* 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.). */
/**
* 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. */
/**
* Height in pixels to return when the font is not loaded or container width is 0.
*/
fallbackHeight: number;
}

View File

@@ -1,10 +1,10 @@
/** @vitest-environment jsdom */
/**
* @vitest-environment jsdom
*/
import { AppliedFontsManager } from './appliedFontsStore.svelte';
import { FontFetchError } from './errors';
import { FontEvictionPolicy } from './utils/fontEvictionPolicy/FontEvictionPolicy';
// ── Fake collaborators ────────────────────────────────────────────────────────
class FakeBufferCache {
async get(_url: string): Promise<ArrayBuffer> {
return new ArrayBuffer(8);
@@ -13,7 +13,9 @@ class FakeBufferCache {
clear(): void {}
}
/** Throws {@link FontFetchError} on every `get()` — simulates network/HTTP failure. */
/**
* Throws {@link FontFetchError} on every `get()` — simulates network/HTTP failure.
*/
class FailingBufferCache {
async get(url: string): Promise<never> {
throw new FontFetchError(url, new Error('network error'), 500);
@@ -22,8 +24,6 @@ class FailingBufferCache {
clear(): void {}
}
// ── Helpers ───────────────────────────────────────────────────────────────────
const makeConfig = (id: string, overrides: Partial<{ weight: number; isVariable: boolean }> = {}) => ({
id,
name: id,
@@ -32,8 +32,6 @@ const makeConfig = (id: string, overrides: Partial<{ weight: number; isVariable:
...overrides,
});
// ── Suite ─────────────────────────────────────────────────────────────────────
describe('AppliedFontsManager', () => {
let manager: AppliedFontsManager;
let eviction: FontEvictionPolicy;
@@ -66,8 +64,6 @@ describe('AppliedFontsManager', () => {
vi.unstubAllGlobals();
});
// ── touch() ───────────────────────────────────────────────────────────────
describe('touch()', () => {
it('queues and loads a new font', async () => {
manager.touch([makeConfig('roboto')]);
@@ -131,8 +127,6 @@ describe('AppliedFontsManager', () => {
});
});
// ── queue processing ──────────────────────────────────────────────────────
describe('queue processing', () => {
it('filters non-critical weights in data-saver mode', async () => {
(navigator as any).connection = { saveData: true };
@@ -163,8 +157,6 @@ describe('AppliedFontsManager', () => {
});
});
// ── Phase 1: fetch ────────────────────────────────────────────────────────
describe('Phase 1 — fetch', () => {
it('sets status to error on fetch failure', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
@@ -209,8 +201,6 @@ describe('AppliedFontsManager', () => {
});
});
// ── Phase 2: parse ────────────────────────────────────────────────────────
describe('Phase 2 — parse', () => {
it('sets status to error on parse failure', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
@@ -241,8 +231,6 @@ describe('AppliedFontsManager', () => {
});
});
// ── #purgeUnused ──────────────────────────────────────────────────────────
describe('#purgeUnused', () => {
it('evicts fonts after TTL expires', async () => {
manager.touch([makeConfig('ephemeral')]);
@@ -300,8 +288,6 @@ describe('AppliedFontsManager', () => {
});
});
// ── destroy() ─────────────────────────────────────────────────────────────
describe('destroy()', () => {
it('clears all statuses', async () => {
manager.touch([makeConfig('roboto')]);

View File

@@ -156,7 +156,9 @@ export class AppliedFontsManager {
}
}
/** Returns true if data-saver mode is enabled (defers non-critical weights). */
/**
* Returns true if data-saver mode is enabled (defers non-critical weights).
*/
#shouldDeferNonCritical(): boolean {
return (navigator as any).connection?.saveData === true;
}
@@ -188,13 +190,11 @@ export class AppliedFontsManager {
const concurrency = getEffectiveConcurrency();
const buffers = new Map<string, ArrayBuffer>();
// ==================== PHASE 1: Concurrent Fetching ====================
// Fetch multiple font files in parallel since network I/O is non-blocking
for (let i = 0; i < entries.length; i += concurrency) {
await this.#fetchChunk(entries.slice(i, i + concurrency), buffers);
}
// ==================== PHASE 2: Sequential Parsing ====================
// Parse buffers one at a time with periodic yields to avoid blocking UI
const hasInputPending = !!(navigator as any).scheduling?.isInputPending;
let lastYield = performance.now();
@@ -279,7 +279,9 @@ export class AppliedFontsManager {
}
}
/** Removes fonts unused within TTL (LRU-style cleanup). Runs every PURGE_INTERVAL. Pinned fonts are never evicted. */
/**
* Removes fonts unused within TTL (LRU-style cleanup). Runs every PURGE_INTERVAL. Pinned fonts are never evicted.
*/
#purgeUnused() {
const now = Date.now();
// Iterate through all tracked font keys
@@ -307,7 +309,9 @@ export class AppliedFontsManager {
}
}
/** Returns current loading status for a font, or undefined if never requested. */
/**
* Returns current loading status for a font, or undefined if never requested.
*/
getFontStatus(id: string, weight: number, isVariable = false) {
try {
return this.statuses.get(generateFontKey({ id, weight, isVariable }));
@@ -316,17 +320,23 @@ export class AppliedFontsManager {
}
}
/** Pins a font so it is never evicted by #purgeUnused(), regardless of TTL. */
/**
* Pins a font so it is never evicted by #purgeUnused(), regardless of TTL.
*/
pin(id: string, weight: number, isVariable = false): void {
this.#eviction.pin(generateFontKey({ id, weight, isVariable }));
}
/** Unpins a font, allowing it to be evicted by #purgeUnused() once its TTL expires. */
/**
* Unpins a font, allowing it to be evicted by #purgeUnused() once its TTL expires.
*/
unpin(id: string, weight: number, isVariable = false): void {
this.#eviction.unpin(generateFontKey({ id, weight, isVariable }));
}
/** Waits for all fonts to finish loading using document.fonts.ready. */
/**
* Waits for all fonts to finish loading using document.fonts.ready.
*/
async ready(): Promise<void> {
if (typeof document === 'undefined') {
return;
@@ -336,7 +346,9 @@ export class AppliedFontsManager {
} catch { /* document unloaded */ }
}
/** Aborts all operations, removes fonts from document, and clears state. Manager cannot be reused after. */
/**
* Aborts all operations, removes fonts from document, and clears state. Manager cannot be reused after.
*/
destroy() {
// Abort all in-flight network requests
this.#abortController.abort();
@@ -375,5 +387,7 @@ export class AppliedFontsManager {
}
}
/** Singleton instance — use throughout the application for unified font loading state. */
/**
* Singleton instance — use throughout the application for unified font loading state.
*/
export const appliedFontsManager = new AppliedFontsManager();

View File

@@ -1,4 +1,6 @@
/** @vitest-environment jsdom */
/**
* @vitest-environment jsdom
*/
import { FontFetchError } from '../../errors';
import { FontBufferCache } from './FontBufferCache';

View File

@@ -3,9 +3,13 @@ import { FontFetchError } from '../../errors';
type Fetcher = (url: string, init?: RequestInit) => Promise<Response>;
interface FontBufferCacheOptions {
/** Custom fetch implementation. Defaults to `globalThis.fetch`. Inject in tests for isolation. */
/**
* Custom fetch implementation. Defaults to `globalThis.fetch`. Inject in tests for isolation.
*/
fetcher?: Fetcher;
/** Cache API cache name. Defaults to `'font-cache-v1'`. */
/**
* Cache API cache name. Defaults to `'font-cache-v1'`.
*/
cacheName?: string;
}
@@ -85,12 +89,16 @@ export class FontBufferCache {
return buffer;
}
/** Removes a URL from the in-memory cache. Next call to `get()` will re-fetch. */
/**
* Removes a URL from the in-memory cache. Next call to `get()` will re-fetch.
*/
evict(url: string): void {
this.#buffersByUrl.delete(url);
}
/** Clears all in-memory cached buffers. */
/**
* Clears all in-memory cached buffers.
*/
clear(): void {
this.#buffersByUrl.clear();
}

View File

@@ -1,5 +1,7 @@
interface FontEvictionPolicyOptions {
/** TTL in milliseconds. Defaults to 5 minutes. */
/**
* TTL in milliseconds. Defaults to 5 minutes.
*/
ttl?: number;
}
@@ -28,12 +30,16 @@ export class FontEvictionPolicy {
this.#usageTracker.set(key, now);
}
/** Pins a font key so it is never evicted regardless of TTL. */
/**
* Pins a font key so it is never evicted regardless of TTL.
*/
pin(key: string): void {
this.#pinnedFonts.add(key);
}
/** Unpins a font key, allowing it to be evicted once its TTL expires. */
/**
* Unpins a font key, allowing it to be evicted once its TTL expires.
*/
unpin(key: string): void {
this.#pinnedFonts.delete(key);
}
@@ -57,18 +63,24 @@ export class FontEvictionPolicy {
return now - lastUsed >= this.#TTL;
}
/** Returns an iterator over all tracked font keys. */
/**
* Returns an iterator over all tracked font keys.
*/
keys(): IterableIterator<string> {
return this.#usageTracker.keys();
}
/** Removes a font key from tracking. Called by the orchestrator after eviction. */
/**
* Removes a font key from tracking. Called by the orchestrator after eviction.
*/
remove(key: string): void {
this.#usageTracker.delete(key);
this.#pinnedFonts.delete(key);
}
/** Clears all usage timestamps and pinned keys. */
/**
* Clears all usage timestamps and pinned keys.
*/
clear(): void {
this.#usageTracker.clear();
this.#pinnedFonts.clear();

View File

@@ -34,22 +34,30 @@ export class FontLoadQueue {
return entries;
}
/** Returns `true` if the key is currently in the queue. */
/**
* Returns `true` if the key is currently in the queue.
*/
has(key: string): boolean {
return this.#queue.has(key);
}
/** Increments the retry count for a font key. */
/**
* Increments the retry count for a font key.
*/
incrementRetry(key: string): void {
this.#retryCounts.set(key, (this.#retryCounts.get(key) ?? 0) + 1);
}
/** Returns `true` if the font has reached or exceeded the maximum retry limit. */
/**
* Returns `true` if the font has reached or exceeded the maximum retry limit.
*/
isMaxRetriesReached(key: string): boolean {
return (this.#retryCounts.get(key) ?? 0) >= this.#MAX_RETRIES;
}
/** Clears all queued fonts and resets all retry counts. */
/**
* Clears all queued fonts and resets all retry counts.
*/
clear(): void {
this.#queue.clear();
this.#retryCounts.clear();

View File

@@ -1,4 +1,6 @@
/** @vitest-environment jsdom */
/**
* @vitest-environment jsdom
*/
import { FontParseError } from '../../errors';
import { loadFont } from './loadFont';

View File

@@ -61,7 +61,6 @@ describe('FontStore', () => {
vi.resetAllMocks();
});
// -----------------------------------------------------------------------
describe('construction', () => {
it('stores initial params', () => {
const store = makeStore({ limit: 20 });
@@ -90,7 +89,6 @@ describe('FontStore', () => {
});
});
// -----------------------------------------------------------------------
describe('state after fetch', () => {
it('exposes loaded fonts', async () => {
const store = await fetchedStore({}, generateMockFonts(7));
@@ -129,7 +127,6 @@ describe('FontStore', () => {
});
});
// -----------------------------------------------------------------------
describe('error states', () => {
it('isError is false before any fetch', () => {
const store = makeStore();
@@ -178,7 +175,6 @@ describe('FontStore', () => {
});
});
// -----------------------------------------------------------------------
describe('font accumulation', () => {
it('replaces fonts when refetching the first page', async () => {
const store = makeStore();
@@ -212,7 +208,6 @@ describe('FontStore', () => {
});
});
// -----------------------------------------------------------------------
describe('pagination state', () => {
it('returns zero-value defaults before any fetch', () => {
const store = makeStore();
@@ -248,7 +243,6 @@ describe('FontStore', () => {
});
});
// -----------------------------------------------------------------------
describe('setParams', () => {
it('merges updates into existing params', () => {
const store = makeStore({ limit: 10 });
@@ -266,7 +260,6 @@ describe('FontStore', () => {
});
});
// -----------------------------------------------------------------------
describe('filter change resets', () => {
it('clears accumulated fonts when a filter changes', async () => {
const store = await fetchedStore({}, generateMockFonts(5));
@@ -302,7 +295,6 @@ describe('FontStore', () => {
});
});
// -----------------------------------------------------------------------
describe('staleTime in buildOptions', () => {
it('is 5 minutes with no active filters', () => {
const store = makeStore();
@@ -331,7 +323,6 @@ describe('FontStore', () => {
});
});
// -----------------------------------------------------------------------
describe('buildQueryKey', () => {
it('omits empty-string params', () => {
const store = makeStore();
@@ -366,7 +357,6 @@ describe('FontStore', () => {
});
});
// -----------------------------------------------------------------------
describe('destroy', () => {
it('does not throw', () => {
const store = makeStore();
@@ -380,7 +370,6 @@ describe('FontStore', () => {
});
});
// -----------------------------------------------------------------------
describe('refetch', () => {
it('triggers a fetch', async () => {
const store = makeStore();
@@ -400,7 +389,6 @@ describe('FontStore', () => {
});
});
// -----------------------------------------------------------------------
describe('nextPage', () => {
let store: FontStore;
@@ -437,7 +425,6 @@ describe('FontStore', () => {
});
});
// -----------------------------------------------------------------------
describe('prevPage and goToPage', () => {
it('prevPage is a no-op — infinite scroll does not support backward navigation', async () => {
const store = await fetchedStore({}, generateMockFonts(5));
@@ -454,7 +441,6 @@ describe('FontStore', () => {
});
});
// -----------------------------------------------------------------------
describe('prefetch', () => {
it('triggers a fetch for the provided params', async () => {
const store = makeStore();
@@ -465,7 +451,6 @@ describe('FontStore', () => {
});
});
// -----------------------------------------------------------------------
describe('getCachedData / setQueryData', () => {
it('getCachedData returns undefined before any fetch', () => {
queryClient.clear();
@@ -497,7 +482,6 @@ describe('FontStore', () => {
});
});
// -----------------------------------------------------------------------
describe('invalidate', () => {
it('calls invalidateQueries', async () => {
const store = await fetchedStore();
@@ -508,7 +492,6 @@ describe('FontStore', () => {
});
});
// -----------------------------------------------------------------------
describe('setLimit', () => {
it('updates the limit param', () => {
const store = makeStore({ limit: 10 });
@@ -518,7 +501,6 @@ describe('FontStore', () => {
});
});
// -----------------------------------------------------------------------
describe('filter shortcut methods', () => {
let store: FontStore;
@@ -561,7 +543,6 @@ describe('FontStore', () => {
});
});
// -----------------------------------------------------------------------
describe('category getters', () => {
it('each getter returns only fonts of that category', async () => {
const fonts = generateMixedCategoryFonts(2); // 2 of each category = 10 total

View File

@@ -18,7 +18,9 @@ import type { UnifiedFont } from '../../types';
type PageParam = { offset: number };
/** Filter params + limit — offset is managed by TQ as a page param, not a user param. */
/**
* Filter params + limit — offset is managed by TQ as a page param, not a user param.
*/
type FontStoreParams = Omit<ProxyFontsParams, 'offset'>;
type FontStoreResult = InfiniteQueryObserverResult<InfiniteData<ProxyFontsResponse, PageParam>, Error>;
@@ -44,34 +46,53 @@ export class FontStore {
});
}
// -- Public state --
/**
* Current filter and limit configuration
*/
get params(): FontStoreParams {
return this.#params;
}
/**
* Flattened list of all fonts loaded across all pages (reactive)
*/
get fonts(): UnifiedFont[] {
return this.#result.data?.pages.flatMap((p: ProxyFontsResponse) => p.fonts) ?? [];
}
/**
* True if the first page is currently being fetched
*/
get isLoading(): boolean {
return this.#result.isLoading;
}
/**
* True if any background fetch is in progress (initial or pagination)
*/
get isFetching(): boolean {
return this.#result.isFetching;
}
/**
* True if the last fetch attempt resulted in an error
*/
get isError(): boolean {
return this.#result.isError;
}
/**
* Last caught error from the query observer
*/
get error(): Error | null {
return this.#result.error ?? null;
}
// isEmpty is false during loading/fetching so the UI never flashes "no results"
// while a fetch is in progress. The !isFetching guard is specifically for the filter-change
// transition: fonts clear synchronously → isFetching becomes true → isEmpty stays false.
/**
* True if no fonts were found for the current filter criteria
*/
get isEmpty(): boolean {
return !this.isLoading && !this.isFetching && this.fonts.length === 0;
}
/**
* Pagination metadata derived from the last loaded page
*/
get pagination() {
const pages = this.#result.data?.pages;
const last = pages?.at(-1);
@@ -95,37 +116,52 @@ export class FontStore {
};
}
// -- Lifecycle --
/**
* Cleans up subscriptions and destroys the observer
*/
destroy() {
this.#unsubscribe();
this.#observer.destroy();
}
// -- Param management --
/**
* Merge new parameters into existing state and trigger a refetch
*/
setParams(updates: Partial<FontStoreParams>) {
this.#params = { ...this.#params, ...updates };
this.#observer.setOptions(this.buildOptions());
}
/**
* Forcefully invalidate and refetch the current query from the network
*/
invalidate() {
this.#qc.invalidateQueries({ queryKey: this.buildQueryKey(this.#params) });
}
// -- Async operations --
/**
* Manually trigger a query refetch
*/
async refetch() {
await this.#observer.refetch();
}
/**
* Prime the cache with data for a specific parameter set
*/
async prefetch(params: FontStoreParams) {
await this.#qc.prefetchInfiniteQuery(this.buildOptions(params));
}
/**
* Abort any active network requests for this store
*/
cancel() {
this.#qc.cancelQueries({ queryKey: this.buildQueryKey(this.#params) });
}
/**
* Retrieve current font list from cache without triggering a fetch
*/
getCachedData(): UnifiedFont[] | undefined {
const data = this.#qc.getQueryData<InfiniteData<ProxyFontsResponse, PageParam>>(
this.buildQueryKey(this.#params),
@@ -134,6 +170,9 @@ export class FontStore {
return data.pages.flatMap(p => p.fonts);
}
/**
* Manually update the cached font data (useful for optimistic updates)
*/
setQueryData(updater: (old: UnifiedFont[] | undefined) => UnifiedFont[]) {
const key = this.buildQueryKey(this.#params);
this.#qc.setQueryData<InfiniteData<ProxyFontsResponse, PageParam>>(
@@ -164,56 +203,90 @@ export class FontStore {
);
}
// -- Filter shortcuts --
/**
* Shortcut to update provider filters
*/
setProviders(v: ProxyFontsParams['providers']) {
this.setParams({ providers: v });
}
/**
* Shortcut to update category filters
*/
setCategories(v: ProxyFontsParams['categories']) {
this.setParams({ categories: v });
}
/**
* Shortcut to update subset filters
*/
setSubsets(v: ProxyFontsParams['subsets']) {
this.setParams({ subsets: v });
}
/**
* Shortcut to update search query
*/
setSearch(v: string) {
this.setParams({ q: v || undefined });
}
/**
* Shortcut to update sort order
*/
setSort(v: ProxyFontsParams['sort']) {
this.setParams({ sort: v });
}
// -- Pagination navigation --
/**
* Fetch the next page of results if available
*/
async nextPage(): Promise<void> {
await this.#observer.fetchNextPage();
}
prevPage(): void {} // no-op: infinite scroll accumulates forward only; method kept for API compatibility
goToPage(_page: number): void {} // no-op
/**
* Backward pagination (no-op: infinite scroll accumulates forward only)
*/
prevPage(): void {}
/**
* Jump to specific page (no-op for infinite scroll)
*/
goToPage(_page: number): void {}
/**
* Update the number of items fetched per page
*/
setLimit(limit: number) {
this.setParams({ limit });
}
// -- Category views --
/**
* Derived list of sans-serif fonts in the current set
*/
get sansSerifFonts() {
return this.fonts.filter(f => f.category === 'sans-serif');
}
/**
* Derived list of serif fonts in the current set
*/
get serifFonts() {
return this.fonts.filter(f => f.category === 'serif');
}
/**
* Derived list of display fonts in the current set
*/
get displayFonts() {
return this.fonts.filter(f => f.category === 'display');
}
/**
* Derived list of handwriting fonts in the current set
*/
get handwritingFonts() {
return this.fonts.filter(f => f.category === 'handwriting');
}
/**
* Derived list of monospace fonts in the current set
*/
get monospaceFonts() {
return this.fonts.filter(f => f.category === 'monospace');
}
// -- Private helpers (TypeScript-private so tests can spy via `as any`) --
private buildQueryKey(params: FontStoreParams): readonly unknown[] {
const filtered: Record<string, any> = {};

View File

@@ -31,18 +31,28 @@ export type FontSubset = 'latin' | 'latin-ext' | 'cyrillic' | 'greek' | 'arabic'
* Combined filter state for font queries
*/
export interface FontFilters {
/** Selected font providers */
/**
* Active font providers to fetch from
*/
providers: FontProvider[];
/** Selected font categories */
/**
* Visual classifications (sans, serif, etc.)
*/
categories: FontCategory[];
/** Selected character subsets */
/**
* Character sets required for the sample text
*/
subsets: FontSubset[];
}
/** Filter group identifier */
/**
* Filter group identifier
*/
export type FilterGroup = 'providers' | 'categories' | 'subsets';
/** Filter type including search query */
/**
* Filter type including search query
*/
export type FilterType = FilterGroup | 'searchQuery';
/**
@@ -80,15 +90,25 @@ export type UnifiedFontVariant = FontVariant;
* Font style URLs
*/
export interface FontStyleUrls {
/** Regular weight URL */
/**
* URL for the regular (400) weight
*/
regular?: string;
/** Italic URL */
/**
* URL for the italic (400) style
*/
italic?: string;
/** Bold weight URL */
/**
* URL for the bold (700) weight
*/
bold?: string;
/** Bold italic URL */
/**
* URL for the bold-italic (700) style
*/
boldItalic?: string;
/** Additional variant mapping */
/**
* Mapping for all other numeric/custom variants
*/
variants?: Partial<Record<UnifiedFontVariant, string>>;
}
@@ -96,19 +116,24 @@ export interface FontStyleUrls {
* Font metadata
*/
export interface FontMetadata {
/** Timestamp when font was cached */
/**
* Epoch timestamp of last successful fetch
*/
cachedAt: number;
/** Font version from provider */
/**
* Semantic version string from upstream
*/
version?: string;
/** Last modified date from provider */
/**
* ISO date string of last remote update
*/
lastModified?: string;
/** Popularity rank (if available from provider) */
/**
* Raw ranking integer from provider
*/
popularity?: number;
/**
* Normalized popularity score (0-100)
*
* Normalized across all fonts for consistent ranking
* Higher values indicate more popular fonts
* Normalized score (0-100) used for global sorting
*/
popularityScore?: number;
}
@@ -117,17 +142,38 @@ export interface FontMetadata {
* Font features (variable fonts, axes, tags)
*/
export interface FontFeatures {
/** Whether this is a variable font */
/**
* Whether the font supports fluid weight/width axes
*/
isVariable?: boolean;
/** Variable font axes (for Fontshare) */
/**
* Definable axes for variable font interpolation
*/
axes?: Array<{
/**
* Human-readable axis name (e.g., 'Weight')
*/
name: string;
/**
* CSS property name (e.g., 'wght')
*/
property: string;
/**
* Default numeric value for the axis
*/
default: number;
/**
* Minimum inclusive bound
*/
min: number;
/**
* Maximum inclusive bound
*/
max: number;
}>;
/** Usage tags (for Fontshare) */
/**
* Descriptive keywords for search indexing
*/
tags?: string[];
}
@@ -138,29 +184,44 @@ export interface FontFeatures {
* for consistent font handling across the application.
*/
export interface UnifiedFont {
/** Unique identifier (Google: family name, Fontshare: slug) */
/**
* Unique ID (family name for Google, slug for Fontshare)
*/
id: string;
/** Font display name */
/**
* Canonical family name for CSS font-family
*/
name: string;
/** Font provider (google | fontshare) */
/**
* Upstream data source
*/
provider: FontProvider;
/**
* Provider badge display name
*
* Human-readable provider name for UI display
* e.g., "Google Fonts" or "Fontshare"
* Display label for provider badges
*/
providerBadge?: string;
/** Font category classification */
/**
* Primary typographic category
*/
category: FontCategory;
/** Supported character subsets */
/**
* All supported character sets
*/
subsets: FontSubset[];
/** Available font variants (weights, styles) */
/**
* List of available weights and styles
*/
variants: UnifiedFontVariant[];
/** URL mapping for font file downloads */
/**
* Remote assets for font loading
*/
styles: FontStyleUrls;
/** Additional metadata */
/**
* Technical metadata and rankings
*/
metadata: FontMetadata;
/** Advanced font features */
/**
* Variable font details and tags
*/
features: FontFeatures;
}

View File

@@ -1,12 +1,3 @@
/**
* ============================================================================
* SINGLE EXPORT POINT
* ============================================================================
*
* This is the single export point for all Font types.
* All imports should use: `import { X } from '$entities/Font/model/types'`
*/
// Font domain and model types
export type {
FilterGroup,

View File

@@ -1,9 +1,3 @@
/**
* ============================================================================
* STORE TYPES
* ============================================================================
*/
import type {
FontCategory,
FontProvider,
@@ -12,37 +6,55 @@ import type {
} from './font';
/**
* Font collection state
* Global state for the local font collection
*/
export interface FontCollectionState {
/** All cached fonts */
/**
* Map of cached fonts indexed by their unique family ID
*/
fonts: Record<string, UnifiedFont>;
/** Active filters */
/**
* Set of active user-defined filters
*/
filters: FontCollectionFilters;
/** Sort configuration */
/**
* Current sorting parameters for the display list
*/
sort: FontCollectionSort;
}
/**
* Font collection filters
* Filter configuration for narrow collections
*/
export interface FontCollectionFilters {
/** Search query */
/**
* Partial family name to match against
*/
searchQuery: string;
/** Filter by providers */
/**
* Data sources (Google, Fontshare) to include
*/
providers?: FontProvider[];
/** Filter by categories */
/**
* Typographic categories (Serif, Sans, etc.) to include
*/
categories?: FontCategory[];
/** Filter by subsets */
/**
* Character sets (Latin, Cyrillic, etc.) to include
*/
subsets?: FontSubset[];
}
/**
* Font collection sort configuration
* Ordering configuration for the font list
*/
export interface FontCollectionSort {
/** Sort field */
/**
* The font property to order by
*/
field: 'name' | 'popularity' | 'category';
/** Sort direction */
/**
* The sort order (Ascending or Descending)
*/
direction: 'asc' | 'desc';
}