Compare commits

...

10 Commits

Author SHA1 Message Date
Ilia Mashkov 06b6274e66 refactor: extract magic constants — wave 5 (single-site thresholds)
Long-tail cleanup: threshold and default-value literals in shared
helpers get named module-level constants.

- CharacterComparisonEngine: CHAR_PROXIMITY_RANGE_PCT (5),
  DEFAULT_RENDER_SIZE_PX (16) — kept local instead of importing
  DEFAULT_FONT_SIZE from \$entities/Font because \$shared/lib cannot
  legally upward-import from \$entities per FSD (also avoids an init
  cycle through the mocks barrel).
- typographySettingsStore: BASE_SIZE_EPSILON (0.01) — rounding-jitter
  guard for baseSize reconciliation.
- createDebouncedState: DEFAULT_DEBOUNCE_MS (300) — exported so callers
  can mirror the default.
- createVirtualizer: MEASUREMENT_EPSILON_PX (0.5) — minimum height
  delta before committing a re-measured row.
- createPerspectiveManager: PERSPECTIVE_TOGGLE_THRESHOLD (0.5) — the
  halfway point on the 0-1 spring that flips isBack/isFront.

Skipped #19 (PerspectivePlan defaults) per review — marginal gain.
2026-05-24 22:07:44 +03:00
Ilia Mashkov 0c59262a59 refactor: extract magic constants — wave 4 (UX timings + physics)
Name throttle/debounce intervals, spring presets, and layout paddings
that were inline numeric literals:

- VirtualList: VISIBLE_CHANGE_THROTTLE_MS (150), NEAR_BOTTOM_THROTTLE_MS
  (200), JUMP_THROTTLE_MS (200)
- SampleList: CHECK_POSITION_THROTTLE_MS (100)
- SliderArea: SLIDER_SPRING_CONFIG ({stiffness: 0.2, damping: 0.7}),
  SLIDER_PERSIST_DEBOUNCE_MS (100), SLIDER_PADDING_MOBILE_PX (48),
  SLIDER_PADDING_DESKTOP_PX (96)
- FontVirtualList: TOUCH_DEBOUNCE_MS (150)
- createPerspectiveManager: PERSPECTIVE_SPRING_CONFIG ({stiffness: 0.2,
  damping: 0.8})

No behavior changes — values preserved exactly.
2026-05-24 21:13:46 +03:00
Ilia Mashkov 2bb43797f0 refactor: extract magic constants — wave 3 (font lifecycle)
Promote font-loading scheduling and lifecycle tunables to named
module-level constants:

- comparisonStore: FONT_READY_FALLBACK_MS (1000ms) — UI unblock safety net
- fontLifecycleManager:
  - PURGE_INTERVAL_MS (60000) — periodic eviction sweep
  - IDLE_CALLBACK_TIMEOUT_MS (150) — requestIdleCallback timeout
  - SCHEDULE_FALLBACK_MS (16) — setTimeout fallback (~60fps)
  - YIELD_INTERVAL_MS (8) — parse-loop yield budget for non-Chromium
  - CRITICAL_FONT_WEIGHTS ([400, 700]) — data-saver allowlist
- FontEvictionPolicy: DEFAULT_FONT_TTL_MS (5 minutes)
- FontLoadQueue: FONT_LOAD_MAX_RETRIES (3)

No behavior changes — values preserved exactly. Class-private fields
that mirrored these constants are removed in favor of module scope.
2026-05-24 21:13:38 +03:00
Ilia Mashkov ccef3cf7bb refactor: extract magic constants — wave 2 (TanStack Query defaults)
Promote the duplicated query lifecycle constants in \$shared/api/queryClient.ts:

- staleTime (5 minutes) -> DEFAULT_QUERY_STALE_TIME_MS
- gcTime (10 minutes)   -> DEFAULT_QUERY_GC_TIME_MS
- retry (3)             -> QUERY_RETRY_COUNT
- retryDelay (1s base, 30s cap) -> QUERY_RETRY_BASE_DELAY_MS + QUERY_RETRY_MAX_DELAY_MS

fontCatalogStore and availableFilterStore now import the stale/gc
constants instead of re-deriving '5 * 60 * 1000' / '10 * 60 * 1000'.

fontCatalogStore.svelte.spec.ts's queryClient mock now passes through
the new named exports via importOriginal so the consumer's imports
resolve.
2026-05-24 20:33:46 +03:00
Ilia Mashkov e3b489f173 refactor: extract magic constants — wave 1 (UX, API, storage)
- Use existing MULTIPLIER_S/M/L from \$entities/Font in SliderArea instead
  of inlining the 0.5/0.75/1 literals (constants already existed but were
  duplicated at the call site).
- Centralize API base URL in \$shared/api/endpoints.ts (was duplicated
  between proxyFonts and FilterAndSortFonts filters api).
- Promote every 'glyphdiff:...' localStorage key to a named module-level
  STORAGE_KEY constant. Test files now import the source constant rather
  than redeclaring it (eliminates silent-typo divergence risk).
2026-05-24 20:30:26 +03:00
Ilia Mashkov f92577608a refactor(Font): use pretext layout() directly in row size resolver
createFontRowSizeResolver was reaching into TextLayoutEngine, which
internally called pretext's heavy layoutWithLines and then walked
per-grapheme cursors to build chars arrays. The resolver discarded all
that work and used only totalHeight.

Replace with direct prepare + layout from @chenglou/pretext — pretext
docs explicitly recommend layout() (not layoutWithLines) for the resize
hot path: pure arithmetic on cached segment widths, no canvas calls, no
string allocations.

Test spies on TextLayoutEngine.prototype.layout migrated to vi.mock-ed
pretext layout (pretext's ESM exports are frozen — vi.spyOn fails with
"Cannot redefine property").

TextLayoutEngine marked @deprecated since it has no remaining
consumers; slated for removal after a release cycle.
2026-05-24 20:12:48 +03:00
Ilia Mashkov 728380498b refactor(Font): rename fontStore and appliedFontsManager
Both names were vague or overloaded:

- fontStore / FontStore -> fontCatalogStore / FontCatalogStore
  Three font-related stores live in this slice; the new name names the
  paginated catalog specifically.

- appliedFontsManager / AppliedFontsManager -> fontLifecycleManager /
  FontLifecycleManager
  "Applied" collided with the filter-side appliedFilterStore (different
  meaning). The class actually orchestrates a load-use-evict lifecycle
  with FontBufferCache + FontEvictionPolicy + FontLoadQueue
  collaborators, so "Manager" is justified. Companion types file moved
  alongside (appliedFonts.ts -> fontLifecycle.ts).

Directories, file basenames, factory (createFontStore ->
createFontCatalogStore), and the AppliedFontsManagerDeps interface all
renamed. All consumers (ComparisonView, SampleList, FontList,
FontApplicator, FontVirtualList, FilterAndSortFonts bindings,
createFontRowSizeResolver, mocks) updated.
2026-05-24 20:00:43 +03:00
Ilia Mashkov 07d044f4d6 refactor: extract BatchFontStore into new FetchFontsByIds feature
The byId font fetch was a verb-oriented capability with a single
consumer driven by a feature need (materializing comparison picks).
That shape belongs at the feature layer, not on the entity.

Move:
- entities/Font/model/store/batchFontStore -> features/FetchFontsByIds/model/store/fontsByIdsStore
- Class BatchFontStore -> FontsByIdsStore

entities/Font retains the transport primitives (fetchFontsByIds,
seedFontCache) and the keyspace (fontKeys); the feature wraps them in
the reactive store. comparisonStore now imports FontsByIdsStore from
the new feature. The proxy API is imported via direct path so vi.spyOn
on the source module still observes the call.
2026-05-24 19:41:40 +03:00
Ilia Mashkov df59dfda02 refactor(features): rename SetupFont to AdjustTypography + reorganize
Structural:
- Merge factory + singleton from lib/settingsManager and model/state into
  one model/store/typographySettingsStore/ slice
- Drop now-empty lib/ and model/state/ directories

Semantic:
- Rename feature SetupFont -> AdjustTypography (the feature owns
  continuous typography adjustment, not one-time font setup)
- Drop "Manager" from TypographySettingsManager -> TypographySettingsStore
  (class + factory); singleton typographySettingsStore unchanged

All consumers (Character, Line, SampleList, SliderArea, FontSampler,
comparisonStore) updated. Public barrel signature changed: now exports
createTypographySettingsStore and type TypographySettingsStore.
2026-05-24 18:27:10 +03:00
Ilia Mashkov ca382fd43d refactor(features): rename GetFonts to FilterAndSortFonts
The feature does not fetch fonts — that lives in \$entities/Font's
fontStore. It owns the user's filter selections, sort preference, and
search-by-name query that drive the listing. The new name describes what
it actually does.

