Chore/architecture refactoring #42

Merged
ilia merged 29 commits from chore/architecture-refactoring into main 2026-05-25 08:43:07 +00:00
38 changed files with 105 additions and 105 deletions
Showing only changes of commit 728380498b - Show all commits
+3 -3
View File
@@ -667,10 +667,10 @@ export const MOCK_STORES = {
}; };
}, },
/** /**
* Create a mock FontStore object * Create a mock FontCatalogStore object
* Matches FontStore's public API for Storybook use * Matches FontCatalogStore's public API for Storybook use
*/ */
fontStore: (config: { fontCatalogStore: (config: {
/** /**
* Preset font list * Preset font list
*/ */
@@ -1,5 +1,5 @@
import { TextLayoutEngine } from '$shared/lib'; import { TextLayoutEngine } from '$shared/lib';
import { generateFontKey } from '../../model/store/appliedFontsStore/utils/generateFontKey/generateFontKey'; import { generateFontKey } from '../../model/store/fontLifecycleManager/utils/generateFontKey/generateFontKey';
import type { import type {
FontLoadStatus, FontLoadStatus,
UnifiedFont, UnifiedFont,
@@ -41,7 +41,7 @@ export interface FontRowSizeResolverOptions {
/** /**
* Returns the font load status for a given font key (`'{id}@{weight}'` or `'{id}@vf'`). * Returns the font load status for a given font key (`'{id}@{weight}'` or `'{id}@vf'`).
* *
* In production: `(key) => appliedFontsManager.statuses.get(key)`. * In production: `(key) => fontLifecycleManager.statuses.get(key)`.
* Injected for testability — avoids a module-level singleton dependency in tests. * Injected for testability — avoids a module-level singleton dependency in tests.
* The call to `.get()` on a `SvelteMap` must happen inside a `$derived.by` context * The call to `.get()` on a `SvelteMap` must happen inside a `$derived.by` context
* for reactivity to work. This is satisfied when `itemHeight` is called by * for reactivity to work. This is satisfied when `itemHeight` is called by
@@ -108,7 +108,7 @@ export function createFontRowSizeResolver(options: FontRowSizeResolverOptions):
// generateFontKey: '{id}@{weight}' for static fonts, '{id}@vf' for variable fonts. // generateFontKey: '{id}@{weight}' for static fonts, '{id}@vf' for variable fonts.
const fontKey = generateFontKey({ id: font.id, weight, isVariable: font.features?.isVariable }); const fontKey = generateFontKey({ id: font.id, weight, isVariable: font.features?.isVariable });
// Reading via getStatus() allows the caller to pass appliedFontsManager.statuses.get(), // Reading via getStatus() allows the caller to pass fontLifecycleManager.statuses.get(),
// which creates a Svelte 5 reactive dependency when called inside $derived.by. // which creates a Svelte 5 reactive dependency when called inside $derived.by.
const status = options.getStatus(fontKey); const status = options.getStatus(fontKey);
if (status !== 'loaded') { if (status !== 'loaded') {
@@ -17,7 +17,7 @@ import {
generateMockFonts, generateMockFonts,
} from '../../../lib/mocks/fonts.mock'; } from '../../../lib/mocks/fonts.mock';
import type { UnifiedFont } from '../../types'; import type { UnifiedFont } from '../../types';
import { FontStore } from './fontStore.svelte'; import { FontCatalogStore } from './fontCatalogStore.svelte';
vi.mock('$shared/api/queryClient', () => ({ vi.mock('$shared/api/queryClient', () => ({
queryClient: new QueryClient({ queryClient: new QueryClient({
@@ -44,7 +44,7 @@ const makeResponse = (
}); });
function makeStore(params = {}) { function makeStore(params = {}) {
return new FontStore({ limit: 10, ...params }); return new FontCatalogStore({ limit: 10, ...params });
} }
async function fetchedStore(params = {}, fonts = generateMockFonts(5), meta: Parameters<typeof makeResponse>[1] = {}) { async function fetchedStore(params = {}, fonts = generateMockFonts(5), meta: Parameters<typeof makeResponse>[1] = {}) {
@@ -55,7 +55,7 @@ async function fetchedStore(params = {}, fonts = generateMockFonts(5), meta: Par
return store; return store;
} }
describe('FontStore', () => { describe('FontCatalogStore', () => {
afterEach(() => { afterEach(() => {
queryClient.clear(); queryClient.clear();
vi.resetAllMocks(); vi.resetAllMocks();
@@ -69,7 +69,7 @@ describe('FontStore', () => {
}); });
it('defaults limit to 50 when not provided', () => { it('defaults limit to 50 when not provided', () => {
const store = new FontStore(); const store = new FontCatalogStore();
expect(store.params.limit).toBe(50); expect(store.params.limit).toBe(50);
store.destroy(); store.destroy();
}); });
@@ -390,11 +390,11 @@ describe('FontStore', () => {
}); });
describe('nextPage', () => { describe('nextPage', () => {
let store: FontStore; let store: FontCatalogStore;
beforeEach(async () => { beforeEach(async () => {
fetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 30, limit: 10, offset: 0 })); fetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 30, limit: 10, offset: 0 }));
store = new FontStore({ limit: 10 }); store = new FontCatalogStore({ limit: 10 });
await store.refetch(); await store.refetch();
flushSync(); flushSync();
}); });
@@ -415,7 +415,7 @@ describe('FontStore', () => {
// Set up a store where all fonts fit in one page (hasMore = false) // Set up a store where all fonts fit in one page (hasMore = false)
queryClient.clear(); queryClient.clear();
fetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 10, limit: 10, offset: 0 })); fetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 10, limit: 10, offset: 0 }));
store = new FontStore({ limit: 10 }); store = new FontCatalogStore({ limit: 10 });
await store.refetch(); await store.refetch();
flushSync(); flushSync();
@@ -454,7 +454,7 @@ describe('FontStore', () => {
describe('getCachedData / setQueryData', () => { describe('getCachedData / setQueryData', () => {
it('getCachedData returns undefined before any fetch', () => { it('getCachedData returns undefined before any fetch', () => {
queryClient.clear(); queryClient.clear();
const store = new FontStore({ limit: 10 }); const store = new FontCatalogStore({ limit: 10 });
expect(store.getCachedData()).toBeUndefined(); expect(store.getCachedData()).toBeUndefined();
store.destroy(); store.destroy();
}); });
@@ -502,7 +502,7 @@ describe('FontStore', () => {
}); });
describe('filter shortcut methods', () => { describe('filter shortcut methods', () => {
let store: FontStore; let store: FontCatalogStore;
beforeEach(() => { beforeEach(() => {
store = makeStore(); store = makeStore();
@@ -25,7 +25,7 @@ type FontStoreParams = Omit<ProxyFontsParams, 'offset'>;
type FontStoreResult = InfiniteQueryObserverResult<InfiniteData<ProxyFontsResponse, PageParam>, Error>; type FontStoreResult = InfiniteQueryObserverResult<InfiniteData<ProxyFontsResponse, PageParam>, Error>;
export class FontStore { export class FontCatalogStore {
#params = $state<FontStoreParams>({ limit: 50 }); #params = $state<FontStoreParams>({ limit: 50 });
#result = $state<FontStoreResult>({} as FontStoreResult); #result = $state<FontStoreResult>({} as FontStoreResult);
#observer: InfiniteQueryObserver< #observer: InfiniteQueryObserver<
@@ -459,8 +459,8 @@ export class FontStore {
} }
} }
export function createFontStore(params: FontStoreParams = {}): FontStore { export function createFontCatalogStore(params: FontStoreParams = {}): FontCatalogStore {
return new FontStore(params); return new FontCatalogStore(params);
} }
export const fontStore = new FontStore({ limit: 50 }); export const fontCatalogStore = new FontCatalogStore({ limit: 50 });
@@ -17,7 +17,7 @@ import { FontBufferCache } from './utils/fontBufferCache/FontBufferCache';
import { FontEvictionPolicy } from './utils/fontEvictionPolicy/FontEvictionPolicy'; import { FontEvictionPolicy } from './utils/fontEvictionPolicy/FontEvictionPolicy';
import { FontLoadQueue } from './utils/fontLoadQueue/FontLoadQueue'; import { FontLoadQueue } from './utils/fontLoadQueue/FontLoadQueue';
interface AppliedFontsManagerDeps { interface FontLifecycleManagerDeps {
cache?: FontBufferCache; cache?: FontBufferCache;
eviction?: FontEvictionPolicy; eviction?: FontEvictionPolicy;
queue?: FontLoadQueue; queue?: FontLoadQueue;
@@ -46,7 +46,7 @@ interface AppliedFontsManagerDeps {
* *
* **Browser APIs Used:** `scheduler.yield()`, `isInputPending()`, `requestIdleCallback`, Cache API, Network Information API * **Browser APIs Used:** `scheduler.yield()`, `isInputPending()`, `requestIdleCallback`, Cache API, Network Information API
*/ */
export class AppliedFontsManager { export class FontLifecycleManager {
// Injected collaborators - each handles one concern for better testability // Injected collaborators - each handles one concern for better testability
readonly #cache: FontBufferCache; readonly #cache: FontBufferCache;
readonly #eviction: FontEvictionPolicy; readonly #eviction: FontEvictionPolicy;
@@ -78,7 +78,7 @@ export class AppliedFontsManager {
// Starts periodic cleanup timer (browser-only). // Starts periodic cleanup timer (browser-only).
constructor( constructor(
{ cache = new FontBufferCache(), eviction = new FontEvictionPolicy(), queue = new FontLoadQueue() }: { cache = new FontBufferCache(), eviction = new FontEvictionPolicy(), queue = new FontLoadQueue() }:
AppliedFontsManagerDeps = {}, FontLifecycleManagerDeps = {},
) { ) {
// Inject collaborators - defaults provided for production, fakes for testing // Inject collaborators - defaults provided for production, fakes for testing
this.#cache = cache; this.#cache = cache;
@@ -396,4 +396,4 @@ export class AppliedFontsManager {
/** /**
* Singleton instance use throughout the application for unified font loading state. * Singleton instance use throughout the application for unified font loading state.
*/ */
export const appliedFontsManager = new AppliedFontsManager(); export const fontLifecycleManager = new FontLifecycleManager();
@@ -1,8 +1,8 @@
/** /**
* @vitest-environment jsdom * @vitest-environment jsdom
*/ */
import { AppliedFontsManager } from './appliedFontsStore.svelte';
import { FontFetchError } from './errors'; import { FontFetchError } from './errors';
import { FontLifecycleManager } from './fontLifecycleManager.svelte';
import { FontEvictionPolicy } from './utils/fontEvictionPolicy/FontEvictionPolicy'; import { FontEvictionPolicy } from './utils/fontEvictionPolicy/FontEvictionPolicy';
class FakeBufferCache { class FakeBufferCache {
@@ -32,8 +32,8 @@ const makeConfig = (id: string, overrides: Partial<{ weight: number; isVariable:
...overrides, ...overrides,
}); });
describe('AppliedFontsManager', () => { describe('FontLifecycleManager', () => {
let manager: AppliedFontsManager; let manager: FontLifecycleManager;
let eviction: FontEvictionPolicy; let eviction: FontEvictionPolicy;
let mockFontFaceSet: { add: ReturnType<typeof vi.fn>; delete: ReturnType<typeof vi.fn> }; let mockFontFaceSet: { add: ReturnType<typeof vi.fn>; delete: ReturnType<typeof vi.fn> };
@@ -55,7 +55,7 @@ describe('AppliedFontsManager', () => {
}); });
vi.stubGlobal('FontFace', MockFontFace); vi.stubGlobal('FontFace', MockFontFace);
manager = new AppliedFontsManager({ cache: new FakeBufferCache() as any, eviction }); manager = new FontLifecycleManager({ cache: new FakeBufferCache() as any, eviction });
}); });
afterEach(() => { afterEach(() => {
@@ -101,7 +101,7 @@ describe('AppliedFontsManager', () => {
it('skips fonts that have exhausted retries', async () => { it('skips fonts that have exhausted retries', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const failManager = new AppliedFontsManager({ cache: new FailingBufferCache() as any, eviction }); const failManager = new FontLifecycleManager({ cache: new FailingBufferCache() as any, eviction });
// exhaust all 3 retries // exhaust all 3 retries
for (let i = 0; i < 3; i++) { for (let i = 0; i < 3; i++) {
@@ -160,7 +160,7 @@ describe('AppliedFontsManager', () => {
describe('Phase 1 — fetch', () => { describe('Phase 1 — fetch', () => {
it('sets status to error on fetch failure', async () => { it('sets status to error on fetch failure', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const failManager = new AppliedFontsManager({ cache: new FailingBufferCache() as any, eviction }); const failManager = new FontLifecycleManager({ cache: new FailingBufferCache() as any, eviction });
failManager.touch([makeConfig('broken')]); failManager.touch([makeConfig('broken')]);
await vi.advanceTimersByTimeAsync(50); await vi.advanceTimersByTimeAsync(50);
@@ -171,7 +171,7 @@ describe('AppliedFontsManager', () => {
it('logs a console error on fetch failure', async () => { it('logs a console error on fetch failure', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const failManager = new AppliedFontsManager({ cache: new FailingBufferCache() as any, eviction }); const failManager = new FontLifecycleManager({ cache: new FailingBufferCache() as any, eviction });
failManager.touch([makeConfig('broken')]); failManager.touch([makeConfig('broken')]);
await vi.advanceTimersByTimeAsync(50); await vi.advanceTimersByTimeAsync(50);
@@ -189,7 +189,7 @@ describe('AppliedFontsManager', () => {
evict() {}, evict() {},
clear() {}, clear() {},
}; };
const abortManager = new AppliedFontsManager({ cache: abortingCache as any, eviction }); const abortManager = new FontLifecycleManager({ cache: abortingCache as any, eviction });
abortManager.touch([makeConfig('aborted')]); abortManager.touch([makeConfig('aborted')]);
await vi.advanceTimersByTimeAsync(50); await vi.advanceTimersByTimeAsync(50);
+7 -7
View File
@@ -1,9 +1,9 @@
// Applied fonts manager // Font lifecycle manager (browser-side load + cache + eviction)
export * from './appliedFontsStore/appliedFontsStore.svelte'; export * from './fontLifecycleManager/fontLifecycleManager.svelte';
// Single FontStore // Paginated catalog
export { export {
createFontStore, createFontCatalogStore,
FontStore, FontCatalogStore,
fontStore, fontCatalogStore,
} from './fontStore/fontStore.svelte'; } from './fontCatalogStore/fontCatalogStore.svelte';
+1 -1
View File
@@ -23,5 +23,5 @@ export type {
FontCollectionState, FontCollectionState,
} from './store'; } from './store';
export * from './store/appliedFonts'; export * from './store/fontLifecycle';
export * from './typography'; export * from './typography';
@@ -39,7 +39,7 @@ const fontArialBold = mockUnifiedFont({ id: 'arial-bold', name: 'Arial' });
docs: { docs: {
description: { description: {
story: story:
'Font that has never been loaded by appliedFontsManager. The component renders in its pending state: blurred, scaled down, and semi-transparent.', 'Font that has never been loaded by fontLifecycleManager. The component renders in its pending state: blurred, scaled down, and semi-transparent.',
}, },
}, },
}} }}
@@ -58,7 +58,7 @@ const fontArialBold = mockUnifiedFont({ id: 'arial-bold', name: 'Arial' });
docs: { docs: {
description: { description: {
story: story:
'Uses Arial, a system font available in all browsers. Because appliedFontsManager has not loaded it via FontFace, the manager status may remain pending — meaning the blur/scale state may still show. In a real app the manager would load the font and transition to the revealed state.', 'Uses Arial, a system font available in all browsers. Because fontLifecycleManager has not loaded it via FontFace, the manager status may remain pending — meaning the blur/scale state may still show. In a real app the manager would load the font and transition to the revealed state.',
}, },
}, },
}} }}
@@ -77,7 +77,7 @@ const fontArialBold = mockUnifiedFont({ id: 'arial-bold', name: 'Arial' });
docs: { docs: {
description: { description: {
story: story:
'Demonstrates passing a custom weight (700). The weight is forwarded to appliedFontsManager for font resolution; visually identical to the loaded state story until the manager confirms the font.', 'Demonstrates passing a custom weight (700). The weight is forwarded to fontLifecycleManager for font resolution; visually identical to the loaded state story until the manager confirms the font.',
}, },
}, },
}} }}
@@ -9,7 +9,7 @@ import type { Snippet } from 'svelte';
import { import {
DEFAULT_FONT_WEIGHT, DEFAULT_FONT_WEIGHT,
type UnifiedFont, type UnifiedFont,
appliedFontsManager, fontLifecycleManager,
} from '../../model'; } from '../../model';
interface Props { interface Props {
@@ -46,7 +46,7 @@ let {
}: Props = $props(); }: Props = $props();
const status = $derived( const status = $derived(
appliedFontsManager.getFontStatus( fontLifecycleManager.getFontStatus(
font.id, font.id,
weight, weight,
font.features?.isVariable, font.features?.isVariable,
@@ -10,7 +10,7 @@ const { Story } = defineMeta({
docs: { docs: {
description: { description: {
component: component:
'Virtualized font list backed by the `fontStore` singleton. Handles font loading registration (pin/touch) for visible items and triggers infinite scroll pagination via `fontStore.nextPage()`. Because the component reads directly from the `fontStore` singleton, stories render against a live (but empty/loading) store — no font data will appear unless the API is reachable from the Storybook host.', 'Virtualized font list backed by the `fontCatalogStore` singleton. Handles font loading registration (pin/touch) for visible items and triggers infinite scroll pagination via `fontCatalogStore.nextPage()`. Because the component reads directly from the `fontCatalogStore` singleton, stories render against a live (but empty/loading) store — no font data will appear unless the API is reachable from the Storybook host.',
}, },
story: { inline: false }, story: { inline: false },
}, },
@@ -33,7 +33,7 @@ import type { ComponentProps } from 'svelte';
docs: { docs: {
description: { description: {
story: story:
'Skeleton state shown while `fontStore.fonts` is empty and `fontStore.isLoading` is true. In a real session the skeleton fades out once the first page loads.', 'Skeleton state shown while `fontCatalogStore.fonts` is empty and `fontCatalogStore.isLoading` is true. In a real session the skeleton fades out once the first page loads.',
}, },
}, },
}} }}
@@ -63,7 +63,7 @@ import type { ComponentProps } from 'svelte';
docs: { docs: {
description: { description: {
story: story:
'No `skeleton` snippet provided. When `fontStore.fonts` is empty the underlying VirtualList renders its empty state directly.', 'No `skeleton` snippet provided. When `fontCatalogStore.fonts` is empty the underlying VirtualList renders its empty state directly.',
}, },
}, },
}} }}
@@ -86,7 +86,7 @@ import type { ComponentProps } from 'svelte';
docs: { docs: {
description: { description: {
story: story:
'Demonstrates how to configure a `children` snippet for item rendering. The list will be empty because `fontStore` is not populated in Storybook, but the template shows the expected slot shape: `{ item: UnifiedFont }`.', 'Demonstrates how to configure a `children` snippet for item rendering. The list will be empty because `fontCatalogStore` is not populated in Storybook, but the template shows the expected slot shape: `{ item: UnifiedFont }`.',
}, },
}, },
}} }}
@@ -18,8 +18,8 @@ import { getFontUrl } from '../../lib';
import { import {
type FontLoadRequestConfig, type FontLoadRequestConfig,
type UnifiedFont, type UnifiedFont,
appliedFontsManager, fontCatalogStore,
fontStore, fontLifecycleManager,
} from '../../model'; } from '../../model';
interface Props extends interface Props extends
@@ -51,13 +51,13 @@ let {
}: Props = $props(); }: Props = $props();
const isLoading = $derived( const isLoading = $derived(
fontStore.isFetching || fontStore.isLoading, fontCatalogStore.isFetching || fontCatalogStore.isLoading,
); );
let visibleFonts = $state<UnifiedFont[]>([]); let visibleFonts = $state<UnifiedFont[]>([]);
let isCatchingUp = $state(false); let isCatchingUp = $state(false);
const showInitialSkeleton = $derived(!!skeleton && isLoading && fontStore.fonts.length === 0); const showInitialSkeleton = $derived(!!skeleton && isLoading && fontCatalogStore.fonts.length === 0);
const showCatchupSkeleton = $derived(!!skeleton && isCatchingUp); const showCatchupSkeleton = $derived(!!skeleton && isCatchingUp);
function handleInternalVisibleChange(items: UnifiedFont[]) { function handleInternalVisibleChange(items: UnifiedFont[]) {
@@ -68,23 +68,23 @@ function handleInternalVisibleChange(items: UnifiedFont[]) {
/** /**
* Handle jump scroll — batch-load all missing pages then re-enable font loading. * Handle jump scroll — batch-load all missing pages then re-enable font loading.
* Suppresses appliedFontsManager.touch() during catch-up to avoid loading * Suppresses fontLifecycleManager.touch() during catch-up to avoid loading
* font files for thousands of intermediate fonts. * font files for thousands of intermediate fonts.
*/ */
async function handleJump(targetIndex: number) { async function handleJump(targetIndex: number) {
if (isCatchingUp || !fontStore.pagination.hasMore) { if (isCatchingUp || !fontCatalogStore.pagination.hasMore) {
return; return;
} }
isCatchingUp = true; isCatchingUp = true;
try { try {
await fontStore.fetchAllPagesTo(targetIndex); await fontCatalogStore.fetchAllPagesTo(targetIndex);
} finally { } finally {
isCatchingUp = false; isCatchingUp = false;
} }
} }
const debouncedTouch = debounce((configs: FontLoadRequestConfig[]) => { const debouncedTouch = debounce((configs: FontLoadRequestConfig[]) => {
appliedFontsManager.touch(configs); fontLifecycleManager.touch(configs);
}, 150); }, 150);
// Re-touch whenever visible set or weight changes — fixes weight-change gap // Re-touch whenever visible set or weight changes — fixes weight-change gap
@@ -111,11 +111,11 @@ $effect(() => {
const w = weight; const w = weight;
const fonts = visibleFonts; const fonts = visibleFonts;
for (const f of fonts) { for (const f of fonts) {
appliedFontsManager.pin(f.id, w, f.features?.isVariable); fontLifecycleManager.pin(f.id, w, f.features?.isVariable);
} }
return () => { return () => {
for (const f of fonts) { for (const f of fonts) {
appliedFontsManager.unpin(f.id, w, f.features?.isVariable); fontLifecycleManager.unpin(f.id, w, f.features?.isVariable);
} }
}; };
}); });
@@ -125,12 +125,12 @@ $effect(() => {
*/ */
function loadMore() { function loadMore() {
if ( if (
!fontStore.pagination.hasMore !fontCatalogStore.pagination.hasMore
|| fontStore.isFetching || fontCatalogStore.isFetching
) { ) {
return; return;
} }
fontStore.nextPage(); fontCatalogStore.nextPage();
} }
/** /**
@@ -140,12 +140,12 @@ function loadMore() {
* of the loaded items. Only fetches if there are more pages available. * of the loaded items. Only fetches if there are more pages available.
*/ */
function handleNearBottom(_lastVisibleIndex: number) { function handleNearBottom(_lastVisibleIndex: number) {
const { hasMore } = fontStore.pagination; const { hasMore } = fontCatalogStore.pagination;
// VirtualList already checks if we're near the bottom of loaded items. // VirtualList already checks if we're near the bottom of loaded items.
// Guard isCatchingUp: fetchAllPagesTo bypasses TQ so isFetching stays false // Guard isCatchingUp: fetchAllPagesTo bypasses TQ so isFetching stays false
// during batch catch-up, which would otherwise let nextPage() race with it. // during batch catch-up, which would otherwise let nextPage() race with it.
if (hasMore && !fontStore.isFetching && !isCatchingUp) { if (hasMore && !fontCatalogStore.isFetching && !isCatchingUp) {
loadMore(); loadMore();
} }
} }
@@ -160,8 +160,8 @@ function handleNearBottom(_lastVisibleIndex: number) {
{:else} {:else}
<!-- VirtualList persists during pagination - no destruction/recreation --> <!-- VirtualList persists during pagination - no destruction/recreation -->
<VirtualList <VirtualList
items={fontStore.fonts} items={fontCatalogStore.fonts}
total={fontStore.pagination.total} total={fontCatalogStore.pagination.total}
isLoading={isLoading || isCatchingUp} isLoading={isLoading || isCatchingUp}
onVisibleItemsChange={handleInternalVisibleChange} onVisibleItemsChange={handleInternalVisibleChange}
onNearBottom={handleNearBottom} onNearBottom={handleNearBottom}
@@ -38,7 +38,7 @@ export {
} from './store/appliedFilterStore/appliedFilterStore.svelte'; } from './store/appliedFilterStore/appliedFilterStore.svelte';
/** /**
* Side-effect import: installs the global appliedFilterStore+sortStore → fontStore * Side-effect import: installs the global appliedFilterStore+sortStore → fontCatalogStore
* bridge on first import of this feature barrel. No exports. * bridge on first import of this feature barrel. No exports.
*/ */
import './store/bindings.svelte'; import './store/bindings.svelte';
@@ -1,6 +1,6 @@
/** /**
* Bridges feature-level UI state (appliedFilterStore + sortStore) to the * Bridges feature-level UI state (appliedFilterStore + sortStore) to the
* entity-level fontStore query params. * entity-level fontCatalogStore query params.
* *
* Centralizing this here means consumers (Search, FontSearch, * Centralizing this here means consumers (Search, FontSearch,
* FilterControls, etc.) bind to the manager/store directly without * FilterControls, etc.) bind to the manager/store directly without
@@ -9,7 +9,7 @@
* observer, so it lives at module scope, not in any individual widget. * observer, so it lives at module scope, not in any individual widget.
*/ */
import { fontStore } from '$entities/Font'; import { fontCatalogStore } from '$entities/Font';
import { untrack } from 'svelte'; import { untrack } from 'svelte';
import { mapAppliedFiltersToParams } from '../../lib/mapper/mapAppliedFiltersToParams'; import { mapAppliedFiltersToParams } from '../../lib/mapper/mapAppliedFiltersToParams';
import { appliedFilterStore } from './appliedFilterStore/appliedFilterStore.svelte'; import { appliedFilterStore } from './appliedFilterStore/appliedFilterStore.svelte';
@@ -42,20 +42,20 @@ $effect.root(() => {
}); });
/** /**
* Mirror filter selections + debounced search query into fontStore params. * Mirror filter selections + debounced search query into fontCatalogStore params.
* untrack the write so fontStore's internal $state reads don't feed back * untrack the write so fontCatalogStore's internal $state reads don't feed back
* into this effect's dependency graph. * into this effect's dependency graph.
*/ */
$effect(() => { $effect(() => {
const params = mapAppliedFiltersToParams(appliedFilterStore); const params = mapAppliedFiltersToParams(appliedFilterStore);
untrack(() => fontStore.setParams(params)); untrack(() => fontCatalogStore.setParams(params));
}); });
/** /**
* Mirror sort selection into fontStore. * Mirror sort selection into fontCatalogStore.
*/ */
$effect(() => { $effect(() => {
const apiSort = sortStore.apiValue; const apiSort = sortStore.apiValue;
untrack(() => fontStore.setSort(apiSort)); untrack(() => fontCatalogStore.setSort(apiSort));
}); });
}); });
@@ -58,7 +58,7 @@ export interface VirtualizerOptions {
* when those values change, `offsets` and `totalSize` recompute instantly. * when those values change, `offsets` and `totalSize` recompute instantly.
* *
* For font preview rows, pass a closure that reads * For font preview rows, pass a closure that reads
* `appliedFontsManager.statuses` so the virtualizer recalculates heights * `fontLifecycleManager.statuses` so the virtualizer recalculates heights
* as fonts finish loading, eliminating the DOM-measurement snap on load. * as fonts finish loading, eliminating the DOM-measurement snap on load.
*/ */
estimateSize: (index: number) => number; estimateSize: (index: number) => number;
@@ -16,8 +16,8 @@
import { import {
type FontLoadRequestConfig, type FontLoadRequestConfig,
type UnifiedFont, type UnifiedFont,
appliedFontsManager, fontCatalogStore,
fontStore, fontLifecycleManager,
getFontUrl, getFontUrl,
} from '$entities/Font'; } from '$entities/Font';
import { typographySettingsStore } from '$features/AdjustTypography/model'; import { typographySettingsStore } from '$features/AdjustTypography/model';
@@ -140,7 +140,7 @@ export class ComparisonStore {
}); });
if (configs.length > 0) { if (configs.length > 0) {
appliedFontsManager.touch(configs); fontLifecycleManager.touch(configs);
this.#checkFontsLoaded(); this.#checkFontsLoaded();
} }
}); });
@@ -151,7 +151,7 @@ export class ComparisonStore {
return; return;
} }
const fonts = fontStore.fonts; const fonts = fontCatalogStore.fonts;
if (fonts.length >= 2) { if (fonts.length >= 2) {
untrack(() => { untrack(() => {
const id1 = fonts[0].id; const id1 = fonts[0].id;
@@ -168,17 +168,17 @@ export class ComparisonStore {
const fb = this.#fontB; const fb = this.#fontB;
const w = typographySettingsStore.weight; const w = typographySettingsStore.weight;
if (fa) { if (fa) {
appliedFontsManager.pin(fa.id, w, fa.features?.isVariable); fontLifecycleManager.pin(fa.id, w, fa.features?.isVariable);
} }
if (fb) { if (fb) {
appliedFontsManager.pin(fb.id, w, fb.features?.isVariable); fontLifecycleManager.pin(fb.id, w, fb.features?.isVariable);
} }
return () => { return () => {
if (fa) { if (fa) {
appliedFontsManager.unpin(fa.id, w, fa.features?.isVariable); fontLifecycleManager.unpin(fa.id, w, fa.features?.isVariable);
} }
if (fb) { if (fb) {
appliedFontsManager.unpin(fb.id, w, fb.features?.isVariable); fontLifecycleManager.unpin(fb.id, w, fb.features?.isVariable);
} }
}; };
}); });
@@ -55,8 +55,8 @@ vi.mock('$entities/Font', async importOriginal => {
const actual = await importOriginal<typeof import('$entities/Font')>(); const actual = await importOriginal<typeof import('$entities/Font')>();
return { return {
...actual, ...actual,
fontStore: { fonts: [] }, fontCatalogStore: { fonts: [] },
appliedFontsManager: { fontLifecycleManager: {
touch: vi.fn(), touch: vi.fn(),
pin: vi.fn(), pin: vi.fn(),
unpin: vi.fn(), unpin: vi.fn(),
@@ -85,8 +85,8 @@ vi.mock('$features/AdjustTypography/model', () => ({
})); }));
import { import {
appliedFontsManager, fontCatalogStore,
fontStore, fontLifecycleManager,
} from '$entities/Font'; } from '$entities/Font';
import * as proxyFonts from '$entities/Font/api/proxy/proxyFonts'; import * as proxyFonts from '$entities/Font/api/proxy/proxyFonts';
import { ComparisonStore } from './comparisonStore.svelte'; import { ComparisonStore } from './comparisonStore.svelte';
@@ -100,7 +100,7 @@ describe('ComparisonStore', () => {
vi.clearAllMocks(); vi.clearAllMocks();
mockStorage._value = { fontAId: null, fontBId: null }; mockStorage._value = { fontAId: null, fontBId: null };
mockStorage._clear.mockClear(); mockStorage._clear.mockClear();
(fontStore as any).fonts = []; (fontCatalogStore as any).fonts = [];
// Default: fetchFontsByIds returns empty so tests that don't care don't hang // Default: fetchFontsByIds returns empty so tests that don't care don't hang
vi.spyOn(proxyFonts, 'fetchFontsByIds').mockResolvedValue([]); vi.spyOn(proxyFonts, 'fetchFontsByIds').mockResolvedValue([]);
@@ -155,7 +155,7 @@ describe('ComparisonStore', () => {
describe('Default Fallbacks', () => { describe('Default Fallbacks', () => {
it('should update storage with default IDs when storage is empty', async () => { it('should update storage with default IDs when storage is empty', async () => {
(fontStore as any).fonts = [mockFontA, mockFontB]; (fontCatalogStore as any).fonts = [mockFontA, mockFontB];
vi.spyOn(proxyFonts, 'fetchFontsByIds').mockResolvedValue([mockFontA, mockFontB]); vi.spyOn(proxyFonts, 'fetchFontsByIds').mockResolvedValue([mockFontA, mockFontB]);
new ComparisonStore(); new ComparisonStore();
@@ -212,12 +212,12 @@ describe('ComparisonStore', () => {
new ComparisonStore(); new ComparisonStore();
await vi.waitFor(() => { await vi.waitFor(() => {
expect(appliedFontsManager.pin).toHaveBeenCalledWith( expect(fontLifecycleManager.pin).toHaveBeenCalledWith(
mockFontA.id, mockFontA.id,
400, 400,
mockFontA.features?.isVariable, mockFontA.features?.isVariable,
); );
expect(appliedFontsManager.pin).toHaveBeenCalledWith( expect(fontLifecycleManager.pin).toHaveBeenCalledWith(
mockFontB.id, mockFontB.id,
400, 400,
mockFontB.features?.isVariable, mockFontB.features?.isVariable,
@@ -238,12 +238,12 @@ describe('ComparisonStore', () => {
store.fontA = mockFontC; store.fontA = mockFontC;
await vi.waitFor(() => { await vi.waitFor(() => {
expect(appliedFontsManager.unpin).toHaveBeenCalledWith( expect(fontLifecycleManager.unpin).toHaveBeenCalledWith(
mockFontA.id, mockFontA.id,
400, 400,
mockFontA.features?.isVariable, mockFontA.features?.isVariable,
); );
expect(appliedFontsManager.pin).toHaveBeenCalledWith( expect(fontLifecycleManager.pin).toHaveBeenCalledWith(
mockFontC.id, mockFontC.id,
400, 400,
mockFontC.features?.isVariable, mockFontC.features?.isVariable,
@@ -9,8 +9,8 @@ import {
FontVirtualList, FontVirtualList,
type UnifiedFont, type UnifiedFont,
VIRTUAL_INDEX_NOT_LOADED, VIRTUAL_INDEX_NOT_LOADED,
appliedFontsManager, fontCatalogStore,
fontStore, fontLifecycleManager,
} from '$entities/Font'; } from '$entities/Font';
import { getSkeletonWidth } from '$shared/lib/utils'; import { getSkeletonWidth } from '$shared/lib/utils';
import { import {
@@ -36,7 +36,7 @@ function getVirtualIndex(fontId: string | undefined): number {
if (!fontId) { if (!fontId) {
return -1; return -1;
} }
const idx = fontStore.fonts.findIndex(f => f.id === fontId); const idx = fontCatalogStore.fonts.findIndex(f => f.id === fontId);
if (idx === -1) { if (idx === -1) {
return VIRTUAL_INDEX_NOT_LOADED; return VIRTUAL_INDEX_NOT_LOADED;
} }
@@ -77,11 +77,11 @@ function handleSelect(font: UnifiedFont) {
/** /**
* Returns true once the font file is loaded (or errored) and safe to render. * Returns true once the font file is loaded (or errored) and safe to render.
* Called inside the template — Svelte 5 tracks the $state reads inside * Called inside the template — Svelte 5 tracks the $state reads inside
* appliedFontsManager.getFontStatus(), so each row re-renders reactively * fontLifecycleManager.getFontStatus(), so each row re-renders reactively
* when its file arrives. * when its file arrives.
*/ */
function isFontReady(font: UnifiedFont): boolean { function isFontReady(font: UnifiedFont): boolean {
const status = appliedFontsManager.getFontStatus( const status = fontLifecycleManager.getFontStatus(
font.id, font.id,
DEFAULT_FONT_WEIGHT, DEFAULT_FONT_WEIGHT,
font.features?.isVariable, font.features?.isVariable,
@@ -2,7 +2,7 @@
Component: Search Component: Search
Typeface search input for the comparison view. Typeface search input for the comparison view.
Writes through appliedFilterStore; the global bridge in $features/FilterAndSortFonts Writes through appliedFilterStore; the global bridge in $features/FilterAndSortFonts
propagates the value into fontStore. propagates the value into fontCatalogStore.
--> -->
<script lang="ts"> <script lang="ts">
import { appliedFilterStore } from '$features/FilterAndSortFonts'; import { appliedFilterStore } from '$features/FilterAndSortFonts';
@@ -7,9 +7,9 @@
<script lang="ts"> <script lang="ts">
import { import {
FontVirtualList, FontVirtualList,
appliedFontsManager,
createFontRowSizeResolver, createFontRowSizeResolver,
fontStore, fontCatalogStore,
fontLifecycleManager,
} from '$entities/Font'; } from '$entities/Font';
import { import {
TypographyMenu, TypographyMenu,
@@ -57,17 +57,17 @@ const checkPosition = throttle(() => {
}, 100); }, 100);
// Resolver recreated when typography values change. The returned closure reads // Resolver recreated when typography values change. The returned closure reads
// appliedFontsManager.statuses (a SvelteMap) on every call, so any font status // fontLifecycleManager.statuses (a SvelteMap) on every call, so any font status
// change triggers a full offsets recompute in createVirtualizer — no DOM snap. // change triggers a full offsets recompute in createVirtualizer — no DOM snap.
const fontRowHeight = $derived.by(() => const fontRowHeight = $derived.by(() =>
createFontRowSizeResolver({ createFontRowSizeResolver({
getFonts: () => fontStore.fonts, getFonts: () => fontCatalogStore.fonts,
getWeight: () => typographySettingsStore.weight, getWeight: () => typographySettingsStore.weight,
getPreviewText: () => text, getPreviewText: () => text,
getContainerWidth: () => containerWidth, getContainerWidth: () => containerWidth,
getFontSizePx: () => typographySettingsStore.renderedSize, getFontSizePx: () => typographySettingsStore.renderedSize,
getLineHeightPx: () => typographySettingsStore.height * typographySettingsStore.renderedSize, getLineHeightPx: () => typographySettingsStore.height * typographySettingsStore.renderedSize,
getStatus: key => appliedFontsManager.statuses.get(key), getStatus: key => fontLifecycleManager.statuses.get(key),
contentHorizontalPadding: SAMPLER_CONTENT_PADDING_X, contentHorizontalPadding: SAMPLER_CONTENT_PADDING_X,
chromeHeight: SAMPLER_CHROME_HEIGHT, chromeHeight: SAMPLER_CHROME_HEIGHT,
fallbackHeight: SAMPLER_FALLBACK_HEIGHT, fallbackHeight: SAMPLER_FALLBACK_HEIGHT,
@@ -4,7 +4,7 @@
--> -->
<script lang="ts"> <script lang="ts">
import { NavigationWrapper } from '$entities/Breadcrumb'; import { NavigationWrapper } from '$entities/Breadcrumb';
import { fontStore } from '$entities/Font'; import { fontCatalogStore } from '$entities/Font';
import type { ResponsiveManager } from '$shared/lib'; import type { ResponsiveManager } from '$shared/lib';
import { cn } from '$shared/lib'; import { cn } from '$shared/lib';
import { import {
@@ -36,7 +36,7 @@ const responsive = getContext<ResponsiveManager>('responsive');
id="sample_set" id="sample_set"
title="Sample Set" title="Sample Set"
headerTitle="visual_output" headerTitle="visual_output"
headerSubtitle="items_total: {fontStore.pagination.total ?? 0}" headerSubtitle="items_total: {fontCatalogStore.pagination.total ?? 0}"
headerAction={registerAction} headerAction={registerAction}
> >
{#snippet headerContent()} {#snippet headerContent()}