Compare commits
10 Commits
e0d39d861f
...
06b6274e66
| Author | SHA1 | Date | |
|---|---|---|---|
| 06b6274e66 | |||
| 0c59262a59 | |||
| 2bb43797f0 | |||
| ccef3cf7bb | |||
| e3b489f173 | |||
| f92577608a | |||
| 728380498b | |||
| 07d044f4d6 | |||
| df59dfda02 | |||
| ca382fd43d |
@@ -9,6 +9,7 @@ export {
|
||||
fetchFontsByIds,
|
||||
fetchProxyFontById,
|
||||
fetchProxyFonts,
|
||||
seedFontCache,
|
||||
} from './proxy/proxyFonts';
|
||||
export type {
|
||||
ProxyFontsParams,
|
||||
|
||||
@@ -29,10 +29,12 @@ export function seedFontCache(fonts: UnifiedFont[]): void {
|
||||
});
|
||||
}
|
||||
|
||||
import { API_ENDPOINTS } from '$shared/api/endpoints';
|
||||
|
||||
/**
|
||||
* Proxy API base URL
|
||||
* Proxy API endpoint for font resources.
|
||||
*/
|
||||
const PROXY_API_URL = 'https://api.glyphdiff.com/api/v1/fonts' as const;
|
||||
const PROXY_API_URL = API_ENDPOINTS.fonts;
|
||||
|
||||
/**
|
||||
* Proxy API parameters
|
||||
|
||||
@@ -667,10 +667,10 @@ export const MOCK_STORES = {
|
||||
};
|
||||
},
|
||||
/**
|
||||
* Create a mock FontStore object
|
||||
* Matches FontStore's public API for Storybook use
|
||||
* Create a mock FontCatalogStore object
|
||||
* Matches FontCatalogStore's public API for Storybook use
|
||||
*/
|
||||
fontStore: (config: {
|
||||
fontCatalogStore: (config: {
|
||||
/**
|
||||
* Preset font list
|
||||
*/
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
// @vitest-environment jsdom
|
||||
import { TextLayoutEngine } from '$shared/lib';
|
||||
import { installCanvasMock } from '$shared/lib/helpers/__mocks__/canvas';
|
||||
import { clearCache } from '@chenglou/pretext';
|
||||
import {
|
||||
clearCache,
|
||||
layout,
|
||||
} from '@chenglou/pretext';
|
||||
|
||||
// Wrap pretext's `layout` in a spy-able mock so tests can assert call counts.
|
||||
// `vi.mock` is hoisted, so the import above receives the mocked module.
|
||||
vi.mock('@chenglou/pretext', async () => {
|
||||
const actual = await vi.importActual<typeof import('@chenglou/pretext')>('@chenglou/pretext');
|
||||
return {
|
||||
...actual,
|
||||
layout: vi.fn(actual.layout),
|
||||
};
|
||||
});
|
||||
import {
|
||||
beforeEach,
|
||||
describe,
|
||||
@@ -112,13 +124,13 @@ describe('createFontRowSizeResolver', () => {
|
||||
const { resolver } = makeResolver();
|
||||
statusMap.set('inter@400', 'loaded');
|
||||
|
||||
const layoutSpy = vi.spyOn(TextLayoutEngine.prototype, 'layout');
|
||||
const layoutSpy = vi.mocked(layout);
|
||||
layoutSpy.mockClear();
|
||||
|
||||
resolver(0);
|
||||
resolver(0);
|
||||
|
||||
expect(layoutSpy).toHaveBeenCalledTimes(1);
|
||||
layoutSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('calls layout() again when containerWidth changes (cache miss)', () => {
|
||||
@@ -126,14 +138,14 @@ describe('createFontRowSizeResolver', () => {
|
||||
const { resolver } = makeResolver({ getContainerWidth: () => width });
|
||||
statusMap.set('inter@400', 'loaded');
|
||||
|
||||
const layoutSpy = vi.spyOn(TextLayoutEngine.prototype, 'layout');
|
||||
const layoutSpy = vi.mocked(layout);
|
||||
layoutSpy.mockClear();
|
||||
|
||||
resolver(0);
|
||||
width = 100;
|
||||
resolver(0);
|
||||
|
||||
expect(layoutSpy).toHaveBeenCalledTimes(2);
|
||||
layoutSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('returns greater height when container narrows (more wrapping)', () => {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { TextLayoutEngine } from '$shared/lib';
|
||||
import { generateFontKey } from '../../model/store/appliedFontsStore/utils/generateFontKey/generateFontKey';
|
||||
import {
|
||||
layout,
|
||||
prepare,
|
||||
} from '@chenglou/pretext';
|
||||
import { generateFontKey } from '../../model/store/fontLifecycleManager/utils/generateFontKey/generateFontKey';
|
||||
import type {
|
||||
FontLoadStatus,
|
||||
UnifiedFont,
|
||||
@@ -41,7 +44,7 @@ export interface FontRowSizeResolverOptions {
|
||||
/**
|
||||
* 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.
|
||||
* 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
|
||||
@@ -79,14 +82,13 @@ export interface FontRowSizeResolverOptions {
|
||||
* no DOM snap occurs.
|
||||
*
|
||||
* **Caching:** A `Map` keyed by `fontCssString|text|contentWidth|lineHeightPx`
|
||||
* prevents redundant `TextLayoutEngine.layout()` calls. The cache is invalidated
|
||||
* prevents redundant `pretext.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>();
|
||||
|
||||
@@ -108,7 +110,7 @@ export function createFontRowSizeResolver(options: FontRowSizeResolverOptions):
|
||||
// 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(),
|
||||
// Reading via getStatus() allows the caller to pass fontLifecycleManager.statuses.get(),
|
||||
// which creates a Svelte 5 reactive dependency when called inside $derived.by.
|
||||
const status = options.getStatus(fontKey);
|
||||
if (status !== 'loaded') {
|
||||
@@ -126,7 +128,11 @@ export function createFontRowSizeResolver(options: FontRowSizeResolverOptions):
|
||||
return cached;
|
||||
}
|
||||
|
||||
const { totalHeight } = engine.layout(previewText, fontCssString, contentWidth, lineHeightPx);
|
||||
// Pretext docs recommend `layout()` (not `layoutWithLines`) for the
|
||||
// resize hot path — pure arithmetic on cached segment widths, no canvas
|
||||
// calls, no string allocations.
|
||||
const prepared = prepare(previewText, fontCssString);
|
||||
const { height: totalHeight } = layout(prepared, contentWidth, lineHeightPx);
|
||||
const result = totalHeight + options.chromeHeight;
|
||||
cache.set(cacheKey, result);
|
||||
return result;
|
||||
|
||||
+18
-14
@@ -17,13 +17,17 @@ import {
|
||||
generateMockFonts,
|
||||
} from '../../../lib/mocks/fonts.mock';
|
||||
import type { UnifiedFont } from '../../types';
|
||||
import { FontStore } from './fontStore.svelte';
|
||||
import { FontCatalogStore } from './fontCatalogStore.svelte';
|
||||
|
||||
vi.mock('$shared/api/queryClient', () => ({
|
||||
queryClient: new QueryClient({
|
||||
defaultOptions: { queries: { retry: 0, gcTime: 0 } },
|
||||
}),
|
||||
}));
|
||||
vi.mock('$shared/api/queryClient', async importOriginal => {
|
||||
const actual = await importOriginal<typeof import('$shared/api/queryClient')>();
|
||||
return {
|
||||
...actual,
|
||||
queryClient: new QueryClient({
|
||||
defaultOptions: { queries: { retry: 0, gcTime: 0 } },
|
||||
}),
|
||||
};
|
||||
});
|
||||
vi.mock('../../../api', () => ({ fetchProxyFonts: vi.fn() }));
|
||||
|
||||
import { queryClient } from '$shared/api/queryClient';
|
||||
@@ -44,7 +48,7 @@ const makeResponse = (
|
||||
});
|
||||
|
||||
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] = {}) {
|
||||
@@ -55,7 +59,7 @@ async function fetchedStore(params = {}, fonts = generateMockFonts(5), meta: Par
|
||||
return store;
|
||||
}
|
||||
|
||||
describe('FontStore', () => {
|
||||
describe('FontCatalogStore', () => {
|
||||
afterEach(() => {
|
||||
queryClient.clear();
|
||||
vi.resetAllMocks();
|
||||
@@ -69,7 +73,7 @@ describe('FontStore', () => {
|
||||
});
|
||||
|
||||
it('defaults limit to 50 when not provided', () => {
|
||||
const store = new FontStore();
|
||||
const store = new FontCatalogStore();
|
||||
expect(store.params.limit).toBe(50);
|
||||
store.destroy();
|
||||
});
|
||||
@@ -390,11 +394,11 @@ describe('FontStore', () => {
|
||||
});
|
||||
|
||||
describe('nextPage', () => {
|
||||
let store: FontStore;
|
||||
let store: FontCatalogStore;
|
||||
|
||||
beforeEach(async () => {
|
||||
fetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 30, limit: 10, offset: 0 }));
|
||||
store = new FontStore({ limit: 10 });
|
||||
store = new FontCatalogStore({ limit: 10 });
|
||||
await store.refetch();
|
||||
flushSync();
|
||||
});
|
||||
@@ -415,7 +419,7 @@ describe('FontStore', () => {
|
||||
// Set up a store where all fonts fit in one page (hasMore = false)
|
||||
queryClient.clear();
|
||||
fetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 10, limit: 10, offset: 0 }));
|
||||
store = new FontStore({ limit: 10 });
|
||||
store = new FontCatalogStore({ limit: 10 });
|
||||
await store.refetch();
|
||||
flushSync();
|
||||
|
||||
@@ -454,7 +458,7 @@ describe('FontStore', () => {
|
||||
describe('getCachedData / setQueryData', () => {
|
||||
it('getCachedData returns undefined before any fetch', () => {
|
||||
queryClient.clear();
|
||||
const store = new FontStore({ limit: 10 });
|
||||
const store = new FontCatalogStore({ limit: 10 });
|
||||
expect(store.getCachedData()).toBeUndefined();
|
||||
store.destroy();
|
||||
});
|
||||
@@ -502,7 +506,7 @@ describe('FontStore', () => {
|
||||
});
|
||||
|
||||
describe('filter shortcut methods', () => {
|
||||
let store: FontStore;
|
||||
let store: FontCatalogStore;
|
||||
|
||||
beforeEach(() => {
|
||||
store = makeStore();
|
||||
+11
-7
@@ -1,4 +1,8 @@
|
||||
import { queryClient } from '$shared/api/queryClient';
|
||||
import {
|
||||
DEFAULT_QUERY_GC_TIME_MS,
|
||||
DEFAULT_QUERY_STALE_TIME_MS,
|
||||
queryClient,
|
||||
} from '$shared/api/queryClient';
|
||||
import {
|
||||
type InfiniteData,
|
||||
InfiniteQueryObserver,
|
||||
@@ -25,7 +29,7 @@ type FontStoreParams = Omit<ProxyFontsParams, 'offset'>;
|
||||
|
||||
type FontStoreResult = InfiniteQueryObserverResult<InfiniteData<ProxyFontsResponse, PageParam>, Error>;
|
||||
|
||||
export class FontStore {
|
||||
export class FontCatalogStore {
|
||||
#params = $state<FontStoreParams>({ limit: 50 });
|
||||
#result = $state<FontStoreResult>({} as FontStoreResult);
|
||||
#observer: InfiniteQueryObserver<
|
||||
@@ -427,8 +431,8 @@ export class FontStore {
|
||||
const next = lastPage.offset + lastPage.limit;
|
||||
return next < lastPage.total ? { offset: next } : undefined;
|
||||
},
|
||||
staleTime: hasFilters ? 0 : 5 * 60 * 1000,
|
||||
gcTime: 10 * 60 * 1000,
|
||||
staleTime: hasFilters ? 0 : DEFAULT_QUERY_STALE_TIME_MS,
|
||||
gcTime: DEFAULT_QUERY_GC_TIME_MS,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -459,8 +463,8 @@ export class FontStore {
|
||||
}
|
||||
}
|
||||
|
||||
export function createFontStore(params: FontStoreParams = {}): FontStore {
|
||||
return new FontStore(params);
|
||||
export function createFontCatalogStore(params: FontStoreParams = {}): FontCatalogStore {
|
||||
return new FontCatalogStore(params);
|
||||
}
|
||||
|
||||
export const fontStore = new FontStore({ limit: 50 });
|
||||
export const fontCatalogStore = new FontCatalogStore({ limit: 50 });
|
||||
+38
-12
@@ -17,7 +17,36 @@ import { FontBufferCache } from './utils/fontBufferCache/FontBufferCache';
|
||||
import { FontEvictionPolicy } from './utils/fontEvictionPolicy/FontEvictionPolicy';
|
||||
import { FontLoadQueue } from './utils/fontLoadQueue/FontLoadQueue';
|
||||
|
||||
interface AppliedFontsManagerDeps {
|
||||
/**
|
||||
* How often the periodic eviction sweep runs.
|
||||
*/
|
||||
const PURGE_INTERVAL_MS = 60000;
|
||||
|
||||
/**
|
||||
* Timeout for `requestIdleCallback`. After this elapses, the callback is
|
||||
* forced to run regardless of whether the browser is idle.
|
||||
*/
|
||||
const IDLE_CALLBACK_TIMEOUT_MS = 150;
|
||||
|
||||
/**
|
||||
* setTimeout fallback delay when `requestIdleCallback` is unavailable.
|
||||
* ~16ms ≈ one frame at 60fps.
|
||||
*/
|
||||
const SCHEDULE_FALLBACK_MS = 16;
|
||||
|
||||
/**
|
||||
* How often the parse loop yields back to the main thread when the browser
|
||||
* does not provide `isInputPending` (non-Chromium fallback).
|
||||
*/
|
||||
const YIELD_INTERVAL_MS = 8;
|
||||
|
||||
/**
|
||||
* Font weights treated as "critical" in data-saver mode. Other weights are
|
||||
* skipped to reduce network usage; variable fonts bypass this filter.
|
||||
*/
|
||||
const CRITICAL_FONT_WEIGHTS = [400, 700];
|
||||
|
||||
interface FontLifecycleManagerDeps {
|
||||
cache?: FontBufferCache;
|
||||
eviction?: FontEvictionPolicy;
|
||||
queue?: FontLoadQueue;
|
||||
@@ -46,7 +75,7 @@ interface AppliedFontsManagerDeps {
|
||||
*
|
||||
* **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
|
||||
readonly #cache: FontBufferCache;
|
||||
readonly #eviction: FontEvictionPolicy;
|
||||
@@ -70,22 +99,20 @@ export class AppliedFontsManager {
|
||||
// Tracks which callback type is pending ('idle' | 'timeout' | null) for proper cancellation
|
||||
#pendingType: 'idle' | 'timeout' | null = null;
|
||||
|
||||
readonly #PURGE_INTERVAL = 60000;
|
||||
|
||||
// Reactive status map for Svelte components to track font states
|
||||
statuses = new SvelteMap<string, FontLoadStatus>();
|
||||
|
||||
// Starts periodic cleanup timer (browser-only).
|
||||
constructor(
|
||||
{ cache = new FontBufferCache(), eviction = new FontEvictionPolicy(), queue = new FontLoadQueue() }:
|
||||
AppliedFontsManagerDeps = {},
|
||||
FontLifecycleManagerDeps = {},
|
||||
) {
|
||||
// Inject collaborators - defaults provided for production, fakes for testing
|
||||
this.#cache = cache;
|
||||
this.#eviction = eviction;
|
||||
this.#queue = queue;
|
||||
if (typeof window !== 'undefined') {
|
||||
this.#intervalId = setInterval(() => this.#purgeUnused(), this.#PURGE_INTERVAL);
|
||||
this.#intervalId = setInterval(() => this.#purgeUnused(), PURGE_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,11 +174,11 @@ export class AppliedFontsManager {
|
||||
if (typeof requestIdleCallback !== 'undefined') {
|
||||
this.#timeoutId = requestIdleCallback(
|
||||
() => this.#processQueue(),
|
||||
{ timeout: 150 },
|
||||
{ timeout: IDLE_CALLBACK_TIMEOUT_MS },
|
||||
) as unknown as ReturnType<typeof setTimeout>;
|
||||
this.#pendingType = 'idle';
|
||||
} else {
|
||||
this.#timeoutId = setTimeout(() => this.#processQueue(), 16);
|
||||
this.#timeoutId = setTimeout(() => this.#processQueue(), SCHEDULE_FALLBACK_MS);
|
||||
this.#pendingType = 'timeout';
|
||||
}
|
||||
}
|
||||
@@ -183,7 +210,7 @@ export class AppliedFontsManager {
|
||||
|
||||
// In data-saver mode, only load variable fonts and common weights (400, 700)
|
||||
if (this.#shouldDeferNonCritical()) {
|
||||
entries = entries.filter(([, c]) => c.isVariable || [400, 700].includes(c.weight));
|
||||
entries = entries.filter(([, c]) => c.isVariable || CRITICAL_FONT_WEIGHTS.includes(c.weight));
|
||||
}
|
||||
|
||||
// Determine optimal concurrent fetches based on network speed (1-4)
|
||||
@@ -198,7 +225,6 @@ export class AppliedFontsManager {
|
||||
// Parse buffers one at a time with periodic yields to avoid blocking UI
|
||||
const hasInputPending = !!(navigator as any).scheduling?.isInputPending;
|
||||
let lastYield = performance.now();
|
||||
const YIELD_INTERVAL = 8;
|
||||
|
||||
for (const [key, config] of entries) {
|
||||
const buffer = buffers.get(key);
|
||||
@@ -214,7 +240,7 @@ export class AppliedFontsManager {
|
||||
// Others: yield every 8ms as fallback
|
||||
const shouldYield = hasInputPending
|
||||
? (navigator as any).scheduling.isInputPending({ includeContinuous: true })
|
||||
: performance.now() - lastYield > YIELD_INTERVAL;
|
||||
: performance.now() - lastYield > YIELD_INTERVAL_MS;
|
||||
|
||||
if (shouldYield) {
|
||||
await yieldToMainThread();
|
||||
@@ -396,4 +422,4 @@ export class AppliedFontsManager {
|
||||
/**
|
||||
* Singleton instance — use throughout the application for unified font loading state.
|
||||
*/
|
||||
export const appliedFontsManager = new AppliedFontsManager();
|
||||
export const fontLifecycleManager = new FontLifecycleManager();
|
||||
+8
-8
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { AppliedFontsManager } from './appliedFontsStore.svelte';
|
||||
import { FontFetchError } from './errors';
|
||||
import { FontLifecycleManager } from './fontLifecycleManager.svelte';
|
||||
import { FontEvictionPolicy } from './utils/fontEvictionPolicy/FontEvictionPolicy';
|
||||
|
||||
class FakeBufferCache {
|
||||
@@ -32,8 +32,8 @@ const makeConfig = (id: string, overrides: Partial<{ weight: number; isVariable:
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('AppliedFontsManager', () => {
|
||||
let manager: AppliedFontsManager;
|
||||
describe('FontLifecycleManager', () => {
|
||||
let manager: FontLifecycleManager;
|
||||
let eviction: FontEvictionPolicy;
|
||||
let mockFontFaceSet: { add: ReturnType<typeof vi.fn>; delete: ReturnType<typeof vi.fn> };
|
||||
|
||||
@@ -55,7 +55,7 @@ describe('AppliedFontsManager', () => {
|
||||
});
|
||||
vi.stubGlobal('FontFace', MockFontFace);
|
||||
|
||||
manager = new AppliedFontsManager({ cache: new FakeBufferCache() as any, eviction });
|
||||
manager = new FontLifecycleManager({ cache: new FakeBufferCache() as any, eviction });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -101,7 +101,7 @@ describe('AppliedFontsManager', () => {
|
||||
|
||||
it('skips fonts that have exhausted retries', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const failManager = new AppliedFontsManager({ cache: new FailingBufferCache() as any, eviction });
|
||||
const failManager = new FontLifecycleManager({ cache: new FailingBufferCache() as any, eviction });
|
||||
|
||||
// exhaust all 3 retries
|
||||
for (let i = 0; i < 3; i++) {
|
||||
@@ -160,7 +160,7 @@ describe('AppliedFontsManager', () => {
|
||||
describe('Phase 1 — fetch', () => {
|
||||
it('sets status to error on fetch failure', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const failManager = new AppliedFontsManager({ cache: new FailingBufferCache() as any, eviction });
|
||||
const failManager = new FontLifecycleManager({ cache: new FailingBufferCache() as any, eviction });
|
||||
|
||||
failManager.touch([makeConfig('broken')]);
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
@@ -171,7 +171,7 @@ describe('AppliedFontsManager', () => {
|
||||
|
||||
it('logs a console error on fetch failure', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const failManager = new AppliedFontsManager({ cache: new FailingBufferCache() as any, eviction });
|
||||
const failManager = new FontLifecycleManager({ cache: new FailingBufferCache() as any, eviction });
|
||||
|
||||
failManager.touch([makeConfig('broken')]);
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
@@ -189,7 +189,7 @@ describe('AppliedFontsManager', () => {
|
||||
evict() {},
|
||||
clear() {},
|
||||
};
|
||||
const abortManager = new AppliedFontsManager({ cache: abortingCache as any, eviction });
|
||||
const abortManager = new FontLifecycleManager({ cache: abortingCache as any, eviction });
|
||||
|
||||
abortManager.touch([makeConfig('aborted')]);
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
+7
-2
@@ -1,6 +1,11 @@
|
||||
/**
|
||||
* Default TTL after which an unpinned font is eligible for eviction.
|
||||
*/
|
||||
export const DEFAULT_FONT_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
interface FontEvictionPolicyOptions {
|
||||
/**
|
||||
* TTL in milliseconds. Defaults to 5 minutes.
|
||||
* TTL in milliseconds. Defaults to {@link DEFAULT_FONT_TTL_MS}.
|
||||
*/
|
||||
ttl?: number;
|
||||
}
|
||||
@@ -17,7 +22,7 @@ export class FontEvictionPolicy {
|
||||
|
||||
readonly #TTL: number;
|
||||
|
||||
constructor({ ttl = 5 * 60 * 1000 }: FontEvictionPolicyOptions = {}) {
|
||||
constructor({ ttl = DEFAULT_FONT_TTL_MS }: FontEvictionPolicyOptions = {}) {
|
||||
this.#TTL = ttl;
|
||||
}
|
||||
|
||||
+7
-3
@@ -1,5 +1,11 @@
|
||||
import type { FontLoadRequestConfig } from '../../../../types';
|
||||
|
||||
/**
|
||||
* Maximum number of times a single font key will be retried before it is
|
||||
* considered permanently failed.
|
||||
*/
|
||||
export const FONT_LOAD_MAX_RETRIES = 3;
|
||||
|
||||
/**
|
||||
* Manages the font load queue and per-font retry counts.
|
||||
*
|
||||
@@ -10,8 +16,6 @@ export class FontLoadQueue {
|
||||
#queue = new Map<string, FontLoadRequestConfig>();
|
||||
#retryCounts = new Map<string, number>();
|
||||
|
||||
readonly #MAX_RETRIES = 3;
|
||||
|
||||
/**
|
||||
* Adds a font to the queue.
|
||||
* @returns `true` if the key was newly enqueued, `false` if it was already present.
|
||||
@@ -52,7 +56,7 @@ export class FontLoadQueue {
|
||||
* 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;
|
||||
return (this.#retryCounts.get(key) ?? 0) >= FONT_LOAD_MAX_RETRIES;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1,12 +1,9 @@
|
||||
// Applied fonts manager
|
||||
export * from './appliedFontsStore/appliedFontsStore.svelte';
|
||||
// Font lifecycle manager (browser-side load + cache + eviction)
|
||||
export * from './fontLifecycleManager/fontLifecycleManager.svelte';
|
||||
|
||||
// Batch font store
|
||||
export { BatchFontStore } from './batchFontStore/batchFontStore.svelte';
|
||||
|
||||
// Single FontStore
|
||||
// Paginated catalog
|
||||
export {
|
||||
createFontStore,
|
||||
FontStore,
|
||||
fontStore,
|
||||
} from './fontStore/fontStore.svelte';
|
||||
createFontCatalogStore,
|
||||
FontCatalogStore,
|
||||
fontCatalogStore,
|
||||
} from './fontCatalogStore/fontCatalogStore.svelte';
|
||||
|
||||
@@ -23,5 +23,5 @@ export type {
|
||||
FontCollectionState,
|
||||
} from './store';
|
||||
|
||||
export * from './store/appliedFonts';
|
||||
export * from './store/fontLifecycle';
|
||||
export * from './typography';
|
||||
|
||||
@@ -39,7 +39,7 @@ const fontArialBold = mockUnifiedFont({ id: 'arial-bold', name: 'Arial' });
|
||||
docs: {
|
||||
description: {
|
||||
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: {
|
||||
description: {
|
||||
story:
|
||||
'Uses Arial, a system font available in all browsers. Because appliedFontsManager has not loaded it via FontFace, the manager status may remain pending — meaning the blur/scale state may still show. In a real app the manager would load the font and transition to the revealed state.',
|
||||
'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: {
|
||||
description: {
|
||||
story:
|
||||
'Demonstrates passing a custom weight (700). The weight is forwarded to appliedFontsManager for font resolution; visually identical to the loaded state story until the manager confirms the font.',
|
||||
'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 {
|
||||
DEFAULT_FONT_WEIGHT,
|
||||
type UnifiedFont,
|
||||
appliedFontsManager,
|
||||
fontLifecycleManager,
|
||||
} from '../../model';
|
||||
|
||||
interface Props {
|
||||
@@ -46,7 +46,7 @@ let {
|
||||
}: Props = $props();
|
||||
|
||||
const status = $derived(
|
||||
appliedFontsManager.getFontStatus(
|
||||
fontLifecycleManager.getFontStatus(
|
||||
font.id,
|
||||
weight,
|
||||
font.features?.isVariable,
|
||||
|
||||
@@ -10,7 +10,7 @@ const { Story } = defineMeta({
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Virtualized font list backed by the `fontStore` singleton. Handles font loading registration (pin/touch) for visible items and triggers infinite scroll pagination via `fontStore.nextPage()`. Because the component reads directly from the `fontStore` singleton, stories render against a live (but empty/loading) store — no font data will appear unless the API is reachable from the Storybook host.',
|
||||
'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 },
|
||||
},
|
||||
@@ -33,7 +33,7 @@ import type { ComponentProps } from 'svelte';
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Skeleton state shown while `fontStore.fonts` is empty and `fontStore.isLoading` is true. In a real session the skeleton fades out once the first page loads.',
|
||||
'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: {
|
||||
description: {
|
||||
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: {
|
||||
description: {
|
||||
story:
|
||||
'Demonstrates how to configure a `children` snippet for item rendering. The list will be empty because `fontStore` is not populated in Storybook, but the template shows the expected slot shape: `{ item: UnifiedFont }`.',
|
||||
'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 {
|
||||
type FontLoadRequestConfig,
|
||||
type UnifiedFont,
|
||||
appliedFontsManager,
|
||||
fontStore,
|
||||
fontCatalogStore,
|
||||
fontLifecycleManager,
|
||||
} from '../../model';
|
||||
|
||||
interface Props extends
|
||||
@@ -51,13 +51,13 @@ let {
|
||||
}: Props = $props();
|
||||
|
||||
const isLoading = $derived(
|
||||
fontStore.isFetching || fontStore.isLoading,
|
||||
fontCatalogStore.isFetching || fontCatalogStore.isLoading,
|
||||
);
|
||||
|
||||
let visibleFonts = $state<UnifiedFont[]>([]);
|
||||
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);
|
||||
|
||||
function handleInternalVisibleChange(items: UnifiedFont[]) {
|
||||
@@ -68,24 +68,30 @@ function handleInternalVisibleChange(items: UnifiedFont[]) {
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
async function handleJump(targetIndex: number) {
|
||||
if (isCatchingUp || !fontStore.pagination.hasMore) {
|
||||
if (isCatchingUp || !fontCatalogStore.pagination.hasMore) {
|
||||
return;
|
||||
}
|
||||
isCatchingUp = true;
|
||||
try {
|
||||
await fontStore.fetchAllPagesTo(targetIndex);
|
||||
await fontCatalogStore.fetchAllPagesTo(targetIndex);
|
||||
} finally {
|
||||
isCatchingUp = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounce wait before asking the font lifecycle manager to load fonts
|
||||
* for the current visible window. Coalesces rapid scroll into one batch.
|
||||
*/
|
||||
const TOUCH_DEBOUNCE_MS = 150;
|
||||
|
||||
const debouncedTouch = debounce((configs: FontLoadRequestConfig[]) => {
|
||||
appliedFontsManager.touch(configs);
|
||||
}, 150);
|
||||
fontLifecycleManager.touch(configs);
|
||||
}, TOUCH_DEBOUNCE_MS);
|
||||
|
||||
// Re-touch whenever visible set or weight changes — fixes weight-change gap
|
||||
$effect(() => {
|
||||
@@ -111,11 +117,11 @@ $effect(() => {
|
||||
const w = weight;
|
||||
const fonts = visibleFonts;
|
||||
for (const f of fonts) {
|
||||
appliedFontsManager.pin(f.id, w, f.features?.isVariable);
|
||||
fontLifecycleManager.pin(f.id, w, f.features?.isVariable);
|
||||
}
|
||||
return () => {
|
||||
for (const f of fonts) {
|
||||
appliedFontsManager.unpin(f.id, w, f.features?.isVariable);
|
||||
fontLifecycleManager.unpin(f.id, w, f.features?.isVariable);
|
||||
}
|
||||
};
|
||||
});
|
||||
@@ -125,12 +131,12 @@ $effect(() => {
|
||||
*/
|
||||
function loadMore() {
|
||||
if (
|
||||
!fontStore.pagination.hasMore
|
||||
|| fontStore.isFetching
|
||||
!fontCatalogStore.pagination.hasMore
|
||||
|| fontCatalogStore.isFetching
|
||||
) {
|
||||
return;
|
||||
}
|
||||
fontStore.nextPage();
|
||||
fontCatalogStore.nextPage();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -140,12 +146,12 @@ function loadMore() {
|
||||
* of the loaded items. Only fetches if there are more pages available.
|
||||
*/
|
||||
function handleNearBottom(_lastVisibleIndex: number) {
|
||||
const { hasMore } = fontStore.pagination;
|
||||
const { hasMore } = fontCatalogStore.pagination;
|
||||
|
||||
// VirtualList already checks if we're near the bottom of loaded items.
|
||||
// Guard isCatchingUp: fetchAllPagesTo bypasses TQ so isFetching stays false
|
||||
// during batch catch-up, which would otherwise let nextPage() race with it.
|
||||
if (hasMore && !fontStore.isFetching && !isCatchingUp) {
|
||||
if (hasMore && !fontCatalogStore.isFetching && !isCatchingUp) {
|
||||
loadMore();
|
||||
}
|
||||
}
|
||||
@@ -160,8 +166,8 @@ function handleNearBottom(_lastVisibleIndex: number) {
|
||||
{:else}
|
||||
<!-- VirtualList persists during pagination - no destruction/recreation -->
|
||||
<VirtualList
|
||||
items={fontStore.fonts}
|
||||
total={fontStore.pagination.total}
|
||||
items={fontCatalogStore.fonts}
|
||||
total={fontCatalogStore.pagination.total}
|
||||
isLoading={isLoading || isCatchingUp}
|
||||
onVisibleItemsChange={handleInternalVisibleChange}
|
||||
onNearBottom={handleNearBottom}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
createTypographySettingsStore,
|
||||
type TypographySettingsStore,
|
||||
typographySettingsStore,
|
||||
} from './model';
|
||||
export { TypographyMenu } from './ui';
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
createTypographySettingsStore,
|
||||
type TypographySettingsStore,
|
||||
typographySettingsStore,
|
||||
} from './store/typographySettingsStore/typographySettingsStore.svelte';
|
||||
+32
-5
@@ -16,6 +16,7 @@ import {
|
||||
DEFAULT_FONT_WEIGHT,
|
||||
DEFAULT_LETTER_SPACING,
|
||||
DEFAULT_LINE_HEIGHT,
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
} from '$entities/Font';
|
||||
import {
|
||||
type ControlDataModel,
|
||||
@@ -27,6 +28,14 @@ import {
|
||||
} from '$shared/lib';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
|
||||
/**
|
||||
* Epsilon for detecting "significant" base-size changes when reconciling
|
||||
* the multiplier-derived display value back to the underlying baseSize.
|
||||
* Differences below this threshold are treated as rounding jitter and
|
||||
* skipped to avoid spurious storage writes.
|
||||
*/
|
||||
const BASE_SIZE_EPSILON = 0.01;
|
||||
|
||||
type ControlOnlyFields<T extends string = string> = Omit<ControlModel<T>, keyof ControlDataModel>;
|
||||
|
||||
/**
|
||||
@@ -67,7 +76,7 @@ export interface TypographySettings {
|
||||
* Manages multiple typography controls with persistent storage and
|
||||
* responsive scaling support for font size.
|
||||
*/
|
||||
export class TypographySettingsManager {
|
||||
export class TypographySettingsStore {
|
||||
/**
|
||||
* Internal map of reactive controls keyed by their identifier
|
||||
*/
|
||||
@@ -138,7 +147,7 @@ export class TypographySettingsManager {
|
||||
const calculatedBase = currentDisplayValue / this.#multiplier;
|
||||
|
||||
// Only update if the difference is significant (prevents rounding jitter)
|
||||
if (Math.abs(this.#baseSize - calculatedBase) > 0.01) {
|
||||
if (Math.abs(this.#baseSize - calculatedBase) > BASE_SIZE_EPSILON) {
|
||||
this.#baseSize = calculatedBase;
|
||||
}
|
||||
});
|
||||
@@ -296,6 +305,16 @@ export class TypographySettingsManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default factory storage key — used when a caller doesn't pass one.
|
||||
*/
|
||||
const DEFAULT_STORAGE_KEY = 'glyphdiff:typography';
|
||||
|
||||
/**
|
||||
* Storage key used by the app-wide singleton (scoped to comparison view).
|
||||
*/
|
||||
const COMPARISON_STORAGE_KEY = 'glyphdiff:comparison:typography';
|
||||
|
||||
/**
|
||||
* Creates a typography control manager
|
||||
*
|
||||
@@ -303,9 +322,9 @@ export class TypographySettingsManager {
|
||||
* @param storageId - Persistent storage identifier
|
||||
* @returns Typography control manager instance
|
||||
*/
|
||||
export function createTypographySettingsManager(
|
||||
export function createTypographySettingsStore(
|
||||
configs: ControlModel<ControlId>[],
|
||||
storageId: string = 'glyphdiff:typography',
|
||||
storageId: string = DEFAULT_STORAGE_KEY,
|
||||
) {
|
||||
const storage = createPersistentStore<TypographySettings>(storageId, {
|
||||
fontSize: DEFAULT_FONT_SIZE,
|
||||
@@ -313,5 +332,13 @@ export function createTypographySettingsManager(
|
||||
lineHeight: DEFAULT_LINE_HEIGHT,
|
||||
letterSpacing: DEFAULT_LETTER_SPACING,
|
||||
});
|
||||
return new TypographySettingsManager(configs, storage);
|
||||
return new TypographySettingsStore(configs, storage);
|
||||
}
|
||||
|
||||
/**
|
||||
* App-wide typography settings singleton, keyed for the comparison view.
|
||||
*/
|
||||
export const typographySettingsStore = createTypographySettingsStore(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
COMPARISON_STORAGE_KEY,
|
||||
);
|
||||
+45
-45
@@ -17,13 +17,13 @@ import {
|
||||
} from 'vitest';
|
||||
import {
|
||||
type TypographySettings,
|
||||
TypographySettingsManager,
|
||||
} from './settingsManager.svelte';
|
||||
TypographySettingsStore,
|
||||
} from './typographySettingsStore.svelte';
|
||||
|
||||
/**
|
||||
* Test Strategy for TypographySettingsManager
|
||||
* Test Strategy for TypographySettingsStore
|
||||
*
|
||||
* This test suite validates the TypographySettingsManager state management logic.
|
||||
* This test suite validates the TypographySettingsStore state management logic.
|
||||
* 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
|
||||
@@ -46,7 +46,7 @@ async function flushEffects() {
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
describe('TypographySettingsManager - Unit Tests', () => {
|
||||
describe('TypographySettingsStore - Unit Tests', () => {
|
||||
let mockStorage: TypographySettings;
|
||||
let mockPersistentStore: {
|
||||
value: TypographySettings;
|
||||
@@ -86,7 +86,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
||||
|
||||
describe('Initialization', () => {
|
||||
it('creates manager with default values from storage', () => {
|
||||
const manager = new TypographySettingsManager(
|
||||
const manager = new TypographySettingsStore(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -106,7 +106,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
||||
};
|
||||
mockPersistentStore = createMockPersistentStore(mockStorage);
|
||||
|
||||
const manager = new TypographySettingsManager(
|
||||
const manager = new TypographySettingsStore(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -118,7 +118,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('initializes font size control with base size multiplied by current multiplier (1)', () => {
|
||||
const manager = new TypographySettingsManager(
|
||||
const manager = new TypographySettingsStore(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -127,7 +127,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('returns all controls via controls getter', () => {
|
||||
const manager = new TypographySettingsManager(
|
||||
const manager = new TypographySettingsStore(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -143,7 +143,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('returns individual controls via specific getters', () => {
|
||||
const manager = new TypographySettingsManager(
|
||||
const manager = new TypographySettingsStore(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -161,7 +161,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('control instances have expected interface', () => {
|
||||
const manager = new TypographySettingsManager(
|
||||
const manager = new TypographySettingsStore(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -180,7 +180,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
||||
|
||||
describe('Multiplier System', () => {
|
||||
it('has default multiplier of 1', () => {
|
||||
const manager = new TypographySettingsManager(
|
||||
const manager = new TypographySettingsStore(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -189,7 +189,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('updates multiplier when set', () => {
|
||||
const manager = new TypographySettingsManager(
|
||||
const manager = new TypographySettingsStore(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -202,7 +202,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('does not update multiplier if set to same value', () => {
|
||||
const manager = new TypographySettingsManager(
|
||||
const manager = new TypographySettingsStore(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -218,7 +218,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
||||
mockStorage = { fontSize: 48, fontWeight: 400, lineHeight: 1.5, letterSpacing: 0 };
|
||||
mockPersistentStore = createMockPersistentStore(mockStorage);
|
||||
|
||||
const manager = new TypographySettingsManager(
|
||||
const manager = new TypographySettingsStore(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -242,7 +242,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('updates font size control display value when multiplier increases', () => {
|
||||
const manager = new TypographySettingsManager(
|
||||
const manager = new TypographySettingsStore(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -263,7 +263,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
||||
|
||||
describe('Base Size Setter', () => {
|
||||
it('updates baseSize when set directly', () => {
|
||||
const manager = new TypographySettingsManager(
|
||||
const manager = new TypographySettingsStore(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -274,7 +274,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('updates size control value when baseSize is set', () => {
|
||||
const manager = new TypographySettingsManager(
|
||||
const manager = new TypographySettingsStore(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -285,7 +285,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('applies multiplier to size control when baseSize is set', () => {
|
||||
const manager = new TypographySettingsManager(
|
||||
const manager = new TypographySettingsStore(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -299,7 +299,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
||||
|
||||
describe('Rendered Size Calculation', () => {
|
||||
it('calculates renderedSize as baseSize * multiplier', () => {
|
||||
const manager = new TypographySettingsManager(
|
||||
const manager = new TypographySettingsStore(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -308,7 +308,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('updates renderedSize when multiplier changes', () => {
|
||||
const manager = new TypographySettingsManager(
|
||||
const manager = new TypographySettingsStore(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -321,7 +321,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('updates renderedSize when baseSize changes', () => {
|
||||
const manager = new TypographySettingsManager(
|
||||
const manager = new TypographySettingsStore(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -341,7 +341,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
||||
// proxy effect behavior should be tested in E2E tests.
|
||||
|
||||
it('does NOT immediately update baseSize from control change (effect is async)', () => {
|
||||
const manager = new TypographySettingsManager(
|
||||
const manager = new TypographySettingsStore(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -356,7 +356,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('updates baseSize via direct setter (synchronous)', () => {
|
||||
const manager = new TypographySettingsManager(
|
||||
const manager = new TypographySettingsStore(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -381,7 +381,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
||||
};
|
||||
mockPersistentStore = createMockPersistentStore(mockStorage);
|
||||
|
||||
const manager = new TypographySettingsManager(
|
||||
const manager = new TypographySettingsStore(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -394,7 +394,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('syncs to storage after effect flush (async)', async () => {
|
||||
const manager = new TypographySettingsManager(
|
||||
const manager = new TypographySettingsStore(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -410,7 +410,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('syncs control changes to storage after effect flush (async)', async () => {
|
||||
const manager = new TypographySettingsManager(
|
||||
const manager = new TypographySettingsStore(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -423,7 +423,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('syncs height control changes to storage after effect flush (async)', async () => {
|
||||
const manager = new TypographySettingsManager(
|
||||
const manager = new TypographySettingsStore(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -435,7 +435,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('syncs spacing control changes to storage after effect flush (async)', async () => {
|
||||
const manager = new TypographySettingsManager(
|
||||
const manager = new TypographySettingsStore(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -449,7 +449,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
||||
|
||||
describe('Control Value Getters', () => {
|
||||
it('returns current weight value', () => {
|
||||
const manager = new TypographySettingsManager(
|
||||
const manager = new TypographySettingsStore(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -461,7 +461,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('returns current height value', () => {
|
||||
const manager = new TypographySettingsManager(
|
||||
const manager = new TypographySettingsStore(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -473,7 +473,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('returns current spacing value', () => {
|
||||
const manager = new TypographySettingsManager(
|
||||
const manager = new TypographySettingsStore(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -486,7 +486,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
||||
|
||||
it('returns default value when control is not found', () => {
|
||||
// Create a manager with empty configs (no controls)
|
||||
const manager = new TypographySettingsManager([], mockPersistentStore);
|
||||
const manager = new TypographySettingsStore([], mockPersistentStore);
|
||||
|
||||
expect(manager.weight).toBe(DEFAULT_FONT_WEIGHT);
|
||||
expect(manager.height).toBe(DEFAULT_LINE_HEIGHT);
|
||||
@@ -504,7 +504,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
||||
};
|
||||
mockPersistentStore = createMockPersistentStore(mockStorage);
|
||||
|
||||
const manager = new TypographySettingsManager(
|
||||
const manager = new TypographySettingsStore(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -537,7 +537,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
||||
clear: clearSpy,
|
||||
};
|
||||
|
||||
const manager = new TypographySettingsManager(
|
||||
const manager = new TypographySettingsStore(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -548,7 +548,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('respects multiplier when resetting font size control', () => {
|
||||
const manager = new TypographySettingsManager(
|
||||
const manager = new TypographySettingsStore(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -566,7 +566,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
||||
|
||||
describe('Complex Scenarios', () => {
|
||||
it('handles changing multiplier then modifying baseSize', () => {
|
||||
const manager = new TypographySettingsManager(
|
||||
const manager = new TypographySettingsStore(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -587,7 +587,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('maintains correct renderedSize throughout changes', () => {
|
||||
const manager = new TypographySettingsManager(
|
||||
const manager = new TypographySettingsStore(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -609,7 +609,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('handles multiple control changes in sequence', async () => {
|
||||
const manager = new TypographySettingsManager(
|
||||
const manager = new TypographySettingsStore(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -634,7 +634,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
||||
mockStorage = { fontSize: 48, fontWeight: 400, lineHeight: 1.5, letterSpacing: 0 };
|
||||
mockPersistentStore = createMockPersistentStore(mockStorage);
|
||||
|
||||
const manager = new TypographySettingsManager(
|
||||
const manager = new TypographySettingsStore(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -646,7 +646,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('handles very small multiplier', () => {
|
||||
const manager = new TypographySettingsManager(
|
||||
const manager = new TypographySettingsStore(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -659,7 +659,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('handles large base size with multiplier', () => {
|
||||
const manager = new TypographySettingsManager(
|
||||
const manager = new TypographySettingsStore(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -672,7 +672,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('handles floating point precision in multiplier', () => {
|
||||
const manager = new TypographySettingsManager(
|
||||
const manager = new TypographySettingsStore(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -691,7 +691,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('handles control methods (increase/decrease)', () => {
|
||||
const manager = new TypographySettingsManager(
|
||||
const manager = new TypographySettingsStore(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -705,7 +705,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('handles control boundary conditions', () => {
|
||||
const manager = new TypographySettingsManager(
|
||||
const manager = new TypographySettingsStore(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -30,6 +30,8 @@
|
||||
|
||||
import { createPersistentStore } from '$shared/lib';
|
||||
|
||||
export const STORAGE_KEY = 'glyphdiff:theme';
|
||||
|
||||
type Theme = 'light' | 'dark';
|
||||
type ThemeSource = 'system' | 'user';
|
||||
|
||||
@@ -56,7 +58,7 @@ class ThemeManager {
|
||||
/**
|
||||
* Persistent storage for user's theme preference
|
||||
*/
|
||||
#store = createPersistentStore<Theme | null>('glyphdiff:theme', null);
|
||||
#store = createPersistentStore<Theme | null>(STORAGE_KEY, null);
|
||||
/**
|
||||
* Bound handler for system theme change events
|
||||
*/
|
||||
|
||||
@@ -40,8 +40,7 @@ import { ThemeManager } from './ThemeManager.svelte';
|
||||
* - MediaQueryList listener management
|
||||
*/
|
||||
|
||||
// Storage key used by ThemeManager
|
||||
const STORAGE_KEY = 'glyphdiff:theme';
|
||||
import { STORAGE_KEY } from './ThemeManager.svelte';
|
||||
|
||||
// Helper type for MediaQueryList event handler
|
||||
type MediaQueryListCallback = (this: MediaQueryList, ev: MediaQueryListEvent) => void;
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
FontApplicator,
|
||||
type UnifiedFont,
|
||||
} from '$entities/Font';
|
||||
import { typographySettingsStore } from '$features/SetupFont/model';
|
||||
import { typographySettingsStore } from '$features/AdjustTypography/model';
|
||||
import {
|
||||
Badge,
|
||||
ContentEditable,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { FontsByIdsStore } from './model';
|
||||
@@ -0,0 +1 @@
|
||||
export { FontsByIdsStore } from './store/fontsByIdsStore/fontsByIdsStore.svelte';
|
||||
+8
-9
@@ -1,14 +1,14 @@
|
||||
import { fontKeys } from '$shared/api/queryKeys';
|
||||
import { BaseQueryStore } from '$shared/lib/helpers/BaseQueryStore.svelte';
|
||||
import {
|
||||
fetchFontsByIds,
|
||||
seedFontCache,
|
||||
} from '../../../api/proxy/proxyFonts';
|
||||
} from '$entities/Font/api/proxy/proxyFonts';
|
||||
import {
|
||||
FontNetworkError,
|
||||
FontResponseError,
|
||||
} from '../../../lib/errors/errors';
|
||||
import type { UnifiedFont } from '../../types';
|
||||
} from '$entities/Font/lib/errors/errors';
|
||||
import type { UnifiedFont } from '$entities/Font/model/types';
|
||||
import { fontKeys } from '$shared/api/queryKeys';
|
||||
import { BaseQueryStore } from '$shared/lib/helpers/BaseQueryStore.svelte';
|
||||
|
||||
/**
|
||||
* Internal fetcher that seeds the cache and handles error wrapping.
|
||||
@@ -35,11 +35,10 @@ async function fetchAndSeed(ids: string[]): Promise<UnifiedFont[]> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactive store for fetching and caching batches of fonts by ID.
|
||||
* Integrates with TanStack Query via BaseQueryStore and handles
|
||||
* normalized cache seeding.
|
||||
* Reactive store for fetching specific fonts by ID via the proxy batch endpoint.
|
||||
* Wraps TanStack Query and seeds the detail cache for sibling consumers.
|
||||
*/
|
||||
export class BatchFontStore extends BaseQueryStore<UnifiedFont[]> {
|
||||
export class FontsByIdsStore extends BaseQueryStore<UnifiedFont[]> {
|
||||
constructor(initialIds: string[] = []) {
|
||||
super({
|
||||
queryKey: fontKeys.batch(initialIds),
|
||||
+15
-15
@@ -1,3 +1,8 @@
|
||||
import * as api from '$entities/Font/api/proxy/proxyFonts';
|
||||
import {
|
||||
FontNetworkError,
|
||||
FontResponseError,
|
||||
} from '$entities/Font/lib/errors/errors';
|
||||
import { queryClient } from '$shared/api/queryClient';
|
||||
import { fontKeys } from '$shared/api/queryKeys';
|
||||
import {
|
||||
@@ -7,14 +12,9 @@ import {
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import * as api from '../../../api/proxy/proxyFonts';
|
||||
import {
|
||||
FontNetworkError,
|
||||
FontResponseError,
|
||||
} from '../../../lib/errors/errors';
|
||||
import { BatchFontStore } from './batchFontStore.svelte';
|
||||
import { FontsByIdsStore } from './fontsByIdsStore.svelte';
|
||||
|
||||
describe('BatchFontStore', () => {
|
||||
describe('FontsByIdsStore', () => {
|
||||
beforeEach(() => {
|
||||
queryClient.clear();
|
||||
vi.clearAllMocks();
|
||||
@@ -23,7 +23,7 @@ describe('BatchFontStore', () => {
|
||||
describe('Fetch Behavior', () => {
|
||||
it('should skip fetch when initialized with empty IDs', async () => {
|
||||
const spy = vi.spyOn(api, 'fetchFontsByIds');
|
||||
const store = new BatchFontStore([]);
|
||||
const store = new FontsByIdsStore([]);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
expect(store.fonts).toEqual([]);
|
||||
});
|
||||
@@ -31,7 +31,7 @@ describe('BatchFontStore', () => {
|
||||
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']);
|
||||
const store = new FontsByIdsStore(['a']);
|
||||
await vi.waitFor(() => expect(store.fonts).toEqual(fonts), { timeout: 1000 });
|
||||
expect(queryClient.getQueryData(fontKeys.detail('a'))).toEqual(fonts[0]);
|
||||
});
|
||||
@@ -42,7 +42,7 @@ describe('BatchFontStore', () => {
|
||||
vi.spyOn(api, 'fetchFontsByIds').mockImplementation(() =>
|
||||
new Promise(r => setTimeout(() => r([{ id: 'a' }] as any), 50))
|
||||
);
|
||||
const store = new BatchFontStore(['a']);
|
||||
const store = new FontsByIdsStore(['a']);
|
||||
expect(store.isLoading).toBe(true);
|
||||
await vi.waitFor(() => expect(store.isLoading).toBe(false), { timeout: 1000 });
|
||||
});
|
||||
@@ -51,7 +51,7 @@ describe('BatchFontStore', () => {
|
||||
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']);
|
||||
const store = new FontsByIdsStore(['a']);
|
||||
await vi.waitFor(() => expect(store.isError).toBe(true), { timeout: 1000 });
|
||||
expect(store.error).toBeInstanceOf(FontNetworkError);
|
||||
});
|
||||
@@ -59,7 +59,7 @@ describe('BatchFontStore', () => {
|
||||
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']);
|
||||
const store = new FontsByIdsStore(['a']);
|
||||
await vi.waitFor(() => expect(store.isError).toBe(true), { timeout: 1000 });
|
||||
expect(store.error).toBeInstanceOf(FontResponseError);
|
||||
});
|
||||
@@ -67,7 +67,7 @@ describe('BatchFontStore', () => {
|
||||
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']);
|
||||
const store = new FontsByIdsStore(['a']);
|
||||
await vi.waitFor(() => expect(store.fonts).toEqual(fonts), { timeout: 1000 });
|
||||
expect(store.error).toBeNull();
|
||||
});
|
||||
@@ -78,7 +78,7 @@ describe('BatchFontStore', () => {
|
||||
const fonts1 = [{ id: 'a' }] as any[];
|
||||
const spy = vi.spyOn(api, 'fetchFontsByIds').mockResolvedValueOnce(fonts1);
|
||||
|
||||
const store = new BatchFontStore(['a']);
|
||||
const store = new FontsByIdsStore(['a']);
|
||||
await vi.waitFor(() => expect(store.fonts).toEqual(fonts1), { timeout: 1000 });
|
||||
|
||||
spy.mockClear();
|
||||
@@ -97,7 +97,7 @@ describe('BatchFontStore', () => {
|
||||
.mockResolvedValueOnce(fonts1)
|
||||
.mockResolvedValueOnce(fonts2);
|
||||
|
||||
const store = new BatchFontStore(['a']);
|
||||
const store = new FontsByIdsStore(['a']);
|
||||
await vi.waitFor(() => expect(store.fonts).toEqual(fonts1), { timeout: 1000 });
|
||||
|
||||
store.setIds(['b']);
|
||||
+2
-1
@@ -8,8 +8,9 @@
|
||||
*/
|
||||
|
||||
import { api } from '$shared/api/api';
|
||||
import { API_ENDPOINTS } from '$shared/api/endpoints';
|
||||
|
||||
const PROXY_API_URL = 'https://api.glyphdiff.com/api/v1/filters' as const;
|
||||
const PROXY_API_URL = API_ENDPOINTS.filters;
|
||||
|
||||
/**
|
||||
* Filter metadata type from backend
|
||||
+1
-1
@@ -38,7 +38,7 @@ export {
|
||||
} 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.
|
||||
*/
|
||||
import './store/bindings.svelte';
|
||||
+10
-6
@@ -6,7 +6,7 @@
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { availableFilterStore } from '$features/GetFonts';
|
||||
* import { availableFilterStore } from '$features/FilterAndSortFonts';
|
||||
*
|
||||
* // Access filters (reactive)
|
||||
* $: filters = availableFilterStore.filters;
|
||||
@@ -15,9 +15,13 @@
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { fetchProxyFilters } from '$features/GetFonts/api/filters/filters';
|
||||
import type { FilterMetadata } from '$features/GetFonts/api/filters/filters';
|
||||
import { queryClient } from '$shared/api/queryClient';
|
||||
import { fetchProxyFilters } from '$features/FilterAndSortFonts/api/filters/filters';
|
||||
import type { FilterMetadata } from '$features/FilterAndSortFonts/api/filters/filters';
|
||||
import {
|
||||
DEFAULT_QUERY_GC_TIME_MS,
|
||||
DEFAULT_QUERY_STALE_TIME_MS,
|
||||
queryClient,
|
||||
} from '$shared/api/queryClient';
|
||||
import {
|
||||
type QueryKey,
|
||||
QueryObserver,
|
||||
@@ -81,8 +85,8 @@ export class AvailableFilterStore {
|
||||
return {
|
||||
queryKey: this.getQueryKey(),
|
||||
queryFn: () => this.fetchFn(),
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
gcTime: 10 * 60 * 1000, // 10 minutes
|
||||
staleTime: DEFAULT_QUERY_STALE_TIME_MS,
|
||||
gcTime: DEFAULT_QUERY_GC_TIME_MS,
|
||||
};
|
||||
}
|
||||
|
||||
+7
-7
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* 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,
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { fontStore } from '$entities/Font';
|
||||
import { fontCatalogStore } from '$entities/Font';
|
||||
import { untrack } from 'svelte';
|
||||
import { mapAppliedFiltersToParams } from '../../lib/mapper/mapAppliedFiltersToParams';
|
||||
import { appliedFilterStore } from './appliedFilterStore/appliedFilterStore.svelte';
|
||||
@@ -42,20 +42,20 @@ $effect.root(() => {
|
||||
});
|
||||
|
||||
/**
|
||||
* Mirror filter selections + debounced search query into fontStore params.
|
||||
* untrack the write so fontStore's internal $state reads don't feed back
|
||||
* Mirror filter selections + debounced search query into fontCatalogStore params.
|
||||
* untrack the write so fontCatalogStore's internal $state reads don't feed back
|
||||
* into this effect's dependency graph.
|
||||
*/
|
||||
$effect(() => {
|
||||
const params = mapAppliedFiltersToParams(appliedFilterStore);
|
||||
untrack(() => fontStore.setParams(params));
|
||||
untrack(() => fontCatalogStore.setParams(params));
|
||||
});
|
||||
|
||||
/**
|
||||
* Mirror sort selection into fontStore.
|
||||
* Mirror sort selection into fontCatalogStore.
|
||||
*/
|
||||
$effect(() => {
|
||||
const apiSort = sortStore.apiValue;
|
||||
untrack(() => fontStore.setSort(apiSort));
|
||||
untrack(() => fontCatalogStore.setSort(apiSort));
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
appliedFilterStore,
|
||||
availableFilterStore,
|
||||
} from '$features/GetFonts';
|
||||
} from '$features/FilterAndSortFonts';
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
@@ -1,6 +0,0 @@
|
||||
export {
|
||||
createTypographySettingsManager,
|
||||
type TypographySettingsManager,
|
||||
} from './lib';
|
||||
export { typographySettingsStore } from './model';
|
||||
export { TypographyMenu } from './ui';
|
||||
@@ -1,4 +0,0 @@
|
||||
export {
|
||||
createTypographySettingsManager,
|
||||
type TypographySettingsManager,
|
||||
} from './settingsManager/settingsManager.svelte';
|
||||
@@ -1 +0,0 @@
|
||||
export { typographySettingsStore } from './state/typographySettingsStore';
|
||||
@@ -1,7 +0,0 @@
|
||||
import { DEFAULT_TYPOGRAPHY_CONTROLS_DATA } from '$entities/Font';
|
||||
import { createTypographySettingsManager } from '../../lib';
|
||||
|
||||
export const typographySettingsStore = createTypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
'glyphdiff:comparison:typography',
|
||||
);
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Centralized backend endpoint definitions.
|
||||
*
|
||||
* One source of truth for the proxy API base URL — individual resource
|
||||
* modules (proxyFonts, filters) append their own paths.
|
||||
*/
|
||||
|
||||
export const API_BASE_URL = 'https://api.glyphdiff.com/api/v1' as const;
|
||||
|
||||
export const API_ENDPOINTS = {
|
||||
/**
|
||||
* Font catalog + per-id detail + batch lookup
|
||||
*/
|
||||
fonts: `${API_BASE_URL}/fonts`,
|
||||
/**
|
||||
* Filter metadata (providers, categories, subsets)
|
||||
*/
|
||||
filters: `${API_BASE_URL}/filters`,
|
||||
} as const;
|
||||
@@ -1,5 +1,31 @@
|
||||
import { QueryClient } from '@tanstack/query-core';
|
||||
|
||||
/**
|
||||
* Data remains fresh for this long after fetch. Stores that override
|
||||
* staleness (e.g. filtered queries) can use 0 to bypass.
|
||||
*/
|
||||
export const DEFAULT_QUERY_STALE_TIME_MS = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Unused cache entries are garbage collected after this long.
|
||||
*/
|
||||
export const DEFAULT_QUERY_GC_TIME_MS = 10 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* How many times a failed query is retried before surfacing the error.
|
||||
*/
|
||||
export const QUERY_RETRY_COUNT = 3;
|
||||
|
||||
/**
|
||||
* Base delay for exponential retry backoff.
|
||||
*/
|
||||
export const QUERY_RETRY_BASE_DELAY_MS = 1000;
|
||||
|
||||
/**
|
||||
* Upper bound on retry delay regardless of attempt index.
|
||||
*/
|
||||
export const QUERY_RETRY_MAX_DELAY_MS = 30000;
|
||||
|
||||
/**
|
||||
* TanStack Query client instance
|
||||
*
|
||||
@@ -15,14 +41,8 @@ import { QueryClient } from '@tanstack/query-core';
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
/**
|
||||
* Data remains fresh for 5 minutes after fetch
|
||||
*/
|
||||
staleTime: 5 * 60 * 1000,
|
||||
/**
|
||||
* Unused cache entries are removed after 10 minutes
|
||||
*/
|
||||
gcTime: 10 * 60 * 1000,
|
||||
staleTime: DEFAULT_QUERY_STALE_TIME_MS,
|
||||
gcTime: DEFAULT_QUERY_GC_TIME_MS,
|
||||
/**
|
||||
* Don't refetch when window regains focus
|
||||
*/
|
||||
@@ -31,15 +51,12 @@ export const queryClient = new QueryClient({
|
||||
* Refetch on mount if data is stale
|
||||
*/
|
||||
refetchOnMount: true,
|
||||
retry: QUERY_RETRY_COUNT,
|
||||
/**
|
||||
* Retry failed requests up to 3 times
|
||||
* Exponential backoff: 1s, 2s, 4s, 8s... capped at 30s
|
||||
*/
|
||||
retry: 3,
|
||||
/**
|
||||
* Exponential backoff for retries
|
||||
* 1s, 2s, 4s, 8s... capped at 30s
|
||||
*/
|
||||
retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000),
|
||||
retryDelay: attemptIndex =>
|
||||
Math.min(QUERY_RETRY_BASE_DELAY_MS * 2 ** attemptIndex, QUERY_RETRY_MAX_DELAY_MS),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
+17
-2
@@ -4,6 +4,20 @@ import {
|
||||
prepareWithSegments,
|
||||
} from '@chenglou/pretext';
|
||||
|
||||
/**
|
||||
* Width of the character morph "halo" around the slider thumb, in percent
|
||||
* of container width. Characters within this window get partial blending
|
||||
* instead of a hard A→B flip.
|
||||
*/
|
||||
const CHAR_PROXIMITY_RANGE_PCT = 5;
|
||||
|
||||
/**
|
||||
* Default render size in px when callers omit the `size` arg on `layout()`.
|
||||
* Kept as a local constant to avoid pulling `$entities/Font` into
|
||||
* `$shared/lib` (would create an FSD-illegal upward import cycle).
|
||||
*/
|
||||
const DEFAULT_RENDER_SIZE_PX = 16;
|
||||
|
||||
/**
|
||||
* A single laid-out line produced by dual-font comparison layout.
|
||||
*
|
||||
@@ -129,7 +143,7 @@ export class CharacterComparisonEngine {
|
||||
width: number,
|
||||
lineHeight: number,
|
||||
spacing: number = 0,
|
||||
size: number = 16,
|
||||
size: number = DEFAULT_RENDER_SIZE_PX,
|
||||
): ComparisonResult {
|
||||
if (!text) {
|
||||
return { lines: [], totalHeight: 0 };
|
||||
@@ -260,7 +274,7 @@ export class CharacterComparisonEngine {
|
||||
const chars = line.chars;
|
||||
const n = chars.length;
|
||||
const sliderX = (sliderPos / 100) * containerWidth;
|
||||
const range = 5;
|
||||
const range = CHAR_PROXIMITY_RANGE_PCT;
|
||||
// Prefix sums of widthA (left chars will be past → use widthA).
|
||||
// Suffix sums of widthB (right chars will not be past → use widthB).
|
||||
// This lets us compute, for each char i, what the total line width and
|
||||
@@ -291,6 +305,7 @@ export class CharacterComparisonEngine {
|
||||
const totalRendered = chars.reduce((s, c, i) => s + (isPastArr[i] ? c.widthA : c.widthB), 0);
|
||||
const xOffset = (containerWidth - totalRendered) / 2;
|
||||
let currentX = xOffset;
|
||||
|
||||
return chars.map((char, i) => {
|
||||
const isPast = isPastArr[i] === 1;
|
||||
const charWidth = isPast ? char.widthA : char.widthB;
|
||||
|
||||
@@ -70,6 +70,14 @@ export interface LayoutResult {
|
||||
* **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.
|
||||
*
|
||||
* @deprecated No live consumers remain — the only previous caller
|
||||
* (`createFontRowSizeResolver`) now invokes pretext's `prepare` + `layout`
|
||||
* directly (per pretext's "hot-path resize function" guidance). If you need
|
||||
* single-font height-only measurement, use `prepare` + `layout` from
|
||||
* `@chenglou/pretext` directly. If you need per-grapheme x/width data, see
|
||||
* `CharacterComparisonEngine` (dual-font) or revive a slimmer wrapper.
|
||||
* Slated for removal once it has been absent from `main` for a release cycle.
|
||||
*/
|
||||
export class TextLayoutEngine {
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { debounce } from '$shared/lib/utils';
|
||||
|
||||
/**
|
||||
* Default debounce delay used when no wait is provided. Picked to feel
|
||||
* snappy for typing while still coalescing API-bound side effects.
|
||||
*/
|
||||
export const DEFAULT_DEBOUNCE_MS = 300;
|
||||
|
||||
/**
|
||||
* Creates reactive state with immediate and debounced values.
|
||||
*
|
||||
@@ -23,7 +29,7 @@ import { debounce } from '$shared/lib/utils';
|
||||
* <p>Searching: {search.debounced}</p>
|
||||
* ```
|
||||
*/
|
||||
export function createDebouncedState<T>(initialValue: T, wait: number = 300) {
|
||||
export function createDebouncedState<T>(initialValue: T, wait: number = DEFAULT_DEBOUNCE_MS) {
|
||||
let immediate = $state(initialValue);
|
||||
let debounced = $state(initialValue);
|
||||
|
||||
|
||||
@@ -28,6 +28,19 @@
|
||||
|
||||
import { Spring } from 'svelte/motion';
|
||||
|
||||
/**
|
||||
* Spring tuning for the perspective animation. Lower stiffness = slower
|
||||
* easing into back/front state; higher damping = less overshoot.
|
||||
*/
|
||||
const PERSPECTIVE_SPRING_CONFIG = { stiffness: 0.2, damping: 0.8 } as const;
|
||||
|
||||
/**
|
||||
* Halfway threshold on the 0→1 spring value. Above flips `isBack`,
|
||||
* below flips `isFront`. Picking 0.5 means both states flip at the
|
||||
* exact midpoint of the animation.
|
||||
*/
|
||||
const PERSPECTIVE_TOGGLE_THRESHOLD = 0.5;
|
||||
|
||||
/**
|
||||
* Configuration options for perspective effects
|
||||
*/
|
||||
@@ -93,10 +106,7 @@ export class PerspectiveManager {
|
||||
* Spring animation state
|
||||
* Animates between 0 (front) and 1 (back) with configurable physics
|
||||
*/
|
||||
spring = new Spring(0, {
|
||||
stiffness: 0.2,
|
||||
damping: 0.8,
|
||||
});
|
||||
spring = new Spring(0, PERSPECTIVE_SPRING_CONFIG);
|
||||
|
||||
/**
|
||||
* Reactive state: true when in back position
|
||||
@@ -104,7 +114,7 @@ export class PerspectiveManager {
|
||||
* Content should appear blurred, scaled down, and less interactive
|
||||
* when this is true. Derived from spring value > 0.5.
|
||||
*/
|
||||
isBack = $derived(this.spring.current > 0.5);
|
||||
isBack = $derived(this.spring.current > PERSPECTIVE_TOGGLE_THRESHOLD);
|
||||
|
||||
/**
|
||||
* Reactive state: true when in front position
|
||||
@@ -112,7 +122,7 @@ export class PerspectiveManager {
|
||||
* Content should be fully visible, sharp, and interactive
|
||||
* when this is true. Derived from spring value < 0.5.
|
||||
*/
|
||||
isFront = $derived(this.spring.current < 0.5);
|
||||
isFront = $derived(this.spring.current < PERSPECTIVE_TOGGLE_THRESHOLD);
|
||||
|
||||
/**
|
||||
* Internal configuration with defaults applied
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
* Used to render visible items with absolute positioning based on computed offsets.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Minimum height delta (in px) required to commit a re-measured row height.
|
||||
* Sub-pixel diffs are treated as measurement noise to avoid spurious re-flows.
|
||||
*/
|
||||
const MEASUREMENT_EPSILON_PX = 0.5;
|
||||
|
||||
export interface VirtualItem {
|
||||
/**
|
||||
* Index of the item in the data array
|
||||
@@ -58,7 +64,7 @@ export interface VirtualizerOptions {
|
||||
* 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
|
||||
* `fontLifecycleManager.statuses` so the virtualizer recalculates heights
|
||||
* as fonts finish loading, eliminating the DOM-measurement snap on load.
|
||||
*/
|
||||
estimateSize: (index: number) => number;
|
||||
@@ -381,8 +387,8 @@ export function createVirtualizer<T>(
|
||||
if (!isNaN(index)) {
|
||||
const oldHeight = measuredSizes[index];
|
||||
|
||||
// Only update if the height difference is significant (> 0.5px)
|
||||
if (oldHeight === undefined || Math.abs(oldHeight - height) > 0.5) {
|
||||
// Only update if the height difference is significant
|
||||
if (oldHeight === undefined || Math.abs(oldHeight - height) > MEASUREMENT_EPSILON_PX) {
|
||||
measurementBuffer[index] = height;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,17 +167,33 @@ $effect(() => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Throttle for visible-items change callbacks. Lower = more responsive
|
||||
* downstream UI; higher = fewer recomputes during scroll.
|
||||
*/
|
||||
const VISIBLE_CHANGE_THROTTLE_MS = 150;
|
||||
|
||||
/**
|
||||
* Throttle for near-bottom callbacks (typically used to prefetch next page).
|
||||
*/
|
||||
const NEAR_BOTTOM_THROTTLE_MS = 200;
|
||||
|
||||
/**
|
||||
* Throttle for jump callbacks (programmatic scroll-to-index).
|
||||
*/
|
||||
const JUMP_THROTTLE_MS = 200;
|
||||
|
||||
const throttledVisibleChange = throttle((visibleItems: T[]) => {
|
||||
onVisibleItemsChange?.(visibleItems);
|
||||
}, 150); // 150ms throttle
|
||||
}, VISIBLE_CHANGE_THROTTLE_MS);
|
||||
|
||||
const throttledNearBottom = throttle((lastVisibleIndex: number) => {
|
||||
onNearBottom?.(lastVisibleIndex);
|
||||
}, 200); // 200ms throttle
|
||||
}, NEAR_BOTTOM_THROTTLE_MS);
|
||||
|
||||
const throttledOnJump = throttle((targetIndex: number) => {
|
||||
onJump?.(targetIndex);
|
||||
}, 200);
|
||||
}, JUMP_THROTTLE_MS);
|
||||
|
||||
// Calculate top/bottom padding for spacer elements
|
||||
// In CSS Grid, gap creates space BETWEEN elements.
|
||||
|
||||
@@ -7,21 +7,21 @@
|
||||
*
|
||||
* Features:
|
||||
* - Persistent font selection (survives page refresh)
|
||||
* - Font loading state tracking via BatchFontStore + TanStack Query
|
||||
* - Font loading state tracking via FontsByIdsStore + TanStack Query
|
||||
* - Sample text management
|
||||
* - Typography controls (size, weight, line height, spacing)
|
||||
* - Slider position for character-by-character morphing
|
||||
*/
|
||||
|
||||
import {
|
||||
BatchFontStore,
|
||||
type FontLoadRequestConfig,
|
||||
type UnifiedFont,
|
||||
appliedFontsManager,
|
||||
fontStore,
|
||||
fontCatalogStore,
|
||||
fontLifecycleManager,
|
||||
getFontUrl,
|
||||
} from '$entities/Font';
|
||||
import { typographySettingsStore } from '$features/SetupFont/model';
|
||||
import { typographySettingsStore } from '$features/AdjustTypography/model';
|
||||
import { FontsByIdsStore } from '$features/FetchFontsByIds';
|
||||
import { createPersistentStore } from '$shared/lib';
|
||||
import { untrack } from 'svelte';
|
||||
import { getPretextFontString } from '../../lib';
|
||||
@@ -42,8 +42,17 @@ interface ComparisonState {
|
||||
|
||||
export type Side = 'A' | 'B';
|
||||
|
||||
const STORAGE_KEY = 'glyphdiff:comparison';
|
||||
|
||||
/**
|
||||
* Max time the UI waits after a font-load failure before unblocking
|
||||
* (#fontsReady = true). Acts as a safety net so a transient load error
|
||||
* can't strand the comparison view in a permanent loading state.
|
||||
*/
|
||||
const FONT_READY_FALLBACK_MS = 1000;
|
||||
|
||||
// Persistent storage for selected comparison fonts
|
||||
const storage = createPersistentStore<ComparisonState>('glyphdiff:comparison', {
|
||||
const storage = createPersistentStore<ComparisonState>(STORAGE_KEY, {
|
||||
fontAId: null,
|
||||
fontBId: null,
|
||||
});
|
||||
@@ -51,7 +60,7 @@ const storage = createPersistentStore<ComparisonState>('glyphdiff:comparison', {
|
||||
/**
|
||||
* Store for managing font comparison state.
|
||||
*
|
||||
* Uses BatchFontStore (TanStack Query) to fetch fonts by ID, replacing
|
||||
* Uses FontsByIdsStore (TanStack Query) to fetch fonts by ID, replacing
|
||||
* the previous hand-rolled async fetch approach. Three reactive effects
|
||||
* handle: (1) syncing batch results into fontA/fontB, (2) triggering the
|
||||
* CSS Font Loading API, and (3) falling back to default fonts when
|
||||
@@ -85,17 +94,17 @@ export class ComparisonStore {
|
||||
/**
|
||||
* TanStack Query-backed store for efficient batch font retrieval
|
||||
*/
|
||||
#batchStore: BatchFontStore;
|
||||
#fontsByIdsStore: FontsByIdsStore;
|
||||
|
||||
constructor() {
|
||||
// Synchronously seed the batch store with any IDs already in storage
|
||||
const { fontAId, fontBId } = storage.value;
|
||||
this.#batchStore = new BatchFontStore(fontAId && fontBId ? [fontAId, fontBId] : []);
|
||||
this.#fontsByIdsStore = new FontsByIdsStore(fontAId && fontBId ? [fontAId, fontBId] : []);
|
||||
|
||||
$effect.root(() => {
|
||||
// Effect 1: Sync batch results → fontA / fontB
|
||||
$effect(() => {
|
||||
const fonts = this.#batchStore.fonts;
|
||||
const fonts = this.#fontsByIdsStore.fonts;
|
||||
if (fonts.length === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -140,7 +149,7 @@ export class ComparisonStore {
|
||||
});
|
||||
|
||||
if (configs.length > 0) {
|
||||
appliedFontsManager.touch(configs);
|
||||
fontLifecycleManager.touch(configs);
|
||||
this.#checkFontsLoaded();
|
||||
}
|
||||
});
|
||||
@@ -151,13 +160,13 @@ export class ComparisonStore {
|
||||
return;
|
||||
}
|
||||
|
||||
const fonts = fontStore.fonts;
|
||||
const fonts = fontCatalogStore.fonts;
|
||||
if (fonts.length >= 2) {
|
||||
untrack(() => {
|
||||
const id1 = fonts[0].id;
|
||||
const id2 = fonts[fonts.length - 1].id;
|
||||
storage.value = { fontAId: id1, fontBId: id2 };
|
||||
this.#batchStore.setIds([id1, id2]);
|
||||
this.#fontsByIdsStore.setIds([id1, id2]);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -168,17 +177,17 @@ export class ComparisonStore {
|
||||
const fb = this.#fontB;
|
||||
const w = typographySettingsStore.weight;
|
||||
if (fa) {
|
||||
appliedFontsManager.pin(fa.id, w, fa.features?.isVariable);
|
||||
fontLifecycleManager.pin(fa.id, w, fa.features?.isVariable);
|
||||
}
|
||||
if (fb) {
|
||||
appliedFontsManager.pin(fb.id, w, fb.features?.isVariable);
|
||||
fontLifecycleManager.pin(fb.id, w, fb.features?.isVariable);
|
||||
}
|
||||
return () => {
|
||||
if (fa) {
|
||||
appliedFontsManager.unpin(fa.id, w, fa.features?.isVariable);
|
||||
fontLifecycleManager.unpin(fa.id, w, fa.features?.isVariable);
|
||||
}
|
||||
if (fb) {
|
||||
appliedFontsManager.unpin(fb.id, w, fb.features?.isVariable);
|
||||
fontLifecycleManager.unpin(fb.id, w, fb.features?.isVariable);
|
||||
}
|
||||
};
|
||||
});
|
||||
@@ -234,7 +243,7 @@ export class ComparisonStore {
|
||||
this.#fontsReady = true;
|
||||
} catch (error) {
|
||||
console.warn('[ComparisonStore] Font loading failed:', error);
|
||||
setTimeout(() => (this.#fontsReady = true), 1000);
|
||||
setTimeout(() => (this.#fontsReady = true), FONT_READY_FALLBACK_MS);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,7 +325,7 @@ export class ComparisonStore {
|
||||
* True if any font is currently being fetched or loaded (reactive)
|
||||
*/
|
||||
get isLoading() {
|
||||
return this.#batchStore.isLoading || !this.#fontsReady;
|
||||
return this.#fontsByIdsStore.isLoading || !this.#fontsReady;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -325,7 +334,7 @@ export class ComparisonStore {
|
||||
resetAll() {
|
||||
this.#fontA = undefined;
|
||||
this.#fontB = undefined;
|
||||
this.#batchStore.setIds([]);
|
||||
this.#fontsByIdsStore.setIds([]);
|
||||
storage.clear();
|
||||
typographySettingsStore.reset();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Unit tests for ComparisonStore (TanStack Query refactor)
|
||||
*
|
||||
* Uses the real BatchFontStore so Svelte $state reactivity works correctly.
|
||||
* Uses the real FontsByIdsStore so Svelte $state reactivity works correctly.
|
||||
* Controls network behaviour via vi.spyOn on the proxyFonts API layer.
|
||||
*/
|
||||
|
||||
@@ -53,14 +53,10 @@ vi.mock('$shared/lib/helpers/createPersistentStore/createPersistentStore.svelte'
|
||||
|
||||
vi.mock('$entities/Font', async importOriginal => {
|
||||
const actual = await importOriginal<typeof import('$entities/Font')>();
|
||||
const { BatchFontStore } = await import(
|
||||
'$entities/Font/model/store/batchFontStore/batchFontStore.svelte'
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
BatchFontStore,
|
||||
fontStore: { fonts: [] },
|
||||
appliedFontsManager: {
|
||||
fontCatalogStore: { fonts: [] },
|
||||
fontLifecycleManager: {
|
||||
touch: vi.fn(),
|
||||
pin: vi.fn(),
|
||||
unpin: vi.fn(),
|
||||
@@ -71,7 +67,7 @@ vi.mock('$entities/Font', async importOriginal => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('$features/SetupFont', () => ({
|
||||
vi.mock('$features/AdjustTypography', () => ({
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA: [],
|
||||
createTypographyControlManager: vi.fn(() => ({
|
||||
weight: 400,
|
||||
@@ -80,7 +76,7 @@ vi.mock('$features/SetupFont', () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('$features/SetupFont/model', () => ({
|
||||
vi.mock('$features/AdjustTypography/model', () => ({
|
||||
typographySettingsStore: {
|
||||
weight: 400,
|
||||
renderedSize: 48,
|
||||
@@ -89,8 +85,8 @@ vi.mock('$features/SetupFont/model', () => ({
|
||||
}));
|
||||
|
||||
import {
|
||||
appliedFontsManager,
|
||||
fontStore,
|
||||
fontCatalogStore,
|
||||
fontLifecycleManager,
|
||||
} from '$entities/Font';
|
||||
import * as proxyFonts from '$entities/Font/api/proxy/proxyFonts';
|
||||
import { ComparisonStore } from './comparisonStore.svelte';
|
||||
@@ -104,7 +100,7 @@ describe('ComparisonStore', () => {
|
||||
vi.clearAllMocks();
|
||||
mockStorage._value = { fontAId: null, fontBId: null };
|
||||
mockStorage._clear.mockClear();
|
||||
(fontStore as any).fonts = [];
|
||||
(fontCatalogStore as any).fonts = [];
|
||||
|
||||
// Default: fetchFontsByIds returns empty so tests that don't care don't hang
|
||||
vi.spyOn(proxyFonts, 'fetchFontsByIds').mockResolvedValue([]);
|
||||
@@ -129,7 +125,7 @@ describe('ComparisonStore', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Restoration from Storage (via BatchFontStore)', () => {
|
||||
describe('Restoration from Storage (via FontsByIdsStore)', () => {
|
||||
it('should restore fontA and fontB from stored IDs', async () => {
|
||||
mockStorage._value.fontAId = mockFontA.id;
|
||||
mockStorage._value.fontBId = mockFontB.id;
|
||||
@@ -159,7 +155,7 @@ describe('ComparisonStore', () => {
|
||||
|
||||
describe('Default Fallbacks', () => {
|
||||
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]);
|
||||
|
||||
new ComparisonStore();
|
||||
@@ -216,12 +212,12 @@ describe('ComparisonStore', () => {
|
||||
new ComparisonStore();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(appliedFontsManager.pin).toHaveBeenCalledWith(
|
||||
expect(fontLifecycleManager.pin).toHaveBeenCalledWith(
|
||||
mockFontA.id,
|
||||
400,
|
||||
mockFontA.features?.isVariable,
|
||||
);
|
||||
expect(appliedFontsManager.pin).toHaveBeenCalledWith(
|
||||
expect(fontLifecycleManager.pin).toHaveBeenCalledWith(
|
||||
mockFontB.id,
|
||||
400,
|
||||
mockFontB.features?.isVariable,
|
||||
@@ -242,12 +238,12 @@ describe('ComparisonStore', () => {
|
||||
store.fontA = mockFontC;
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(appliedFontsManager.unpin).toHaveBeenCalledWith(
|
||||
expect(fontLifecycleManager.unpin).toHaveBeenCalledWith(
|
||||
mockFontA.id,
|
||||
400,
|
||||
mockFontA.features?.isVariable,
|
||||
);
|
||||
expect(appliedFontsManager.pin).toHaveBeenCalledWith(
|
||||
expect(fontLifecycleManager.pin).toHaveBeenCalledWith(
|
||||
mockFontC.id,
|
||||
400,
|
||||
mockFontC.features?.isVariable,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
Renders a single character with morphing animation
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { typographySettingsStore } from '$features/SetupFont';
|
||||
import { typographySettingsStore } from '$features/AdjustTypography';
|
||||
import { cn } from '$shared/lib';
|
||||
import { comparisonStore } from '../../model';
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@ import {
|
||||
FontVirtualList,
|
||||
type UnifiedFont,
|
||||
VIRTUAL_INDEX_NOT_LOADED,
|
||||
appliedFontsManager,
|
||||
fontStore,
|
||||
fontCatalogStore,
|
||||
fontLifecycleManager,
|
||||
} from '$entities/Font';
|
||||
import { getSkeletonWidth } from '$shared/lib/utils';
|
||||
import {
|
||||
@@ -36,7 +36,7 @@ function getVirtualIndex(fontId: string | undefined): number {
|
||||
if (!fontId) {
|
||||
return -1;
|
||||
}
|
||||
const idx = fontStore.fonts.findIndex(f => f.id === fontId);
|
||||
const idx = fontCatalogStore.fonts.findIndex(f => f.id === fontId);
|
||||
if (idx === -1) {
|
||||
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.
|
||||
* 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.
|
||||
*/
|
||||
function isFontReady(font: UnifiedFont): boolean {
|
||||
const status = appliedFontsManager.getFontStatus(
|
||||
const status = fontLifecycleManager.getFontStatus(
|
||||
font.id,
|
||||
DEFAULT_FONT_WEIGHT,
|
||||
font.features?.isVariable,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
Renders a line of text in the SliderArea
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { typographySettingsStore } from '$features/SetupFont';
|
||||
import { typographySettingsStore } from '$features/AdjustTypography';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface LineChar {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<!--
|
||||
Component: Search
|
||||
Typeface search input for the comparison view.
|
||||
Writes through appliedFilterStore; the global bridge in $features/GetFonts
|
||||
propagates the value into fontStore.
|
||||
Writes through appliedFilterStore; the global bridge in $features/FilterAndSortFonts
|
||||
propagates the value into fontCatalogStore.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { appliedFilterStore } from '$features/GetFonts';
|
||||
import { appliedFilterStore } from '$features/FilterAndSortFonts';
|
||||
import { SearchBar } from '$shared/ui';
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { appliedFilterStore } from '$features/GetFonts';
|
||||
import { appliedFilterStore } from '$features/FilterAndSortFonts';
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
|
||||
@@ -8,8 +8,13 @@
|
||||
- Performance optimized using offscreen canvas for measurements and transform-based animations.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { TypographyMenu } from '$features/SetupFont';
|
||||
import { typographySettingsStore } from '$features/SetupFont/model';
|
||||
import {
|
||||
MULTIPLIER_L,
|
||||
MULTIPLIER_M,
|
||||
MULTIPLIER_S,
|
||||
} from '$entities/Font';
|
||||
import { TypographyMenu } from '$features/AdjustTypography';
|
||||
import { typographySettingsStore } from '$features/AdjustTypography/model';
|
||||
import {
|
||||
type ResponsiveManager,
|
||||
debounce,
|
||||
@@ -45,6 +50,26 @@ interface Props {
|
||||
|
||||
let { isSidebarOpen = false, class: className }: Props = $props();
|
||||
|
||||
/**
|
||||
* Spring tuning for the comparison slider thumb. Lower stiffness = slower
|
||||
* follow; higher damping = less overshoot.
|
||||
*/
|
||||
const SLIDER_SPRING_CONFIG = { stiffness: 0.2, damping: 0.7 } as const;
|
||||
|
||||
/**
|
||||
* Debounce wait before persisting the slider position to the store.
|
||||
* High frequency during drag → batched writes.
|
||||
*/
|
||||
const SLIDER_PERSIST_DEBOUNCE_MS = 100;
|
||||
|
||||
/**
|
||||
* Horizontal layout padding subtracted from container width before laying
|
||||
* out the comparison text. Different per breakpoint to match the gutters
|
||||
* around the slider track.
|
||||
*/
|
||||
const SLIDER_PADDING_MOBILE_PX = 48;
|
||||
const SLIDER_PADDING_DESKTOP_PX = 96;
|
||||
|
||||
const fontA = $derived(comparisonStore.fontA);
|
||||
const fontB = $derived(comparisonStore.fontB);
|
||||
const isLoading = $derived(comparisonStore.isLoading || !comparisonStore.isReady);
|
||||
@@ -84,10 +109,7 @@ $effect(() => {
|
||||
return () => observer.disconnect();
|
||||
});
|
||||
|
||||
const sliderSpring = new Spring(50, {
|
||||
stiffness: 0.2,
|
||||
damping: 0.7,
|
||||
});
|
||||
const sliderSpring = new Spring(50, SLIDER_SPRING_CONFIG);
|
||||
const sliderPos = $derived(sliderSpring.current);
|
||||
|
||||
function handleMove(e: PointerEvent) {
|
||||
@@ -110,7 +132,7 @@ function startDragging(e: PointerEvent) {
|
||||
|
||||
const storeSliderPosition = debounce((value: number) => {
|
||||
comparisonStore.sliderPosition = value;
|
||||
}, 100);
|
||||
}, SLIDER_PERSIST_DEBOUNCE_MS);
|
||||
|
||||
$effect(() => {
|
||||
storeSliderPosition(sliderPos);
|
||||
@@ -122,16 +144,16 @@ $effect(() => {
|
||||
}
|
||||
switch (true) {
|
||||
case responsive.isMobile:
|
||||
typography.multiplier = 0.5;
|
||||
typography.multiplier = MULTIPLIER_S;
|
||||
break;
|
||||
case responsive.isTablet:
|
||||
typography.multiplier = 0.75;
|
||||
typography.multiplier = MULTIPLIER_M;
|
||||
break;
|
||||
case responsive.isDesktop:
|
||||
typography.multiplier = 1;
|
||||
typography.multiplier = MULTIPLIER_L;
|
||||
break;
|
||||
default:
|
||||
typography.multiplier = 1;
|
||||
typography.multiplier = MULTIPLIER_L;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -167,7 +189,7 @@ $effect(() => {
|
||||
const fontAStr = getPretextFontString(_weight, _size, fontA.name);
|
||||
const fontBStr = getPretextFontString(_weight, _size, fontB.name);
|
||||
|
||||
const padding = _isMobile ? 48 : 96;
|
||||
const padding = _isMobile ? SLIDER_PADDING_MOBILE_PX : SLIDER_PADDING_DESKTOP_PX;
|
||||
const availableWidth = Math.max(0, _width - padding);
|
||||
const lineHeight = _size * _height;
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
FilterControls,
|
||||
Filters,
|
||||
appliedFilterStore,
|
||||
} from '$features/GetFonts';
|
||||
} from '$features/FilterAndSortFonts';
|
||||
import { springySlideFade } from '$shared/lib';
|
||||
import {
|
||||
Button,
|
||||
|
||||
@@ -28,7 +28,7 @@ interface LayoutConfig {
|
||||
mode: LayoutMode;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'glyphdiff:sample-list-layout';
|
||||
export const STORAGE_KEY = 'glyphdiff:sample-list-layout';
|
||||
const SM_GAP_PX = 16;
|
||||
const MD_GAP_PX = 24;
|
||||
|
||||
|
||||
@@ -16,8 +16,7 @@ async function flushEffects() {
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
// Storage key used by LayoutManager
|
||||
const STORAGE_KEY = 'glyphdiff:sample-list-layout';
|
||||
import { STORAGE_KEY } from './layoutStore.svelte';
|
||||
|
||||
describe('layoutStore', () => {
|
||||
// Default viewport for most tests (desktop large - >= 1536px)
|
||||
|
||||
@@ -7,15 +7,15 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
FontVirtualList,
|
||||
appliedFontsManager,
|
||||
createFontRowSizeResolver,
|
||||
fontStore,
|
||||
fontCatalogStore,
|
||||
fontLifecycleManager,
|
||||
} from '$entities/Font';
|
||||
import { FontSampler } from '$features/DisplayFont';
|
||||
import {
|
||||
TypographyMenu,
|
||||
typographySettingsStore,
|
||||
} from '$features/SetupFont';
|
||||
} from '$features/AdjustTypography';
|
||||
import { FontSampler } from '$features/DisplayFont';
|
||||
import { throttle } from '$shared/lib/utils';
|
||||
import { Skeleton } from '$shared/ui';
|
||||
import { layoutManager } from '../../model';
|
||||
@@ -36,6 +36,12 @@ const SAMPLER_CONTENT_PADDING_X = 32;
|
||||
// Matches the previous hardcoded itemHeight={220} value to avoid regressions.
|
||||
const SAMPLER_FALLBACK_HEIGHT = 220;
|
||||
|
||||
/**
|
||||
* Throttle for the `checkPosition` scroll observer. Trades responsiveness
|
||||
* of the perspective tilt against scroll-handler cost.
|
||||
*/
|
||||
const CHECK_POSITION_THROTTLE_MS = 100;
|
||||
|
||||
let text = $state('The quick brown fox jumps over the lazy dog...');
|
||||
let wrapper = $state<HTMLDivElement | null>(null);
|
||||
// Binds to the actual window height
|
||||
@@ -54,20 +60,20 @@ const checkPosition = throttle(() => {
|
||||
const viewportMiddle = innerHeight / 2;
|
||||
|
||||
isAboveMiddle = rect.top < viewportMiddle;
|
||||
}, 100);
|
||||
}, CHECK_POSITION_THROTTLE_MS);
|
||||
|
||||
// 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.
|
||||
const fontRowHeight = $derived.by(() =>
|
||||
createFontRowSizeResolver({
|
||||
getFonts: () => fontStore.fonts,
|
||||
getFonts: () => fontCatalogStore.fonts,
|
||||
getWeight: () => typographySettingsStore.weight,
|
||||
getPreviewText: () => text,
|
||||
getContainerWidth: () => containerWidth,
|
||||
getFontSizePx: () => typographySettingsStore.renderedSize,
|
||||
getLineHeightPx: () => typographySettingsStore.height * typographySettingsStore.renderedSize,
|
||||
getStatus: key => appliedFontsManager.statuses.get(key),
|
||||
getStatus: key => fontLifecycleManager.statuses.get(key),
|
||||
contentHorizontalPadding: SAMPLER_CONTENT_PADDING_X,
|
||||
chromeHeight: SAMPLER_CHROME_HEIGHT,
|
||||
fallbackHeight: SAMPLER_FALLBACK_HEIGHT,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { NavigationWrapper } from '$entities/Breadcrumb';
|
||||
import { fontStore } from '$entities/Font';
|
||||
import { fontCatalogStore } from '$entities/Font';
|
||||
import type { ResponsiveManager } from '$shared/lib';
|
||||
import { cn } from '$shared/lib';
|
||||
import {
|
||||
@@ -36,7 +36,7 @@ const responsive = getContext<ResponsiveManager>('responsive');
|
||||
id="sample_set"
|
||||
title="Sample Set"
|
||||
headerTitle="visual_output"
|
||||
headerSubtitle="items_total: {fontStore.pagination.total ?? 0}"
|
||||
headerSubtitle="items_total: {fontCatalogStore.pagination.total ?? 0}"
|
||||
headerAction={registerAction}
|
||||
>
|
||||
{#snippet headerContent()}
|
||||
|
||||
Reference in New Issue
Block a user