Directory + every \$features/GetFonts import path updated; no symbol
renames in this commit.
2026-05-24 18:16:16 +03:00
93 changed files with 542 additions and 319 deletions
+1
View File
@@ -9,6 +9,7 @@ export {
fetchFontsByIds, fetchFontsByIds,
fetchProxyFontById, fetchProxyFontById,
fetchProxyFonts, fetchProxyFonts,
seedFontCache,
} from './proxy/proxyFonts'; } from './proxy/proxyFonts';
export type { export type {
ProxyFontsParams, ProxyFontsParams,
+4 -2
View File
@@ -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 * Proxy API parameters
+3 -3
View File
@@ -667,10 +667,10 @@ export const MOCK_STORES = {
}; };
}, },
/** /**
* Create a mock FontStore object * Create a mock FontCatalogStore object
* Matches FontStore's public API for Storybook use * Matches FontCatalogStore's public API for Storybook use
*/ */
fontStore: (config: { fontCatalogStore: (config: {
/** /**
* Preset font list * Preset font list
*/ */
@@ -1,7 +1,19 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import { TextLayoutEngine } from '$shared/lib';
import { installCanvasMock } from '$shared/lib/helpers/__mocks__/canvas'; 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 { import {
beforeEach, beforeEach,
describe, describe,
@@ -112,13 +124,13 @@ describe('createFontRowSizeResolver', () => {
const { resolver } = makeResolver(); const { resolver } = makeResolver();
statusMap.set('inter@400', 'loaded'); statusMap.set('inter@400', 'loaded');
const layoutSpy = vi.spyOn(TextLayoutEngine.prototype, 'layout'); const layoutSpy = vi.mocked(layout);
layoutSpy.mockClear();
resolver(0); resolver(0);
resolver(0); resolver(0);
expect(layoutSpy).toHaveBeenCalledTimes(1); expect(layoutSpy).toHaveBeenCalledTimes(1);
layoutSpy.mockRestore();
}); });
it('calls layout() again when containerWidth changes (cache miss)', () => { it('calls layout() again when containerWidth changes (cache miss)', () => {
@@ -126,14 +138,14 @@ describe('createFontRowSizeResolver', () => {
const { resolver } = makeResolver({ getContainerWidth: () => width }); const { resolver } = makeResolver({ getContainerWidth: () => width });
statusMap.set('inter@400', 'loaded'); statusMap.set('inter@400', 'loaded');
const layoutSpy = vi.spyOn(TextLayoutEngine.prototype, 'layout'); const layoutSpy = vi.mocked(layout);
layoutSpy.mockClear();
resolver(0); resolver(0);
width = 100; width = 100;
resolver(0); resolver(0);
expect(layoutSpy).toHaveBeenCalledTimes(2); expect(layoutSpy).toHaveBeenCalledTimes(2);
layoutSpy.mockRestore();
}); });
it('returns greater height when container narrows (more wrapping)', () => { it('returns greater height when container narrows (more wrapping)', () => {
@@ -1,5 +1,8 @@
import { TextLayoutEngine } from '$shared/lib'; import {
import { generateFontKey } from '../../model/store/appliedFontsStore/utils/generateFontKey/generateFontKey'; layout,
prepare,
} from '@chenglou/pretext';
import { generateFontKey } from '../../model/store/fontLifecycleManager/utils/generateFontKey/generateFontKey';
import type { import type {
FontLoadStatus, FontLoadStatus,
UnifiedFont, UnifiedFont,
@@ -41,7 +44,7 @@ export interface FontRowSizeResolverOptions {
/** /**
* Returns the font load status for a given font key (`'{id}@{weight}'` or `'{id}@vf'`). * Returns the font load status for a given font key (`'{id}@{weight}'` or `'{id}@vf'`).
* *
* In production: `(key) => appliedFontsManager.statuses.get(key)`. * In production: `(key) => fontLifecycleManager.statuses.get(key)`.
* Injected for testability — avoids a module-level singleton dependency in tests. * Injected for testability — avoids a module-level singleton dependency in tests.
* The call to `.get()` on a `SvelteMap` must happen inside a `$derived.by` context * The call to `.get()` on a `SvelteMap` must happen inside a `$derived.by` context
* for reactivity to work. This is satisfied when `itemHeight` is called by * for reactivity to work. This is satisfied when `itemHeight` is called by
@@ -79,14 +82,13 @@ export interface FontRowSizeResolverOptions {
* no DOM snap occurs. * no DOM snap occurs.
* *
* **Caching:** A `Map` keyed by `fontCssString|text|contentWidth|lineHeightPx` * **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. * naturally because a change in any input produces a different cache key.
* *
* @param options - Configuration and getter functions (all injected for testability). * @param options - Configuration and getter functions (all injected for testability).
* @returns A function `(rowIndex: number) => number` for use as `VirtualList.itemHeight`. * @returns A function `(rowIndex: number) => number` for use as `VirtualList.itemHeight`.
*/ */
export function createFontRowSizeResolver(options: FontRowSizeResolverOptions): (rowIndex: number) => number { export function createFontRowSizeResolver(options: FontRowSizeResolverOptions): (rowIndex: number) => number {
const engine = new TextLayoutEngine();
// Key: `${fontCssString}|${text}|${contentWidth}|${lineHeightPx}` // Key: `${fontCssString}|${text}|${contentWidth}|${lineHeightPx}`
const cache = new Map<string, number>(); 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. // generateFontKey: '{id}@{weight}' for static fonts, '{id}@vf' for variable fonts.
const fontKey = generateFontKey({ id: font.id, weight, isVariable: font.features?.isVariable }); const fontKey = generateFontKey({ id: font.id, weight, isVariable: font.features?.isVariable });
// Reading via getStatus() allows the caller to pass appliedFontsManager.statuses.get(), // Reading via getStatus() allows the caller to pass fontLifecycleManager.statuses.get(),
// which creates a Svelte 5 reactive dependency when called inside $derived.by. // which creates a Svelte 5 reactive dependency when called inside $derived.by.
const status = options.getStatus(fontKey); const status = options.getStatus(fontKey);
if (status !== 'loaded') { if (status !== 'loaded') {
@@ -126,7 +128,11 @@ export function createFontRowSizeResolver(options: FontRowSizeResolverOptions):
return cached; 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; const result = totalHeight + options.chromeHeight;
cache.set(cacheKey, result); cache.set(cacheKey, result);
return result; return result;
@@ -17,13 +17,17 @@ import {
generateMockFonts, generateMockFonts,
} from '../../../lib/mocks/fonts.mock'; } from '../../../lib/mocks/fonts.mock';
import type { UnifiedFont } from '../../types'; import type { UnifiedFont } from '../../types';
import { FontStore } from './fontStore.svelte'; import { FontCatalogStore } from './fontCatalogStore.svelte';
vi.mock('$shared/api/queryClient', () => ({ vi.mock('$shared/api/queryClient', async importOriginal => {
queryClient: new QueryClient({ const actual = await importOriginal<typeof import('$shared/api/queryClient')>();
defaultOptions: { queries: { retry: 0, gcTime: 0 } }, return {
}), ...actual,
})); queryClient: new QueryClient({
defaultOptions: { queries: { retry: 0, gcTime: 0 } },
}),
};
});
vi.mock('../../../api', () => ({ fetchProxyFonts: vi.fn() })); vi.mock('../../../api', () => ({ fetchProxyFonts: vi.fn() }));
import { queryClient } from '$shared/api/queryClient'; import { queryClient } from '$shared/api/queryClient';
@@ -44,7 +48,7 @@ const makeResponse = (
}); });
function makeStore(params = {}) { function makeStore(params = {}) {
return new FontStore({ limit: 10, ...params }); return new FontCatalogStore({ limit: 10, ...params });
} }
async function fetchedStore(params = {}, fonts = generateMockFonts(5), meta: Parameters<typeof makeResponse>[1] = {}) { async function fetchedStore(params = {}, fonts = generateMockFonts(5), meta: Parameters<typeof makeResponse>[1] = {}) {
@@ -55,7 +59,7 @@ async function fetchedStore(params = {}, fonts = generateMockFonts(5), meta: Par
return store; return store;
} }
describe('FontStore', () => { describe('FontCatalogStore', () => {
afterEach(() => { afterEach(() => {
queryClient.clear(); queryClient.clear();
vi.resetAllMocks(); vi.resetAllMocks();
@@ -69,7 +73,7 @@ describe('FontStore', () => {
}); });
it('defaults limit to 50 when not provided', () => { it('defaults limit to 50 when not provided', () => {
const store = new FontStore(); const store = new FontCatalogStore();
expect(store.params.limit).toBe(50); expect(store.params.limit).toBe(50);
store.destroy(); store.destroy();
}); });
@@ -390,11 +394,11 @@ describe('FontStore', () => {
}); });
describe('nextPage', () => { describe('nextPage', () => {
let store: FontStore; let store: FontCatalogStore;
beforeEach(async () => { beforeEach(async () => {
fetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 30, limit: 10, offset: 0 })); fetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 30, limit: 10, offset: 0 }));
store = new FontStore({ limit: 10 }); store = new FontCatalogStore({ limit: 10 });
await store.refetch(); await store.refetch();
flushSync(); flushSync();
}); });
@@ -415,7 +419,7 @@ describe('FontStore', () => {
// Set up a store where all fonts fit in one page (hasMore = false) // Set up a store where all fonts fit in one page (hasMore = false)
queryClient.clear(); queryClient.clear();
fetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 10, limit: 10, offset: 0 })); fetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 10, limit: 10, offset: 0 }));
store = new FontStore({ limit: 10 }); store = new FontCatalogStore({ limit: 10 });
await store.refetch(); await store.refetch();
flushSync(); flushSync();
@@ -454,7 +458,7 @@ describe('FontStore', () => {
describe('getCachedData / setQueryData', () => { describe('getCachedData / setQueryData', () => {
it('getCachedData returns undefined before any fetch', () => { it('getCachedData returns undefined before any fetch', () => {
queryClient.clear(); queryClient.clear();
const store = new FontStore({ limit: 10 }); const store = new FontCatalogStore({ limit: 10 });
expect(store.getCachedData()).toBeUndefined(); expect(store.getCachedData()).toBeUndefined();
store.destroy(); store.destroy();
}); });
@@ -502,7 +506,7 @@ describe('FontStore', () => {
}); });
describe('filter shortcut methods', () => { describe('filter shortcut methods', () => {
let store: FontStore; let store: FontCatalogStore;
beforeEach(() => { beforeEach(() => {
store = makeStore(); store = makeStore();
@@ -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 { import {
type InfiniteData, type InfiniteData,
InfiniteQueryObserver, InfiniteQueryObserver,
@@ -25,7 +29,7 @@ type FontStoreParams = Omit<ProxyFontsParams, 'offset'>;
type FontStoreResult = InfiniteQueryObserverResult<InfiniteData<ProxyFontsResponse, PageParam>, Error>; type FontStoreResult = InfiniteQueryObserverResult<InfiniteData<ProxyFontsResponse, PageParam>, Error>;
export class FontStore { export class FontCatalogStore {
#params = $state<FontStoreParams>({ limit: 50 }); #params = $state<FontStoreParams>({ limit: 50 });
#result = $state<FontStoreResult>({} as FontStoreResult); #result = $state<FontStoreResult>({} as FontStoreResult);
#observer: InfiniteQueryObserver< #observer: InfiniteQueryObserver<
@@ -427,8 +431,8 @@ export class FontStore {
const next = lastPage.offset + lastPage.limit; const next = lastPage.offset + lastPage.limit;
return next < lastPage.total ? { offset: next } : undefined; return next < lastPage.total ? { offset: next } : undefined;
}, },
staleTime: hasFilters ? 0 : 5 * 60 * 1000, staleTime: hasFilters ? 0 : DEFAULT_QUERY_STALE_TIME_MS,
gcTime: 10 * 60 * 1000, gcTime: DEFAULT_QUERY_GC_TIME_MS,
}; };
} }
@@ -459,8 +463,8 @@ export class FontStore {
} }
} }
export function createFontStore(params: FontStoreParams = {}): FontStore { export function createFontCatalogStore(params: FontStoreParams = {}): FontCatalogStore {
return new FontStore(params); return new FontCatalogStore(params);
} }
export const fontStore = new FontStore({ limit: 50 }); export const fontCatalogStore = new FontCatalogStore({ limit: 50 });
@@ -17,7 +17,36 @@ import { FontBufferCache } from './utils/fontBufferCache/FontBufferCache';
import { FontEvictionPolicy } from './utils/fontEvictionPolicy/FontEvictionPolicy'; import { FontEvictionPolicy } from './utils/fontEvictionPolicy/FontEvictionPolicy';
import { FontLoadQueue } from './utils/fontLoadQueue/FontLoadQueue'; import { FontLoadQueue } from './utils/fontLoadQueue/FontLoadQueue';
interface AppliedFontsManagerDeps { /**
* 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; cache?: FontBufferCache;
eviction?: FontEvictionPolicy; eviction?: FontEvictionPolicy;
queue?: FontLoadQueue; queue?: FontLoadQueue;
@@ -46,7 +75,7 @@ interface AppliedFontsManagerDeps {
* *
* **Browser APIs Used:** `scheduler.yield()`, `isInputPending()`, `requestIdleCallback`, Cache API, Network Information API * **Browser APIs Used:** `scheduler.yield()`, `isInputPending()`, `requestIdleCallback`, Cache API, Network Information API
*/ */
export class AppliedFontsManager { export class FontLifecycleManager {
// Injected collaborators - each handles one concern for better testability // Injected collaborators - each handles one concern for better testability
readonly #cache: FontBufferCache; readonly #cache: FontBufferCache;
readonly #eviction: FontEvictionPolicy; readonly #eviction: FontEvictionPolicy;
@@ -70,22 +99,20 @@ export class AppliedFontsManager {
// Tracks which callback type is pending ('idle' | 'timeout' | null) for proper cancellation // Tracks which callback type is pending ('idle' | 'timeout' | null) for proper cancellation
#pendingType: 'idle' | 'timeout' | null = null; #pendingType: 'idle' | 'timeout' | null = null;
readonly #PURGE_INTERVAL = 60000;
// Reactive status map for Svelte components to track font states // Reactive status map for Svelte components to track font states
statuses = new SvelteMap<string, FontLoadStatus>(); statuses = new SvelteMap<string, FontLoadStatus>();
// Starts periodic cleanup timer (browser-only). // Starts periodic cleanup timer (browser-only).
constructor( constructor(
{ cache = new FontBufferCache(), eviction = new FontEvictionPolicy(), queue = new FontLoadQueue() }: { cache = new FontBufferCache(), eviction = new FontEvictionPolicy(), queue = new FontLoadQueue() }:
AppliedFontsManagerDeps = {}, FontLifecycleManagerDeps = {},
) { ) {
// Inject collaborators - defaults provided for production, fakes for testing // Inject collaborators - defaults provided for production, fakes for testing
this.#cache = cache; this.#cache = cache;
this.#eviction = eviction; this.#eviction = eviction;
this.#queue = queue; this.#queue = queue;
if (typeof window !== 'undefined') { 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') { if (typeof requestIdleCallback !== 'undefined') {
this.#timeoutId = requestIdleCallback( this.#timeoutId = requestIdleCallback(
() => this.#processQueue(), () => this.#processQueue(),
{ timeout: 150 }, { timeout: IDLE_CALLBACK_TIMEOUT_MS },
) as unknown as ReturnType<typeof setTimeout>; ) as unknown as ReturnType<typeof setTimeout>;
this.#pendingType = 'idle'; this.#pendingType = 'idle';
} else { } else {
this.#timeoutId = setTimeout(() => this.#processQueue(), 16); this.#timeoutId = setTimeout(() => this.#processQueue(), SCHEDULE_FALLBACK_MS);
this.#pendingType = 'timeout'; this.#pendingType = 'timeout';
} }
} }
@@ -183,7 +210,7 @@ export class AppliedFontsManager {
// In data-saver mode, only load variable fonts and common weights (400, 700) // In data-saver mode, only load variable fonts and common weights (400, 700)
if (this.#shouldDeferNonCritical()) { 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) // 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 // Parse buffers one at a time with periodic yields to avoid blocking UI
const hasInputPending = !!(navigator as any).scheduling?.isInputPending; const hasInputPending = !!(navigator as any).scheduling?.isInputPending;
let lastYield = performance.now(); let lastYield = performance.now();
const YIELD_INTERVAL = 8;
for (const [key, config] of entries) { for (const [key, config] of entries) {
const buffer = buffers.get(key); const buffer = buffers.get(key);
@@ -214,7 +240,7 @@ export class AppliedFontsManager {
// Others: yield every 8ms as fallback // Others: yield every 8ms as fallback
const shouldYield = hasInputPending const shouldYield = hasInputPending
? (navigator as any).scheduling.isInputPending({ includeContinuous: true }) ? (navigator as any).scheduling.isInputPending({ includeContinuous: true })
: performance.now() - lastYield > YIELD_INTERVAL; : performance.now() - lastYield > YIELD_INTERVAL_MS;
if (shouldYield) { if (shouldYield) {
await yieldToMainThread(); await yieldToMainThread();
@@ -396,4 +422,4 @@ export class AppliedFontsManager {
/** /**
* Singleton instance use throughout the application for unified font loading state. * Singleton instance use throughout the application for unified font loading state.
*/ */
export const appliedFontsManager = new AppliedFontsManager(); export const fontLifecycleManager = new FontLifecycleManager();
@@ -1,8 +1,8 @@
/** /**
* @vitest-environment jsdom * @vitest-environment jsdom
*/ */
import { AppliedFontsManager } from './appliedFontsStore.svelte';
import { FontFetchError } from './errors'; import { FontFetchError } from './errors';
import { FontLifecycleManager } from './fontLifecycleManager.svelte';
import { FontEvictionPolicy } from './utils/fontEvictionPolicy/FontEvictionPolicy'; import { FontEvictionPolicy } from './utils/fontEvictionPolicy/FontEvictionPolicy';
class FakeBufferCache { class FakeBufferCache {
@@ -32,8 +32,8 @@ const makeConfig = (id: string, overrides: Partial<{ weight: number; isVariable:
...overrides, ...overrides,
}); });
describe('AppliedFontsManager', () => { describe('FontLifecycleManager', () => {
let manager: AppliedFontsManager; let manager: FontLifecycleManager;
let eviction: FontEvictionPolicy; let eviction: FontEvictionPolicy;
let mockFontFaceSet: { add: ReturnType<typeof vi.fn>; delete: ReturnType<typeof vi.fn> }; let mockFontFaceSet: { add: ReturnType<typeof vi.fn>; delete: ReturnType<typeof vi.fn> };
@@ -55,7 +55,7 @@ describe('AppliedFontsManager', () => {
}); });
vi.stubGlobal('FontFace', MockFontFace); vi.stubGlobal('FontFace', MockFontFace);
manager = new AppliedFontsManager({ cache: new FakeBufferCache() as any, eviction }); manager = new FontLifecycleManager({ cache: new FakeBufferCache() as any, eviction });
}); });
afterEach(() => { afterEach(() => {
@@ -101,7 +101,7 @@ describe('AppliedFontsManager', () => {
it('skips fonts that have exhausted retries', async () => { it('skips fonts that have exhausted retries', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const failManager = new AppliedFontsManager({ cache: new FailingBufferCache() as any, eviction }); const failManager = new FontLifecycleManager({ cache: new FailingBufferCache() as any, eviction });
// exhaust all 3 retries // exhaust all 3 retries
for (let i = 0; i < 3; i++) { for (let i = 0; i < 3; i++) {
@@ -160,7 +160,7 @@ describe('AppliedFontsManager', () => {
describe('Phase 1 — fetch', () => { describe('Phase 1 — fetch', () => {
it('sets status to error on fetch failure', async () => { it('sets status to error on fetch failure', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const failManager = new AppliedFontsManager({ cache: new FailingBufferCache() as any, eviction }); const failManager = new FontLifecycleManager({ cache: new FailingBufferCache() as any, eviction });
failManager.touch([makeConfig('broken')]); failManager.touch([makeConfig('broken')]);
await vi.advanceTimersByTimeAsync(50); await vi.advanceTimersByTimeAsync(50);
@@ -171,7 +171,7 @@ describe('AppliedFontsManager', () => {
it('logs a console error on fetch failure', async () => { it('logs a console error on fetch failure', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const failManager = new AppliedFontsManager({ cache: new FailingBufferCache() as any, eviction }); const failManager = new FontLifecycleManager({ cache: new FailingBufferCache() as any, eviction });
failManager.touch([makeConfig('broken')]); failManager.touch([makeConfig('broken')]);
await vi.advanceTimersByTimeAsync(50); await vi.advanceTimersByTimeAsync(50);
@@ -189,7 +189,7 @@ describe('AppliedFontsManager', () => {
evict() {}, evict() {},
clear() {}, clear() {},
}; };
const abortManager = new AppliedFontsManager({ cache: abortingCache as any, eviction }); const abortManager = new FontLifecycleManager({ cache: abortingCache as any, eviction });
abortManager.touch([makeConfig('aborted')]); abortManager.touch([makeConfig('aborted')]);
await vi.advanceTimersByTimeAsync(50); await vi.advanceTimersByTimeAsync(50);
@@ -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 { interface FontEvictionPolicyOptions {
/** /**
* TTL in milliseconds. Defaults to 5 minutes. * TTL in milliseconds. Defaults to {@link DEFAULT_FONT_TTL_MS}.
*/ */
ttl?: number; ttl?: number;
} }
@@ -17,7 +22,7 @@ export class FontEvictionPolicy {
readonly #TTL: number; readonly #TTL: number;
constructor({ ttl = 5 * 60 * 1000 }: FontEvictionPolicyOptions = {}) { constructor({ ttl = DEFAULT_FONT_TTL_MS }: FontEvictionPolicyOptions = {}) {
this.#TTL = ttl; this.#TTL = ttl;
} }
@@ -1,5 +1,11 @@
import type { FontLoadRequestConfig } from '../../../../types'; 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. * Manages the font load queue and per-font retry counts.
* *
@@ -10,8 +16,6 @@ export class FontLoadQueue {
#queue = new Map<string, FontLoadRequestConfig>(); #queue = new Map<string, FontLoadRequestConfig>();
#retryCounts = new Map<string, number>(); #retryCounts = new Map<string, number>();
readonly #MAX_RETRIES = 3;
/** /**
* Adds a font to the queue. * Adds a font to the queue.
* @returns `true` if the key was newly enqueued, `false` if it was already present. * @returns `true` if the key was newly enqueued, `false` if it was already present.
@@ -52,7 +56,7 @@ export class FontLoadQueue {
* Returns `true` if the font has reached or exceeded the maximum retry limit. * Returns `true` if the font has reached or exceeded the maximum retry limit.
*/ */
isMaxRetriesReached(key: string): boolean { isMaxRetriesReached(key: string): boolean {
return (this.#retryCounts.get(key) ?? 0) >= this.#MAX_RETRIES; return (this.#retryCounts.get(key) ?? 0) >= FONT_LOAD_MAX_RETRIES;
} }
/** /**
+7 -10
View File
@@ -1,12 +1,9 @@
// Applied fonts manager // Font lifecycle manager (browser-side load + cache + eviction)
export * from './appliedFontsStore/appliedFontsStore.svelte'; export * from './fontLifecycleManager/fontLifecycleManager.svelte';
// Batch font store // Paginated catalog
export { BatchFontStore } from './batchFontStore/batchFontStore.svelte';
// Single FontStore
export { export {
createFontStore, createFontCatalogStore,
FontStore, FontCatalogStore,
fontStore, fontCatalogStore,
} from './fontStore/fontStore.svelte'; } from './fontCatalogStore/fontCatalogStore.svelte';
+1 -1
View File
@@ -23,5 +23,5 @@ export type {
FontCollectionState, FontCollectionState,
} from './store'; } from './store';
export * from './store/appliedFonts'; export * from './store/fontLifecycle';
export * from './typography'; export * from './typography';
@@ -39,7 +39,7 @@ const fontArialBold = mockUnifiedFont({ id: 'arial-bold', name: 'Arial' });
docs: { docs: {
description: { description: {
story: story:
'Font that has never been loaded by appliedFontsManager. The component renders in its pending state: blurred, scaled down, and semi-transparent.', 'Font that has never been loaded by fontLifecycleManager. The component renders in its pending state: blurred, scaled down, and semi-transparent.',
}, },
}, },
}} }}
@@ -58,7 +58,7 @@ const fontArialBold = mockUnifiedFont({ id: 'arial-bold', name: 'Arial' });
docs: { docs: {
description: { description: {
story: story:
'Uses Arial, a system font available in all browsers. Because appliedFontsManager has not loaded it via FontFace, the manager status may remain pending — meaning the blur/scale state may still show. In a real app the manager would load the font and transition to the revealed state.', 'Uses Arial, a system font available in all browsers. Because fontLifecycleManager has not loaded it via FontFace, the manager status may remain pending — meaning the blur/scale state may still show. In a real app the manager would load the font and transition to the revealed state.',
}, },
}, },
}} }}
@@ -77,7 +77,7 @@ const fontArialBold = mockUnifiedFont({ id: 'arial-bold', name: 'Arial' });
docs: { docs: {
description: { description: {
story: story:
'Demonstrates passing a custom weight (700). The weight is forwarded to appliedFontsManager for font resolution; visually identical to the loaded state story until the manager confirms the font.', 'Demonstrates passing a custom weight (700). The weight is forwarded to fontLifecycleManager for font resolution; visually identical to the loaded state story until the manager confirms the font.',
}, },
}, },
}} }}
@@ -9,7 +9,7 @@ import type { Snippet } from 'svelte';
import { import {
DEFAULT_FONT_WEIGHT, DEFAULT_FONT_WEIGHT,
type UnifiedFont, type UnifiedFont,
appliedFontsManager, fontLifecycleManager,
} from '../../model'; } from '../../model';
interface Props { interface Props {
@@ -46,7 +46,7 @@ let {
}: Props = $props(); }: Props = $props();
const status = $derived( const status = $derived(
appliedFontsManager.getFontStatus( fontLifecycleManager.getFontStatus(
font.id, font.id,
weight, weight,
font.features?.isVariable, font.features?.isVariable,
@@ -10,7 +10,7 @@ const { Story } = defineMeta({
docs: { docs: {
description: { description: {
component: component:
'Virtualized font list backed by the `fontStore` singleton. Handles font loading registration (pin/touch) for visible items and triggers infinite scroll pagination via `fontStore.nextPage()`. Because the component reads directly from the `fontStore` singleton, stories render against a live (but empty/loading) store — no font data will appear unless the API is reachable from the Storybook host.', 'Virtualized font list backed by the `fontCatalogStore` singleton. Handles font loading registration (pin/touch) for visible items and triggers infinite scroll pagination via `fontCatalogStore.nextPage()`. Because the component reads directly from the `fontCatalogStore` singleton, stories render against a live (but empty/loading) store — no font data will appear unless the API is reachable from the Storybook host.',
}, },
story: { inline: false }, story: { inline: false },
}, },
@@ -33,7 +33,7 @@ import type { ComponentProps } from 'svelte';
docs: { docs: {
description: { description: {
story: story:
'Skeleton state shown while `fontStore.fonts` is empty and `fontStore.isLoading` is true. In a real session the skeleton fades out once the first page loads.', 'Skeleton state shown while `fontCatalogStore.fonts` is empty and `fontCatalogStore.isLoading` is true. In a real session the skeleton fades out once the first page loads.',
}, },
}, },
}} }}
@@ -63,7 +63,7 @@ import type { ComponentProps } from 'svelte';
docs: { docs: {
description: { description: {
story: story:
'No `skeleton` snippet provided. When `fontStore.fonts` is empty the underlying VirtualList renders its empty state directly.', 'No `skeleton` snippet provided. When `fontCatalogStore.fonts` is empty the underlying VirtualList renders its empty state directly.',
}, },
}, },
}} }}
@@ -86,7 +86,7 @@ import type { ComponentProps } from 'svelte';
docs: { docs: {
description: { description: {
story: story:
'Demonstrates how to configure a `children` snippet for item rendering. The list will be empty because `fontStore` is not populated in Storybook, but the template shows the expected slot shape: `{ item: UnifiedFont }`.', 'Demonstrates how to configure a `children` snippet for item rendering. The list will be empty because `fontCatalogStore` is not populated in Storybook, but the template shows the expected slot shape: `{ item: UnifiedFont }`.',
}, },
}, },
}} }}
@@ -18,8 +18,8 @@ import { getFontUrl } from '../../lib';
import { import {
type FontLoadRequestConfig, type FontLoadRequestConfig,
type UnifiedFont, type UnifiedFont,
appliedFontsManager, fontCatalogStore,
fontStore, fontLifecycleManager,
} from '../../model'; } from '../../model';
interface Props extends interface Props extends
@@ -51,13 +51,13 @@ let {
}: Props = $props(); }: Props = $props();
const isLoading = $derived( const isLoading = $derived(
fontStore.isFetching || fontStore.isLoading, fontCatalogStore.isFetching || fontCatalogStore.isLoading,
); );
let visibleFonts = $state<UnifiedFont[]>([]); let visibleFonts = $state<UnifiedFont[]>([]);
let isCatchingUp = $state(false); let isCatchingUp = $state(false);
const showInitialSkeleton = $derived(!!skeleton && isLoading && fontStore.fonts.length === 0); const showInitialSkeleton = $derived(!!skeleton && isLoading && fontCatalogStore.fonts.length === 0);
const showCatchupSkeleton = $derived(!!skeleton && isCatchingUp); const showCatchupSkeleton = $derived(!!skeleton && isCatchingUp);
function handleInternalVisibleChange(items: UnifiedFont[]) { function handleInternalVisibleChange(items: UnifiedFont[]) {
@@ -68,24 +68,30 @@ function handleInternalVisibleChange(items: UnifiedFont[]) {
/** /**
* Handle jump scroll — batch-load all missing pages then re-enable font loading. * Handle jump scroll — batch-load all missing pages then re-enable font loading.
* Suppresses appliedFontsManager.touch() during catch-up to avoid loading * Suppresses fontLifecycleManager.touch() during catch-up to avoid loading
* font files for thousands of intermediate fonts. * font files for thousands of intermediate fonts.
*/ */
async function handleJump(targetIndex: number) { async function handleJump(targetIndex: number) {
if (isCatchingUp || !fontStore.pagination.hasMore) { if (isCatchingUp || !fontCatalogStore.pagination.hasMore) {
return; return;
} }
isCatchingUp = true; isCatchingUp = true;
try { try {
await fontStore.fetchAllPagesTo(targetIndex); await fontCatalogStore.fetchAllPagesTo(targetIndex);
} finally { } finally {
isCatchingUp = false; isCatchingUp = false;
} }
} }
/**
* 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[]) => { const debouncedTouch = debounce((configs: FontLoadRequestConfig[]) => {
appliedFontsManager.touch(configs); fontLifecycleManager.touch(configs);
}, 150); }, TOUCH_DEBOUNCE_MS);
// Re-touch whenever visible set or weight changes — fixes weight-change gap // Re-touch whenever visible set or weight changes — fixes weight-change gap
$effect(() => { $effect(() => {
@@ -111,11 +117,11 @@ $effect(() => {
const w = weight; const w = weight;
const fonts = visibleFonts; const fonts = visibleFonts;
for (const f of fonts) { for (const f of fonts) {
appliedFontsManager.pin(f.id, w, f.features?.isVariable); fontLifecycleManager.pin(f.id, w, f.features?.isVariable);
} }
return () => { return () => {
for (const f of fonts) { for (const f of fonts) {
appliedFontsManager.unpin(f.id, w, f.features?.isVariable); fontLifecycleManager.unpin(f.id, w, f.features?.isVariable);
} }
}; };
}); });
@@ -125,12 +131,12 @@ $effect(() => {
*/ */
function loadMore() { function loadMore() {
if ( if (
!fontStore.pagination.hasMore !fontCatalogStore.pagination.hasMore
|| fontStore.isFetching || fontCatalogStore.isFetching
) { ) {
return; return;
} }
fontStore.nextPage(); fontCatalogStore.nextPage();
} }
/** /**
@@ -140,12 +146,12 @@ function loadMore() {
* of the loaded items. Only fetches if there are more pages available. * of the loaded items. Only fetches if there are more pages available.
*/ */
function handleNearBottom(_lastVisibleIndex: number) { function handleNearBottom(_lastVisibleIndex: number) {
const { hasMore } = fontStore.pagination; const { hasMore } = fontCatalogStore.pagination;
// VirtualList already checks if we're near the bottom of loaded items. // VirtualList already checks if we're near the bottom of loaded items.
// Guard isCatchingUp: fetchAllPagesTo bypasses TQ so isFetching stays false // Guard isCatchingUp: fetchAllPagesTo bypasses TQ so isFetching stays false
// during batch catch-up, which would otherwise let nextPage() race with it. // during batch catch-up, which would otherwise let nextPage() race with it.
if (hasMore && !fontStore.isFetching && !isCatchingUp) { if (hasMore && !fontCatalogStore.isFetching && !isCatchingUp) {
loadMore(); loadMore();
} }
} }
@@ -160,8 +166,8 @@ function handleNearBottom(_lastVisibleIndex: number) {
{:else} {:else}
<!-- VirtualList persists during pagination - no destruction/recreation --> <!-- VirtualList persists during pagination - no destruction/recreation -->
<VirtualList <VirtualList
items={fontStore.fonts} items={fontCatalogStore.fonts}
total={fontStore.pagination.total} total={fontCatalogStore.pagination.total}
isLoading={isLoading || isCatchingUp} isLoading={isLoading || isCatchingUp}
onVisibleItemsChange={handleInternalVisibleChange} onVisibleItemsChange={handleInternalVisibleChange}
onNearBottom={handleNearBottom} onNearBottom={handleNearBottom}
+6
View File
@@ -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';
@@ -16,6 +16,7 @@ import {
DEFAULT_FONT_WEIGHT, DEFAULT_FONT_WEIGHT,
DEFAULT_LETTER_SPACING, DEFAULT_LETTER_SPACING,
DEFAULT_LINE_HEIGHT, DEFAULT_LINE_HEIGHT,
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
} from '$entities/Font'; } from '$entities/Font';
import { import {
type ControlDataModel, type ControlDataModel,
@@ -27,6 +28,14 @@ import {
} from '$shared/lib'; } from '$shared/lib';
import { SvelteMap } from 'svelte/reactivity'; 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>; 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 * Manages multiple typography controls with persistent storage and
* responsive scaling support for font size. * responsive scaling support for font size.
*/ */
export class TypographySettingsManager { export class TypographySettingsStore {
/** /**
* Internal map of reactive controls keyed by their identifier * Internal map of reactive controls keyed by their identifier
*/ */
@@ -138,7 +147,7 @@ export class TypographySettingsManager {
const calculatedBase = currentDisplayValue / this.#multiplier; const calculatedBase = currentDisplayValue / this.#multiplier;
// Only update if the difference is significant (prevents rounding jitter) // 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; 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 * Creates a typography control manager
* *
@@ -303,9 +322,9 @@ export class TypographySettingsManager {
* @param storageId - Persistent storage identifier * @param storageId - Persistent storage identifier
* @returns Typography control manager instance * @returns Typography control manager instance
*/ */
export function createTypographySettingsManager( export function createTypographySettingsStore(
configs: ControlModel<ControlId>[], configs: ControlModel<ControlId>[],
storageId: string = 'glyphdiff:typography', storageId: string = DEFAULT_STORAGE_KEY,
) { ) {
const storage = createPersistentStore<TypographySettings>(storageId, { const storage = createPersistentStore<TypographySettings>(storageId, {
fontSize: DEFAULT_FONT_SIZE, fontSize: DEFAULT_FONT_SIZE,
@@ -313,5 +332,13 @@ export function createTypographySettingsManager(
lineHeight: DEFAULT_LINE_HEIGHT, lineHeight: DEFAULT_LINE_HEIGHT,
letterSpacing: DEFAULT_LETTER_SPACING, 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,
);
@@ -17,13 +17,13 @@ import {
} from 'vitest'; } from 'vitest';
import { import {
type TypographySettings, type TypographySettings,
TypographySettingsManager, TypographySettingsStore,
} from './settingsManager.svelte'; } 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. * These are unit tests for the manager logic, separate from component rendering.
* *
* NOTE: Svelte 5's $effect runs in microtasks, so we need to flush effects * NOTE: Svelte 5's $effect runs in microtasks, so we need to flush effects
@@ -46,7 +46,7 @@ async function flushEffects() {
await Promise.resolve(); await Promise.resolve();
} }
describe('TypographySettingsManager - Unit Tests', () => { describe('TypographySettingsStore - Unit Tests', () => {
let mockStorage: TypographySettings; let mockStorage: TypographySettings;
let mockPersistentStore: { let mockPersistentStore: {
value: TypographySettings; value: TypographySettings;
@@ -86,7 +86,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
describe('Initialization', () => { describe('Initialization', () => {
it('creates manager with default values from storage', () => { it('creates manager with default values from storage', () => {
const manager = new TypographySettingsManager( const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore, mockPersistentStore,
); );
@@ -106,7 +106,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
}; };
mockPersistentStore = createMockPersistentStore(mockStorage); mockPersistentStore = createMockPersistentStore(mockStorage);
const manager = new TypographySettingsManager( const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore, mockPersistentStore,
); );
@@ -118,7 +118,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
}); });
it('initializes font size control with base size multiplied by current multiplier (1)', () => { it('initializes font size control with base size multiplied by current multiplier (1)', () => {
const manager = new TypographySettingsManager( const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore, mockPersistentStore,
); );
@@ -127,7 +127,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
}); });
it('returns all controls via controls getter', () => { it('returns all controls via controls getter', () => {
const manager = new TypographySettingsManager( const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore, mockPersistentStore,
); );
@@ -143,7 +143,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
}); });
it('returns individual controls via specific getters', () => { it('returns individual controls via specific getters', () => {
const manager = new TypographySettingsManager( const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore, mockPersistentStore,
); );
@@ -161,7 +161,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
}); });
it('control instances have expected interface', () => { it('control instances have expected interface', () => {
const manager = new TypographySettingsManager( const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore, mockPersistentStore,
); );
@@ -180,7 +180,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
describe('Multiplier System', () => { describe('Multiplier System', () => {
it('has default multiplier of 1', () => { it('has default multiplier of 1', () => {
const manager = new TypographySettingsManager( const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore, mockPersistentStore,
); );
@@ -189,7 +189,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
}); });
it('updates multiplier when set', () => { it('updates multiplier when set', () => {
const manager = new TypographySettingsManager( const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore, mockPersistentStore,
); );
@@ -202,7 +202,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
}); });
it('does not update multiplier if set to same value', () => { it('does not update multiplier if set to same value', () => {
const manager = new TypographySettingsManager( const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore, mockPersistentStore,
); );
@@ -218,7 +218,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
mockStorage = { fontSize: 48, fontWeight: 400, lineHeight: 1.5, letterSpacing: 0 }; mockStorage = { fontSize: 48, fontWeight: 400, lineHeight: 1.5, letterSpacing: 0 };
mockPersistentStore = createMockPersistentStore(mockStorage); mockPersistentStore = createMockPersistentStore(mockStorage);
const manager = new TypographySettingsManager( const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore, mockPersistentStore,
); );
@@ -242,7 +242,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
}); });
it('updates font size control display value when multiplier increases', () => { it('updates font size control display value when multiplier increases', () => {
const manager = new TypographySettingsManager( const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore, mockPersistentStore,
); );
@@ -263,7 +263,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
describe('Base Size Setter', () => { describe('Base Size Setter', () => {
it('updates baseSize when set directly', () => { it('updates baseSize when set directly', () => {
const manager = new TypographySettingsManager( const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore, mockPersistentStore,
); );
@@ -274,7 +274,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
}); });
it('updates size control value when baseSize is set', () => { it('updates size control value when baseSize is set', () => {
const manager = new TypographySettingsManager( const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore, mockPersistentStore,
); );
@@ -285,7 +285,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
}); });
it('applies multiplier to size control when baseSize is set', () => { it('applies multiplier to size control when baseSize is set', () => {
const manager = new TypographySettingsManager( const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore, mockPersistentStore,
); );
@@ -299,7 +299,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
describe('Rendered Size Calculation', () => { describe('Rendered Size Calculation', () => {
it('calculates renderedSize as baseSize * multiplier', () => { it('calculates renderedSize as baseSize * multiplier', () => {
const manager = new TypographySettingsManager( const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore, mockPersistentStore,
); );
@@ -308,7 +308,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
}); });
it('updates renderedSize when multiplier changes', () => { it('updates renderedSize when multiplier changes', () => {
const manager = new TypographySettingsManager( const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore, mockPersistentStore,
); );
@@ -321,7 +321,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
}); });
it('updates renderedSize when baseSize changes', () => { it('updates renderedSize when baseSize changes', () => {
const manager = new TypographySettingsManager( const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore, mockPersistentStore,
); );
@@ -341,7 +341,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
// proxy effect behavior should be tested in E2E tests. // proxy effect behavior should be tested in E2E tests.
it('does NOT immediately update baseSize from control change (effect is async)', () => { it('does NOT immediately update baseSize from control change (effect is async)', () => {
const manager = new TypographySettingsManager( const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore, mockPersistentStore,
); );
@@ -356,7 +356,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
}); });
it('updates baseSize via direct setter (synchronous)', () => { it('updates baseSize via direct setter (synchronous)', () => {
const manager = new TypographySettingsManager( const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore, mockPersistentStore,
); );
@@ -381,7 +381,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
}; };
mockPersistentStore = createMockPersistentStore(mockStorage); mockPersistentStore = createMockPersistentStore(mockStorage);
const manager = new TypographySettingsManager( const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore, mockPersistentStore,
); );
@@ -394,7 +394,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
}); });
it('syncs to storage after effect flush (async)', async () => { it('syncs to storage after effect flush (async)', async () => {
const manager = new TypographySettingsManager( const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore, mockPersistentStore,
); );
@@ -410,7 +410,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
}); });
it('syncs control changes to storage after effect flush (async)', async () => { it('syncs control changes to storage after effect flush (async)', async () => {
const manager = new TypographySettingsManager( const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore, mockPersistentStore,
); );
@@ -423,7 +423,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
}); });
it('syncs height control changes to storage after effect flush (async)', async () => { it('syncs height control changes to storage after effect flush (async)', async () => {
const manager = new TypographySettingsManager( const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore, mockPersistentStore,
); );
@@ -435,7 +435,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
}); });
it('syncs spacing control changes to storage after effect flush (async)', async () => { it('syncs spacing control changes to storage after effect flush (async)', async () => {
const manager = new TypographySettingsManager( const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore, mockPersistentStore,
); );
@@ -449,7 +449,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
describe('Control Value Getters', () => { describe('Control Value Getters', () => {
it('returns current weight value', () => { it('returns current weight value', () => {
const manager = new TypographySettingsManager( const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore, mockPersistentStore,
); );
@@ -461,7 +461,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
}); });
it('returns current height value', () => { it('returns current height value', () => {
const manager = new TypographySettingsManager( const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore, mockPersistentStore,
); );
@@ -473,7 +473,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
}); });
it('returns current spacing value', () => { it('returns current spacing value', () => {
const manager = new TypographySettingsManager( const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore, mockPersistentStore,
); );
@@ -486,7 +486,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
it('returns default value when control is not found', () => { it('returns default value when control is not found', () => {
// Create a manager with empty configs (no controls) // Create a manager with empty configs (no controls)
const manager = new TypographySettingsManager([], mockPersistentStore); const manager = new TypographySettingsStore([], mockPersistentStore);
expect(manager.weight).toBe(DEFAULT_FONT_WEIGHT); expect(manager.weight).toBe(DEFAULT_FONT_WEIGHT);
expect(manager.height).toBe(DEFAULT_LINE_HEIGHT); expect(manager.height).toBe(DEFAULT_LINE_HEIGHT);
@@ -504,7 +504,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
}; };
mockPersistentStore = createMockPersistentStore(mockStorage); mockPersistentStore = createMockPersistentStore(mockStorage);
const manager = new TypographySettingsManager( const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore, mockPersistentStore,
); );
@@ -537,7 +537,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
clear: clearSpy, clear: clearSpy,
}; };
const manager = new TypographySettingsManager( const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore, mockPersistentStore,
); );
@@ -548,7 +548,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
}); });
it('respects multiplier when resetting font size control', () => { it('respects multiplier when resetting font size control', () => {
const manager = new TypographySettingsManager( const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore, mockPersistentStore,
); );
@@ -566,7 +566,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
describe('Complex Scenarios', () => { describe('Complex Scenarios', () => {
it('handles changing multiplier then modifying baseSize', () => { it('handles changing multiplier then modifying baseSize', () => {
const manager = new TypographySettingsManager( const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore, mockPersistentStore,
); );
@@ -587,7 +587,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
}); });
it('maintains correct renderedSize throughout changes', () => { it('maintains correct renderedSize throughout changes', () => {
const manager = new TypographySettingsManager( const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore, mockPersistentStore,
); );
@@ -609,7 +609,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
}); });
it('handles multiple control changes in sequence', async () => { it('handles multiple control changes in sequence', async () => {
const manager = new TypographySettingsManager( const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore, mockPersistentStore,
); );
@@ -634,7 +634,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
mockStorage = { fontSize: 48, fontWeight: 400, lineHeight: 1.5, letterSpacing: 0 }; mockStorage = { fontSize: 48, fontWeight: 400, lineHeight: 1.5, letterSpacing: 0 };
mockPersistentStore = createMockPersistentStore(mockStorage); mockPersistentStore = createMockPersistentStore(mockStorage);
const manager = new TypographySettingsManager( const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore, mockPersistentStore,
); );
@@ -646,7 +646,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
}); });
it('handles very small multiplier', () => { it('handles very small multiplier', () => {
const manager = new TypographySettingsManager( const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore, mockPersistentStore,
); );
@@ -659,7 +659,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
}); });
it('handles large base size with multiplier', () => { it('handles large base size with multiplier', () => {
const manager = new TypographySettingsManager( const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore, mockPersistentStore,
); );
@@ -672,7 +672,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
}); });
it('handles floating point precision in multiplier', () => { it('handles floating point precision in multiplier', () => {
const manager = new TypographySettingsManager( const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore, mockPersistentStore,
); );
@@ -691,7 +691,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
}); });
it('handles control methods (increase/decrease)', () => { it('handles control methods (increase/decrease)', () => {
const manager = new TypographySettingsManager( const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore, mockPersistentStore,
); );
@@ -705,7 +705,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
}); });
it('handles control boundary conditions', () => { it('handles control boundary conditions', () => {
const manager = new TypographySettingsManager( const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore, mockPersistentStore,
); );
@@ -30,6 +30,8 @@
import { createPersistentStore } from '$shared/lib'; import { createPersistentStore } from '$shared/lib';
export const STORAGE_KEY = 'glyphdiff:theme';
type Theme = 'light' | 'dark'; type Theme = 'light' | 'dark';
type ThemeSource = 'system' | 'user'; type ThemeSource = 'system' | 'user';
@@ -56,7 +58,7 @@ class ThemeManager {
/** /**
* Persistent storage for user's theme preference * Persistent storage for user's theme preference
*/ */
#store = createPersistentStore<Theme | null>('glyphdiff:theme', null); #store = createPersistentStore<Theme | null>(STORAGE_KEY, null);
/** /**
* Bound handler for system theme change events * Bound handler for system theme change events
*/ */
@@ -40,8 +40,7 @@ import { ThemeManager } from './ThemeManager.svelte';
* - MediaQueryList listener management * - MediaQueryList listener management
*/ */
// Storage key used by ThemeManager import { STORAGE_KEY } from './ThemeManager.svelte';
const STORAGE_KEY = 'glyphdiff:theme';
// Helper type for MediaQueryList event handler // Helper type for MediaQueryList event handler
type MediaQueryListCallback = (this: MediaQueryList, ev: MediaQueryListEvent) => void; type MediaQueryListCallback = (this: MediaQueryList, ev: MediaQueryListEvent) => void;
@@ -8,7 +8,7 @@ import {
FontApplicator, FontApplicator,
type UnifiedFont, type UnifiedFont,
} from '$entities/Font'; } from '$entities/Font';
import { typographySettingsStore } from '$features/SetupFont/model'; import { typographySettingsStore } from '$features/AdjustTypography/model';
import { import {
Badge, Badge,
ContentEditable, ContentEditable,
+1
View File
@@ -0,0 +1 @@
export { FontsByIdsStore } from './model';
@@ -0,0 +1 @@
export { FontsByIdsStore } from './store/fontsByIdsStore/fontsByIdsStore.svelte';
@@ -1,14 +1,14 @@
import { fontKeys } from '$shared/api/queryKeys';
import { BaseQueryStore } from '$shared/lib/helpers/BaseQueryStore.svelte';
import { import {
fetchFontsByIds, fetchFontsByIds,
seedFontCache, seedFontCache,
} from '../../../api/proxy/proxyFonts'; } from '$entities/Font/api/proxy/proxyFonts';
import { import {
FontNetworkError, FontNetworkError,
FontResponseError, FontResponseError,
} from '../../../lib/errors/errors'; } from '$entities/Font/lib/errors/errors';
import type { UnifiedFont } from '../../types'; 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. * 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. * Reactive store for fetching specific fonts by ID via the proxy batch endpoint.
* Integrates with TanStack Query via BaseQueryStore and handles * Wraps TanStack Query and seeds the detail cache for sibling consumers.
* normalized cache seeding.
*/ */
export class BatchFontStore extends BaseQueryStore<UnifiedFont[]> { export class FontsByIdsStore extends BaseQueryStore<UnifiedFont[]> {
constructor(initialIds: string[] = []) { constructor(initialIds: string[] = []) {
super({ super({
queryKey: fontKeys.batch(initialIds), queryKey: fontKeys.batch(initialIds),
@@ -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 { queryClient } from '$shared/api/queryClient';
import { fontKeys } from '$shared/api/queryKeys'; import { fontKeys } from '$shared/api/queryKeys';
import { import {
@@ -7,14 +12,9 @@ import {
it, it,
vi, vi,
} from 'vitest'; } from 'vitest';
import * as api from '../../../api/proxy/proxyFonts'; import { FontsByIdsStore } from './fontsByIdsStore.svelte';
import {
FontNetworkError,
FontResponseError,
} from '../../../lib/errors/errors';
import { BatchFontStore } from './batchFontStore.svelte';
describe('BatchFontStore', () => { describe('FontsByIdsStore', () => {
beforeEach(() => { beforeEach(() => {
queryClient.clear(); queryClient.clear();
vi.clearAllMocks(); vi.clearAllMocks();
@@ -23,7 +23,7 @@ describe('BatchFontStore', () => {
describe('Fetch Behavior', () => { describe('Fetch Behavior', () => {
it('should skip fetch when initialized with empty IDs', async () => { it('should skip fetch when initialized with empty IDs', async () => {
const spy = vi.spyOn(api, 'fetchFontsByIds'); const spy = vi.spyOn(api, 'fetchFontsByIds');
const store = new BatchFontStore([]); const store = new FontsByIdsStore([]);
expect(spy).not.toHaveBeenCalled(); expect(spy).not.toHaveBeenCalled();
expect(store.fonts).toEqual([]); expect(store.fonts).toEqual([]);
}); });
@@ -31,7 +31,7 @@ describe('BatchFontStore', () => {
it('should fetch and seed cache for valid IDs', async () => { it('should fetch and seed cache for valid IDs', async () => {
const fonts = [{ id: 'a', name: 'A' }] as any[]; const fonts = [{ id: 'a', name: 'A' }] as any[];
vi.spyOn(api, 'fetchFontsByIds').mockResolvedValue(fonts); 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 }); await vi.waitFor(() => expect(store.fonts).toEqual(fonts), { timeout: 1000 });
expect(queryClient.getQueryData(fontKeys.detail('a'))).toEqual(fonts[0]); expect(queryClient.getQueryData(fontKeys.detail('a'))).toEqual(fonts[0]);
}); });
@@ -42,7 +42,7 @@ describe('BatchFontStore', () => {
vi.spyOn(api, 'fetchFontsByIds').mockImplementation(() => vi.spyOn(api, 'fetchFontsByIds').mockImplementation(() =>
new Promise(r => setTimeout(() => r([{ id: 'a' }] as any), 50)) 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); expect(store.isLoading).toBe(true);
await vi.waitFor(() => expect(store.isLoading).toBe(false), { timeout: 1000 }); await vi.waitFor(() => expect(store.isLoading).toBe(false), { timeout: 1000 });
}); });
@@ -51,7 +51,7 @@ describe('BatchFontStore', () => {
describe('Error Handling', () => { describe('Error Handling', () => {
it('should wrap network failures in FontNetworkError', async () => { it('should wrap network failures in FontNetworkError', async () => {
vi.spyOn(api, 'fetchFontsByIds').mockRejectedValue(new Error('Network fail')); 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 }); await vi.waitFor(() => expect(store.isError).toBe(true), { timeout: 1000 });
expect(store.error).toBeInstanceOf(FontNetworkError); expect(store.error).toBeInstanceOf(FontNetworkError);
}); });
@@ -59,7 +59,7 @@ describe('BatchFontStore', () => {
it('should handle malformed API responses with FontResponseError', async () => { it('should handle malformed API responses with FontResponseError', async () => {
// Mocking a malformed response that the store should validate // Mocking a malformed response that the store should validate
vi.spyOn(api, 'fetchFontsByIds').mockResolvedValue(null as any); 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 }); await vi.waitFor(() => expect(store.isError).toBe(true), { timeout: 1000 });
expect(store.error).toBeInstanceOf(FontResponseError); expect(store.error).toBeInstanceOf(FontResponseError);
}); });
@@ -67,7 +67,7 @@ describe('BatchFontStore', () => {
it('should have null error in success state', async () => { it('should have null error in success state', async () => {
const fonts = [{ id: 'a' }] as any[]; const fonts = [{ id: 'a' }] as any[];
vi.spyOn(api, 'fetchFontsByIds').mockResolvedValue(fonts); 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 }); await vi.waitFor(() => expect(store.fonts).toEqual(fonts), { timeout: 1000 });
expect(store.error).toBeNull(); expect(store.error).toBeNull();
}); });
@@ -78,7 +78,7 @@ describe('BatchFontStore', () => {
const fonts1 = [{ id: 'a' }] as any[]; const fonts1 = [{ id: 'a' }] as any[];
const spy = vi.spyOn(api, 'fetchFontsByIds').mockResolvedValueOnce(fonts1); 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 }); await vi.waitFor(() => expect(store.fonts).toEqual(fonts1), { timeout: 1000 });
spy.mockClear(); spy.mockClear();
@@ -97,7 +97,7 @@ describe('BatchFontStore', () => {
.mockResolvedValueOnce(fonts1) .mockResolvedValueOnce(fonts1)
.mockResolvedValueOnce(fonts2); .mockResolvedValueOnce(fonts2);
const store = new BatchFontStore(['a']); const store = new FontsByIdsStore(['a']);
await vi.waitFor(() => expect(store.fonts).toEqual(fonts1), { timeout: 1000 }); await vi.waitFor(() => expect(store.fonts).toEqual(fonts1), { timeout: 1000 });
store.setIds(['b']); store.setIds(['b']);
@@ -8,8 +8,9 @@
*/ */
import { api } from '$shared/api/api'; 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 * Filter metadata type from backend
@@ -38,7 +38,7 @@ export {
} from './store/appliedFilterStore/appliedFilterStore.svelte'; } from './store/appliedFilterStore/appliedFilterStore.svelte';
/** /**
* Side-effect import: installs the global appliedFilterStore+sortStore fontStore * Side-effect import: installs the global appliedFilterStore+sortStore fontCatalogStore
* bridge on first import of this feature barrel. No exports. * bridge on first import of this feature barrel. No exports.
*/ */
import './store/bindings.svelte'; import './store/bindings.svelte';
@@ -6,7 +6,7 @@
* *
* @example * @example
* ```ts * ```ts
* import { availableFilterStore } from '$features/GetFonts'; * import { availableFilterStore } from '$features/FilterAndSortFonts';
* *
* // Access filters (reactive) * // Access filters (reactive)
* $: filters = availableFilterStore.filters; * $: filters = availableFilterStore.filters;
@@ -15,9 +15,13 @@
* ``` * ```
*/ */
import { fetchProxyFilters } from '$features/GetFonts/api/filters/filters'; import { fetchProxyFilters } from '$features/FilterAndSortFonts/api/filters/filters';
import type { FilterMetadata } from '$features/GetFonts/api/filters/filters'; import type { FilterMetadata } from '$features/FilterAndSortFonts/api/filters/filters';
import { queryClient } from '$shared/api/queryClient'; import {
DEFAULT_QUERY_GC_TIME_MS,
DEFAULT_QUERY_STALE_TIME_MS,
queryClient,
} from '$shared/api/queryClient';
import { import {
type QueryKey, type QueryKey,
QueryObserver, QueryObserver,
@@ -81,8 +85,8 @@ export class AvailableFilterStore {
return { return {
queryKey: this.getQueryKey(), queryKey: this.getQueryKey(),
queryFn: () => this.fetchFn(), queryFn: () => this.fetchFn(),
staleTime: 5 * 60 * 1000, // 5 minutes staleTime: DEFAULT_QUERY_STALE_TIME_MS,
gcTime: 10 * 60 * 1000, // 10 minutes gcTime: DEFAULT_QUERY_GC_TIME_MS,
}; };
} }
@@ -1,6 +1,6 @@
/** /**
* Bridges feature-level UI state (appliedFilterStore + sortStore) to the * Bridges feature-level UI state (appliedFilterStore + sortStore) to the
* entity-level fontStore query params. * entity-level fontCatalogStore query params.
* *
* Centralizing this here means consumers (Search, FontSearch, * Centralizing this here means consumers (Search, FontSearch,
* FilterControls, etc.) bind to the manager/store directly without * FilterControls, etc.) bind to the manager/store directly without
@@ -9,7 +9,7 @@
* observer, so it lives at module scope, not in any individual widget. * observer, so it lives at module scope, not in any individual widget.
*/ */
import { fontStore } from '$entities/Font'; import { fontCatalogStore } from '$entities/Font';
import { untrack } from 'svelte'; import { untrack } from 'svelte';
import { mapAppliedFiltersToParams } from '../../lib/mapper/mapAppliedFiltersToParams'; import { mapAppliedFiltersToParams } from '../../lib/mapper/mapAppliedFiltersToParams';
import { appliedFilterStore } from './appliedFilterStore/appliedFilterStore.svelte'; import { appliedFilterStore } from './appliedFilterStore/appliedFilterStore.svelte';
@@ -42,20 +42,20 @@ $effect.root(() => {
}); });
/** /**
* Mirror filter selections + debounced search query into fontStore params. * Mirror filter selections + debounced search query into fontCatalogStore params.
* untrack the write so fontStore's internal $state reads don't feed back * untrack the write so fontCatalogStore's internal $state reads don't feed back
* into this effect's dependency graph. * into this effect's dependency graph.
*/ */
$effect(() => { $effect(() => {
const params = mapAppliedFiltersToParams(appliedFilterStore); const params = mapAppliedFiltersToParams(appliedFilterStore);
untrack(() => fontStore.setParams(params)); untrack(() => fontCatalogStore.setParams(params));
}); });
/** /**
* Mirror sort selection into fontStore. * Mirror sort selection into fontCatalogStore.
*/ */
$effect(() => { $effect(() => {
const apiSort = sortStore.apiValue; const apiSort = sortStore.apiValue;
untrack(() => fontStore.setSort(apiSort)); untrack(() => fontCatalogStore.setSort(apiSort));
}); });
}); });
@@ -1,7 +1,7 @@
import { import {
appliedFilterStore, appliedFilterStore,
availableFilterStore, availableFilterStore,
} from '$features/GetFonts'; } from '$features/FilterAndSortFonts';
import { import {
render, render,
screen, screen,
-6
View File
@@ -1,6 +0,0 @@
export {
createTypographySettingsManager,
type TypographySettingsManager,
} from './lib';
export { typographySettingsStore } from './model';
export { TypographyMenu } from './ui';
-4
View File
@@ -1,4 +0,0 @@
export {
createTypographySettingsManager,
type TypographySettingsManager,
} from './settingsManager/settingsManager.svelte';
-1
View File
@@ -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',
);
+19
View File
@@ -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;
+32 -15
View File
@@ -1,5 +1,31 @@
import { QueryClient } from '@tanstack/query-core'; 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 * TanStack Query client instance
* *
@@ -15,14 +41,8 @@ import { QueryClient } from '@tanstack/query-core';
export const queryClient = new QueryClient({ export const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
queries: { queries: {
/** staleTime: DEFAULT_QUERY_STALE_TIME_MS,
* Data remains fresh for 5 minutes after fetch gcTime: DEFAULT_QUERY_GC_TIME_MS,
*/
staleTime: 5 * 60 * 1000,
/**
* Unused cache entries are removed after 10 minutes
*/
gcTime: 10 * 60 * 1000,
/** /**
* Don't refetch when window regains focus * Don't refetch when window regains focus
*/ */
@@ -31,15 +51,12 @@ export const queryClient = new QueryClient({
* Refetch on mount if data is stale * Refetch on mount if data is stale
*/ */
refetchOnMount: true, refetchOnMount: true,
retry: QUERY_RETRY_COUNT,
/** /**
* Retry failed requests up to 3 times * Exponential backoff: 1s, 2s, 4s, 8s... capped at 30s
*/ */
retry: 3, retryDelay: attemptIndex =>
/** Math.min(QUERY_RETRY_BASE_DELAY_MS * 2 ** attemptIndex, QUERY_RETRY_MAX_DELAY_MS),
* Exponential backoff for retries
* 1s, 2s, 4s, 8s... capped at 30s
*/
retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000),
}, },
}, },
}); });
@@ -4,6 +4,20 @@ import {
prepareWithSegments, prepareWithSegments,
} from '@chenglou/pretext'; } 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 AB 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. * A single laid-out line produced by dual-font comparison layout.
* *
@@ -129,7 +143,7 @@ export class CharacterComparisonEngine {
width: number, width: number,
lineHeight: number, lineHeight: number,
spacing: number = 0, spacing: number = 0,
size: number = 16, size: number = DEFAULT_RENDER_SIZE_PX,
): ComparisonResult { ): ComparisonResult {
if (!text) { if (!text) {
return { lines: [], totalHeight: 0 }; return { lines: [], totalHeight: 0 };
@@ -260,7 +274,7 @@ export class CharacterComparisonEngine {
const chars = line.chars; const chars = line.chars;
const n = chars.length; const n = chars.length;
const sliderX = (sliderPos / 100) * containerWidth; 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). // Prefix sums of widthA (left chars will be past → use widthA).
// Suffix sums of widthB (right chars will not be past → use widthB). // 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 // 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 totalRendered = chars.reduce((s, c, i) => s + (isPastArr[i] ? c.widthA : c.widthB), 0);
const xOffset = (containerWidth - totalRendered) / 2; const xOffset = (containerWidth - totalRendered) / 2;
let currentX = xOffset; let currentX = xOffset;
return chars.map((char, i) => { return chars.map((char, i) => {
const isPast = isPastArr[i] === 1; const isPast = isPastArr[i] === 1;
const charWidth = isPast ? char.widthA : char.widthB; const charWidth = isPast ? char.widthA : char.widthB;
@@ -70,6 +70,14 @@ export interface LayoutResult {
* **Canvas requirement:** pretext calls `document.createElement('canvas').getContext('2d')` on * **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 * 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. * (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 { export class TextLayoutEngine {
/** /**
@@ -1,5 +1,11 @@
import { debounce } from '$shared/lib/utils'; 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. * Creates reactive state with immediate and debounced values.
* *
@@ -23,7 +29,7 @@ import { debounce } from '$shared/lib/utils';
* <p>Searching: {search.debounced}</p> * <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 immediate = $state(initialValue);
let debounced = $state(initialValue); let debounced = $state(initialValue);
@@ -28,6 +28,19 @@
import { Spring } from 'svelte/motion'; 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 01 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 * Configuration options for perspective effects
*/ */
@@ -93,10 +106,7 @@ export class PerspectiveManager {
* Spring animation state * Spring animation state
* Animates between 0 (front) and 1 (back) with configurable physics * Animates between 0 (front) and 1 (back) with configurable physics
*/ */
spring = new Spring(0, { spring = new Spring(0, PERSPECTIVE_SPRING_CONFIG);
stiffness: 0.2,
damping: 0.8,
});
/** /**
* Reactive state: true when in back position * Reactive state: true when in back position
@@ -104,7 +114,7 @@ export class PerspectiveManager {
* Content should appear blurred, scaled down, and less interactive * Content should appear blurred, scaled down, and less interactive
* when this is true. Derived from spring value > 0.5. * 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 * Reactive state: true when in front position
@@ -112,7 +122,7 @@ export class PerspectiveManager {
* Content should be fully visible, sharp, and interactive * Content should be fully visible, sharp, and interactive
* when this is true. Derived from spring value < 0.5. * 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 * Internal configuration with defaults applied
@@ -4,6 +4,12 @@
* Used to render visible items with absolute positioning based on computed offsets. * 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 { export interface VirtualItem {
/** /**
* Index of the item in the data array * Index of the item in the data array
@@ -58,7 +64,7 @@ export interface VirtualizerOptions {
* when those values change, `offsets` and `totalSize` recompute instantly. * when those values change, `offsets` and `totalSize` recompute instantly.
* *
* For font preview rows, pass a closure that reads * For font preview rows, pass a closure that reads
* `appliedFontsManager.statuses` so the virtualizer recalculates heights * `fontLifecycleManager.statuses` so the virtualizer recalculates heights
* as fonts finish loading, eliminating the DOM-measurement snap on load. * as fonts finish loading, eliminating the DOM-measurement snap on load.
*/ */
estimateSize: (index: number) => number; estimateSize: (index: number) => number;
@@ -381,8 +387,8 @@ export function createVirtualizer<T>(
if (!isNaN(index)) { if (!isNaN(index)) {
const oldHeight = measuredSizes[index]; const oldHeight = measuredSizes[index];
// Only update if the height difference is significant (> 0.5px) // Only update if the height difference is significant
if (oldHeight === undefined || Math.abs(oldHeight - height) > 0.5) { if (oldHeight === undefined || Math.abs(oldHeight - height) > MEASUREMENT_EPSILON_PX) {
measurementBuffer[index] = height; measurementBuffer[index] = height;
} }
} }
+19 -3
View File
@@ -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[]) => { const throttledVisibleChange = throttle((visibleItems: T[]) => {
onVisibleItemsChange?.(visibleItems); onVisibleItemsChange?.(visibleItems);
}, 150); // 150ms throttle }, VISIBLE_CHANGE_THROTTLE_MS);
const throttledNearBottom = throttle((lastVisibleIndex: number) => { const throttledNearBottom = throttle((lastVisibleIndex: number) => {
onNearBottom?.(lastVisibleIndex); onNearBottom?.(lastVisibleIndex);
}, 200); // 200ms throttle }, NEAR_BOTTOM_THROTTLE_MS);
const throttledOnJump = throttle((targetIndex: number) => { const throttledOnJump = throttle((targetIndex: number) => {
onJump?.(targetIndex); onJump?.(targetIndex);
}, 200); }, JUMP_THROTTLE_MS);
// Calculate top/bottom padding for spacer elements // Calculate top/bottom padding for spacer elements
// In CSS Grid, gap creates space BETWEEN elements. // In CSS Grid, gap creates space BETWEEN elements.
@@ -7,21 +7,21 @@
* *
* Features: * Features:
* - Persistent font selection (survives page refresh) * - 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 * - Sample text management
* - Typography controls (size, weight, line height, spacing) * - Typography controls (size, weight, line height, spacing)
* - Slider position for character-by-character morphing * - Slider position for character-by-character morphing
*/ */
import { import {
BatchFontStore,
type FontLoadRequestConfig, type FontLoadRequestConfig,
type UnifiedFont, type UnifiedFont,
appliedFontsManager, fontCatalogStore,
fontStore, fontLifecycleManager,
getFontUrl, getFontUrl,
} from '$entities/Font'; } 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 { createPersistentStore } from '$shared/lib';
import { untrack } from 'svelte'; import { untrack } from 'svelte';
import { getPretextFontString } from '../../lib'; import { getPretextFontString } from '../../lib';
@@ -42,8 +42,17 @@ interface ComparisonState {
export type Side = 'A' | 'B'; 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 // Persistent storage for selected comparison fonts
const storage = createPersistentStore<ComparisonState>('glyphdiff:comparison', { const storage = createPersistentStore<ComparisonState>(STORAGE_KEY, {
fontAId: null, fontAId: null,
fontBId: null, fontBId: null,
}); });
@@ -51,7 +60,7 @@ const storage = createPersistentStore<ComparisonState>('glyphdiff:comparison', {
/** /**
* Store for managing font comparison state. * 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 * the previous hand-rolled async fetch approach. Three reactive effects
* handle: (1) syncing batch results into fontA/fontB, (2) triggering the * handle: (1) syncing batch results into fontA/fontB, (2) triggering the
* CSS Font Loading API, and (3) falling back to default fonts when * 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 * TanStack Query-backed store for efficient batch font retrieval
*/ */
#batchStore: BatchFontStore; #fontsByIdsStore: FontsByIdsStore;
constructor() { constructor() {
// Synchronously seed the batch store with any IDs already in storage // Synchronously seed the batch store with any IDs already in storage
const { fontAId, fontBId } = storage.value; const { fontAId, fontBId } = storage.value;
this.#batchStore = new BatchFontStore(fontAId && fontBId ? [fontAId, fontBId] : []); this.#fontsByIdsStore = new FontsByIdsStore(fontAId && fontBId ? [fontAId, fontBId] : []);
$effect.root(() => { $effect.root(() => {
// Effect 1: Sync batch results → fontA / fontB // Effect 1: Sync batch results → fontA / fontB
$effect(() => { $effect(() => {
const fonts = this.#batchStore.fonts; const fonts = this.#fontsByIdsStore.fonts;
if (fonts.length === 0) { if (fonts.length === 0) {
return; return;
} }
@@ -140,7 +149,7 @@ export class ComparisonStore {
}); });
if (configs.length > 0) { if (configs.length > 0) {
appliedFontsManager.touch(configs); fontLifecycleManager.touch(configs);
this.#checkFontsLoaded(); this.#checkFontsLoaded();
} }
}); });
@@ -151,13 +160,13 @@ export class ComparisonStore {
return; return;
} }
const fonts = fontStore.fonts; const fonts = fontCatalogStore.fonts;
if (fonts.length >= 2) { if (fonts.length >= 2) {
untrack(() => { untrack(() => {
const id1 = fonts[0].id; const id1 = fonts[0].id;
const id2 = fonts[fonts.length - 1].id; const id2 = fonts[fonts.length - 1].id;
storage.value = { fontAId: id1, fontBId: id2 }; 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 fb = this.#fontB;
const w = typographySettingsStore.weight; const w = typographySettingsStore.weight;
if (fa) { if (fa) {
appliedFontsManager.pin(fa.id, w, fa.features?.isVariable); fontLifecycleManager.pin(fa.id, w, fa.features?.isVariable);
} }
if (fb) { if (fb) {
appliedFontsManager.pin(fb.id, w, fb.features?.isVariable); fontLifecycleManager.pin(fb.id, w, fb.features?.isVariable);
} }
return () => { return () => {
if (fa) { if (fa) {
appliedFontsManager.unpin(fa.id, w, fa.features?.isVariable); fontLifecycleManager.unpin(fa.id, w, fa.features?.isVariable);
} }
if (fb) { if (fb) {
appliedFontsManager.unpin(fb.id, w, fb.features?.isVariable); fontLifecycleManager.unpin(fb.id, w, fb.features?.isVariable);
} }
}; };
}); });
@@ -234,7 +243,7 @@ export class ComparisonStore {
this.#fontsReady = true; this.#fontsReady = true;
} catch (error) { } catch (error) {
console.warn('[ComparisonStore] Font loading failed:', error); console.warn('[ComparisonStore] Font loading failed:', error);
setTimeout(() => (this.#fontsReady = true), 1000); setTimeout(() => (this.#fontsReady = true), FONT_READY_FALLBACK_MS);
} }
} }
@@ -316,7 +325,7 @@ export class ComparisonStore {
* True if any font is currently being fetched or loaded (reactive) * True if any font is currently being fetched or loaded (reactive)
*/ */
get isLoading() { get isLoading() {
return this.#batchStore.isLoading || !this.#fontsReady; return this.#fontsByIdsStore.isLoading || !this.#fontsReady;
} }
/** /**
@@ -325,7 +334,7 @@ export class ComparisonStore {
resetAll() { resetAll() {
this.#fontA = undefined; this.#fontA = undefined;
this.#fontB = undefined; this.#fontB = undefined;
this.#batchStore.setIds([]); this.#fontsByIdsStore.setIds([]);
storage.clear(); storage.clear();
typographySettingsStore.reset(); typographySettingsStore.reset();
} }
@@ -1,7 +1,7 @@
/** /**
* Unit tests for ComparisonStore (TanStack Query refactor) * 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. * 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 => { vi.mock('$entities/Font', async importOriginal => {
const actual = await importOriginal<typeof import('$entities/Font')>(); const actual = await importOriginal<typeof import('$entities/Font')>();
const { BatchFontStore } = await import(
'$entities/Font/model/store/batchFontStore/batchFontStore.svelte'
);
return { return {
...actual, ...actual,
BatchFontStore, fontCatalogStore: { fonts: [] },
fontStore: { fonts: [] }, fontLifecycleManager: {
appliedFontsManager: {
touch: vi.fn(), touch: vi.fn(),
pin: vi.fn(), pin: vi.fn(),
unpin: 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: [], DEFAULT_TYPOGRAPHY_CONTROLS_DATA: [],
createTypographyControlManager: vi.fn(() => ({ createTypographyControlManager: vi.fn(() => ({
weight: 400, weight: 400,
@@ -80,7 +76,7 @@ vi.mock('$features/SetupFont', () => ({
})), })),
})); }));
vi.mock('$features/SetupFont/model', () => ({ vi.mock('$features/AdjustTypography/model', () => ({
typographySettingsStore: { typographySettingsStore: {
weight: 400, weight: 400,
renderedSize: 48, renderedSize: 48,
@@ -89,8 +85,8 @@ vi.mock('$features/SetupFont/model', () => ({
})); }));
import { import {
appliedFontsManager, fontCatalogStore,
fontStore, fontLifecycleManager,
} from '$entities/Font'; } from '$entities/Font';
import * as proxyFonts from '$entities/Font/api/proxy/proxyFonts'; import * as proxyFonts from '$entities/Font/api/proxy/proxyFonts';
import { ComparisonStore } from './comparisonStore.svelte'; import { ComparisonStore } from './comparisonStore.svelte';
@@ -104,7 +100,7 @@ describe('ComparisonStore', () => {
vi.clearAllMocks(); vi.clearAllMocks();
mockStorage._value = { fontAId: null, fontBId: null }; mockStorage._value = { fontAId: null, fontBId: null };
mockStorage._clear.mockClear(); mockStorage._clear.mockClear();
(fontStore as any).fonts = []; (fontCatalogStore as any).fonts = [];
// Default: fetchFontsByIds returns empty so tests that don't care don't hang // Default: fetchFontsByIds returns empty so tests that don't care don't hang
vi.spyOn(proxyFonts, 'fetchFontsByIds').mockResolvedValue([]); vi.spyOn(proxyFonts, 'fetchFontsByIds').mockResolvedValue([]);
@@ -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 () => { it('should restore fontA and fontB from stored IDs', async () => {
mockStorage._value.fontAId = mockFontA.id; mockStorage._value.fontAId = mockFontA.id;
mockStorage._value.fontBId = mockFontB.id; mockStorage._value.fontBId = mockFontB.id;
@@ -159,7 +155,7 @@ describe('ComparisonStore', () => {
describe('Default Fallbacks', () => { describe('Default Fallbacks', () => {
it('should update storage with default IDs when storage is empty', async () => { it('should update storage with default IDs when storage is empty', async () => {
(fontStore as any).fonts = [mockFontA, mockFontB]; (fontCatalogStore as any).fonts = [mockFontA, mockFontB];
vi.spyOn(proxyFonts, 'fetchFontsByIds').mockResolvedValue([mockFontA, mockFontB]); vi.spyOn(proxyFonts, 'fetchFontsByIds').mockResolvedValue([mockFontA, mockFontB]);
new ComparisonStore(); new ComparisonStore();
@@ -216,12 +212,12 @@ describe('ComparisonStore', () => {
new ComparisonStore(); new ComparisonStore();
await vi.waitFor(() => { await vi.waitFor(() => {
expect(appliedFontsManager.pin).toHaveBeenCalledWith( expect(fontLifecycleManager.pin).toHaveBeenCalledWith(
mockFontA.id, mockFontA.id,
400, 400,
mockFontA.features?.isVariable, mockFontA.features?.isVariable,
); );
expect(appliedFontsManager.pin).toHaveBeenCalledWith( expect(fontLifecycleManager.pin).toHaveBeenCalledWith(
mockFontB.id, mockFontB.id,
400, 400,
mockFontB.features?.isVariable, mockFontB.features?.isVariable,
@@ -242,12 +238,12 @@ describe('ComparisonStore', () => {
store.fontA = mockFontC; store.fontA = mockFontC;
await vi.waitFor(() => { await vi.waitFor(() => {
expect(appliedFontsManager.unpin).toHaveBeenCalledWith( expect(fontLifecycleManager.unpin).toHaveBeenCalledWith(
mockFontA.id, mockFontA.id,
400, 400,
mockFontA.features?.isVariable, mockFontA.features?.isVariable,
); );
expect(appliedFontsManager.pin).toHaveBeenCalledWith( expect(fontLifecycleManager.pin).toHaveBeenCalledWith(
mockFontC.id, mockFontC.id,
400, 400,
mockFontC.features?.isVariable, mockFontC.features?.isVariable,
@@ -3,7 +3,7 @@
Renders a single character with morphing animation Renders a single character with morphing animation
--> -->
<script lang="ts"> <script lang="ts">
import { typographySettingsStore } from '$features/SetupFont'; import { typographySettingsStore } from '$features/AdjustTypography';
import { cn } from '$shared/lib'; import { cn } from '$shared/lib';
import { comparisonStore } from '../../model'; import { comparisonStore } from '../../model';
@@ -9,8 +9,8 @@ import {
FontVirtualList, FontVirtualList,
type UnifiedFont, type UnifiedFont,
VIRTUAL_INDEX_NOT_LOADED, VIRTUAL_INDEX_NOT_LOADED,
appliedFontsManager, fontCatalogStore,
fontStore, fontLifecycleManager,
} from '$entities/Font'; } from '$entities/Font';
import { getSkeletonWidth } from '$shared/lib/utils'; import { getSkeletonWidth } from '$shared/lib/utils';
import { import {
@@ -36,7 +36,7 @@ function getVirtualIndex(fontId: string | undefined): number {
if (!fontId) { if (!fontId) {
return -1; return -1;
} }
const idx = fontStore.fonts.findIndex(f => f.id === fontId); const idx = fontCatalogStore.fonts.findIndex(f => f.id === fontId);
if (idx === -1) { if (idx === -1) {
return VIRTUAL_INDEX_NOT_LOADED; return VIRTUAL_INDEX_NOT_LOADED;
} }
@@ -77,11 +77,11 @@ function handleSelect(font: UnifiedFont) {
/** /**
* Returns true once the font file is loaded (or errored) and safe to render. * Returns true once the font file is loaded (or errored) and safe to render.
* Called inside the template — Svelte 5 tracks the $state reads inside * Called inside the template — Svelte 5 tracks the $state reads inside
* appliedFontsManager.getFontStatus(), so each row re-renders reactively * fontLifecycleManager.getFontStatus(), so each row re-renders reactively
* when its file arrives. * when its file arrives.
*/ */
function isFontReady(font: UnifiedFont): boolean { function isFontReady(font: UnifiedFont): boolean {
const status = appliedFontsManager.getFontStatus( const status = fontLifecycleManager.getFontStatus(
font.id, font.id,
DEFAULT_FONT_WEIGHT, DEFAULT_FONT_WEIGHT,
font.features?.isVariable, font.features?.isVariable,
@@ -3,7 +3,7 @@
Renders a line of text in the SliderArea Renders a line of text in the SliderArea
--> -->
<script lang="ts"> <script lang="ts">
import { typographySettingsStore } from '$features/SetupFont'; import { typographySettingsStore } from '$features/AdjustTypography';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
interface LineChar { interface LineChar {
@@ -1,11 +1,11 @@
<!-- <!--
Component: Search Component: Search
Typeface search input for the comparison view. Typeface search input for the comparison view.
Writes through appliedFilterStore; the global bridge in $features/GetFonts Writes through appliedFilterStore; the global bridge in $features/FilterAndSortFonts
propagates the value into fontStore. propagates the value into fontCatalogStore.
--> -->
<script lang="ts"> <script lang="ts">
import { appliedFilterStore } from '$features/GetFonts'; import { appliedFilterStore } from '$features/FilterAndSortFonts';
import { SearchBar } from '$shared/ui'; import { SearchBar } from '$shared/ui';
</script> </script>
@@ -1,4 +1,4 @@
import { appliedFilterStore } from '$features/GetFonts'; import { appliedFilterStore } from '$features/FilterAndSortFonts';
import { import {
render, render,
screen, screen,
@@ -8,8 +8,13 @@
- Performance optimized using offscreen canvas for measurements and transform-based animations. - Performance optimized using offscreen canvas for measurements and transform-based animations.
--> -->
<script lang="ts"> <script lang="ts">
import { TypographyMenu } from '$features/SetupFont'; import {
import { typographySettingsStore } from '$features/SetupFont/model'; MULTIPLIER_L,
MULTIPLIER_M,
MULTIPLIER_S,
} from '$entities/Font';
import { TypographyMenu } from '$features/AdjustTypography';
import { typographySettingsStore } from '$features/AdjustTypography/model';
import { import {
type ResponsiveManager, type ResponsiveManager,
debounce, debounce,
@@ -45,6 +50,26 @@ interface Props {
let { isSidebarOpen = false, class: className }: Props = $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 fontA = $derived(comparisonStore.fontA);
const fontB = $derived(comparisonStore.fontB); const fontB = $derived(comparisonStore.fontB);
const isLoading = $derived(comparisonStore.isLoading || !comparisonStore.isReady); const isLoading = $derived(comparisonStore.isLoading || !comparisonStore.isReady);
@@ -84,10 +109,7 @@ $effect(() => {
return () => observer.disconnect(); return () => observer.disconnect();
}); });
const sliderSpring = new Spring(50, { const sliderSpring = new Spring(50, SLIDER_SPRING_CONFIG);
stiffness: 0.2,
damping: 0.7,
});
const sliderPos = $derived(sliderSpring.current); const sliderPos = $derived(sliderSpring.current);
function handleMove(e: PointerEvent) { function handleMove(e: PointerEvent) {
@@ -110,7 +132,7 @@ function startDragging(e: PointerEvent) {
const storeSliderPosition = debounce((value: number) => { const storeSliderPosition = debounce((value: number) => {
comparisonStore.sliderPosition = value; comparisonStore.sliderPosition = value;
}, 100); }, SLIDER_PERSIST_DEBOUNCE_MS);
$effect(() => { $effect(() => {
storeSliderPosition(sliderPos); storeSliderPosition(sliderPos);
@@ -122,16 +144,16 @@ $effect(() => {
} }
switch (true) { switch (true) {
case responsive.isMobile: case responsive.isMobile:
typography.multiplier = 0.5; typography.multiplier = MULTIPLIER_S;
break; break;
case responsive.isTablet: case responsive.isTablet:
typography.multiplier = 0.75; typography.multiplier = MULTIPLIER_M;
break; break;
case responsive.isDesktop: case responsive.isDesktop:
typography.multiplier = 1; typography.multiplier = MULTIPLIER_L;
break; break;
default: default:
typography.multiplier = 1; typography.multiplier = MULTIPLIER_L;
} }
}); });
@@ -167,7 +189,7 @@ $effect(() => {
const fontAStr = getPretextFontString(_weight, _size, fontA.name); const fontAStr = getPretextFontString(_weight, _size, fontA.name);
const fontBStr = getPretextFontString(_weight, _size, fontB.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 availableWidth = Math.max(0, _width - padding);
const lineHeight = _size * _height; const lineHeight = _size * _height;
@@ -7,7 +7,7 @@ import {
FilterControls, FilterControls,
Filters, Filters,
appliedFilterStore, appliedFilterStore,
} from '$features/GetFonts'; } from '$features/FilterAndSortFonts';
import { springySlideFade } from '$shared/lib'; import { springySlideFade } from '$shared/lib';
import { import {
Button, Button,
@@ -28,7 +28,7 @@ interface LayoutConfig {
mode: LayoutMode; mode: LayoutMode;
} }
const STORAGE_KEY = 'glyphdiff:sample-list-layout'; export const STORAGE_KEY = 'glyphdiff:sample-list-layout';
const SM_GAP_PX = 16; const SM_GAP_PX = 16;
const MD_GAP_PX = 24; const MD_GAP_PX = 24;
@@ -16,8 +16,7 @@ async function flushEffects() {
await Promise.resolve(); await Promise.resolve();
} }
// Storage key used by LayoutManager import { STORAGE_KEY } from './layoutStore.svelte';
const STORAGE_KEY = 'glyphdiff:sample-list-layout';
describe('layoutStore', () => { describe('layoutStore', () => {
// Default viewport for most tests (desktop large - >= 1536px) // Default viewport for most tests (desktop large - >= 1536px)
@@ -7,15 +7,15 @@
<script lang="ts"> <script lang="ts">
import { import {
FontVirtualList, FontVirtualList,
appliedFontsManager,
createFontRowSizeResolver, createFontRowSizeResolver,
fontStore, fontCatalogStore,
fontLifecycleManager,
} from '$entities/Font'; } from '$entities/Font';
import { FontSampler } from '$features/DisplayFont';
import { import {
TypographyMenu, TypographyMenu,
typographySettingsStore, typographySettingsStore,
} from '$features/SetupFont'; } from '$features/AdjustTypography';
import { FontSampler } from '$features/DisplayFont';
import { throttle } from '$shared/lib/utils'; import { throttle } from '$shared/lib/utils';
import { Skeleton } from '$shared/ui'; import { Skeleton } from '$shared/ui';
import { layoutManager } from '../../model'; import { layoutManager } from '../../model';
@@ -36,6 +36,12 @@ const SAMPLER_CONTENT_PADDING_X = 32;
// Matches the previous hardcoded itemHeight={220} value to avoid regressions. // Matches the previous hardcoded itemHeight={220} value to avoid regressions.
const SAMPLER_FALLBACK_HEIGHT = 220; 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 text = $state('The quick brown fox jumps over the lazy dog...');
let wrapper = $state<HTMLDivElement | null>(null); let wrapper = $state<HTMLDivElement | null>(null);
// Binds to the actual window height // Binds to the actual window height
@@ -54,20 +60,20 @@ const checkPosition = throttle(() => {
const viewportMiddle = innerHeight / 2; const viewportMiddle = innerHeight / 2;
isAboveMiddle = rect.top < viewportMiddle; isAboveMiddle = rect.top < viewportMiddle;
}, 100); }, CHECK_POSITION_THROTTLE_MS);
// Resolver recreated when typography values change. The returned closure reads // Resolver recreated when typography values change. The returned closure reads
// appliedFontsManager.statuses (a SvelteMap) on every call, so any font status // fontLifecycleManager.statuses (a SvelteMap) on every call, so any font status
// change triggers a full offsets recompute in createVirtualizer — no DOM snap. // change triggers a full offsets recompute in createVirtualizer — no DOM snap.
const fontRowHeight = $derived.by(() => const fontRowHeight = $derived.by(() =>
createFontRowSizeResolver({ createFontRowSizeResolver({
getFonts: () => fontStore.fonts, getFonts: () => fontCatalogStore.fonts,
getWeight: () => typographySettingsStore.weight, getWeight: () => typographySettingsStore.weight,
getPreviewText: () => text, getPreviewText: () => text,
getContainerWidth: () => containerWidth, getContainerWidth: () => containerWidth,
getFontSizePx: () => typographySettingsStore.renderedSize, getFontSizePx: () => typographySettingsStore.renderedSize,
getLineHeightPx: () => typographySettingsStore.height * typographySettingsStore.renderedSize, getLineHeightPx: () => typographySettingsStore.height * typographySettingsStore.renderedSize,
getStatus: key => appliedFontsManager.statuses.get(key), getStatus: key => fontLifecycleManager.statuses.get(key),
contentHorizontalPadding: SAMPLER_CONTENT_PADDING_X, contentHorizontalPadding: SAMPLER_CONTENT_PADDING_X,
chromeHeight: SAMPLER_CHROME_HEIGHT, chromeHeight: SAMPLER_CHROME_HEIGHT,
fallbackHeight: SAMPLER_FALLBACK_HEIGHT, fallbackHeight: SAMPLER_FALLBACK_HEIGHT,
@@ -4,7 +4,7 @@
--> -->
<script lang="ts"> <script lang="ts">
import { NavigationWrapper } from '$entities/Breadcrumb'; import { NavigationWrapper } from '$entities/Breadcrumb';
import { fontStore } from '$entities/Font'; import { fontCatalogStore } from '$entities/Font';
import type { ResponsiveManager } from '$shared/lib'; import type { ResponsiveManager } from '$shared/lib';
import { cn } from '$shared/lib'; import { cn } from '$shared/lib';
import { import {
@@ -36,7 +36,7 @@ const responsive = getContext<ResponsiveManager>('responsive');
id="sample_set" id="sample_set"
title="Sample Set" title="Sample Set"
headerTitle="visual_output" headerTitle="visual_output"
headerSubtitle="items_total: {fontStore.pagination.total ?? 0}" headerSubtitle="items_total: {fontCatalogStore.pagination.total ?? 0}"
headerAction={registerAction} headerAction={registerAction}
> >
{#snippet headerContent()} {#snippet headerContent()}