diff --git a/src/app/styles/app.css b/src/app/styles/app.css
index b00d02a..909b793 100644
--- a/src/app/styles/app.css
+++ b/src/app/styles/app.css
@@ -14,6 +14,13 @@
--swiss-black: #1a1a1a;
--swiss-white: #ffffff;
+ /* Semantic mode-switching colors. These are redefined inside `.dark`
+ so utilities that reference them auto-adapt without a `dark:` variant. */
+ --color-border-subtle: var(--neutral-300);
+ --color-text-subtle: var(--neutral-500);
+ --color-skeleton: var(--neutral-200);
+ --color-grid-line: rgb(0 0 0 / 0.03);
+
/* Neutral Grays */
--neutral-50: #fafafa;
--neutral-100: #f5f5f5;
@@ -80,16 +87,6 @@
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
- /* Spacing Scale (rem-based) */
- --space-xs: 0.25rem;
- --space-sm: 0.5rem;
- --space-md: 0.75rem;
- --space-lg: 1rem;
- --space-xl: 1.5rem;
- --space-2xl: 2rem;
- --space-3xl: 3rem;
- --space-4xl: 4rem;
-
/* Typography Scale */
--text-xs: 0.75rem;
--text-sm: 0.875rem;
@@ -114,6 +111,12 @@
--color-surface: var(--dark-bg);
--color-paper: var(--dark-card);
+ /* Dark-mode overrides for the semantic mode-switching colors. */
+ --color-border-subtle: rgb(255 255 255 / 0.1);
+ --color-text-subtle: var(--neutral-400);
+ --color-skeleton: var(--neutral-800);
+ --color-grid-line: rgb(255 255 255 / 0.05);
+
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.145 0 0);
@@ -212,6 +215,51 @@
--text-2xs: 0.625rem;
/* Monospace label tracking — used in Loader and Footnote */
--tracking-wider-mono: 0.2em;
+
+ /* ============================================
+ SHADOW TOKENS
+ ============================================ */
+
+ /* Default resting shadow — equivalent to Tailwind's shadow-sm. Used on
+ buttons, sliders, popover triggers in non-floating state. */
+ --shadow-rest: 0 1px 2px 0 rgb(0 0 0 / 0.05);
+
+ /* Swiss "hard offset" stamp — rests at 2px/2px, lifts to 3px/3px on
+ hover, presses back to 1px/1px on active. Primary button motif. */
+ --shadow-stamp-rest: 0.125rem 0.125rem 0 0 rgb(0 0 0 / 0.1);
+ --shadow-stamp-hover: 0.1875rem 0.1875rem 0 0 rgb(0 0 0 / 0.15);
+ --shadow-stamp-pressed: 0.0625rem 0.0625rem 0 0 rgb(0 0 0 / 0.1);
+
+ /* Card-tier hard-offset stamp — wider, brand-tinted. Used on
+ interactive cards (FontSampler hover). */
+ --shadow-stamp-card: 5px 5px 0 0 var(--color-brand);
+
+ /* Floating popovers (typography menu, combo control list). */
+ --shadow-popover: 0 20px 40px -10px rgb(0 0 0 / 0.15);
+
+ /* Drop-shadow under semi-translucent floating panels like the
+ comparison slider's character row. */
+ --shadow-floating-panel: 0 25px 50px -12px rgb(0 0 0 / 0.05);
+ --shadow-floating-panel-dark: 0 25px 50px -12px rgb(0 0 0 / 0.2);
+
+ /* Drawer / overlay shadow — full-strength shadow-2xl. */
+ --shadow-overlay: 0 25px 50px -12px rgb(0 0 0 / 0.25);
+
+ /* ============================================
+ MOTION TOKENS
+ ============================================ */
+
+ --duration-fast: 150ms;
+ --duration-normal: 200ms;
+ --duration-slow: 300ms;
+ --duration-slower: 500ms;
+
+ /* Tailwind's default ease-in-out — symmetric, good for layout shifts. */
+ --ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
+ /* Decelerating curve — matches Tailwind's ease-out. Dominant in this codebase. */
+ --ease-out-soft: cubic-bezier(0, 0, 0.2, 1);
+ /* Spring overshoot — used in character pop animation. */
+ --ease-spring-overshoot: cubic-bezier(0.34, 1.56, 0.64, 1);
}
@layer base {
@@ -277,21 +325,112 @@
}
}
-@layer utilities {
- /* 21× border-black/5 dark:border-white/10 → single token */
- .border-subtle {
- @apply border-black/5 dark:border-white/10;
- }
- /* Secondary text pair */
- .text-secondary {
- @apply text-neutral-500 dark:text-neutral-400;
- }
- /* Standard focus ring */
- .focus-ring {
- @apply focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2;
+/* ============================================
+ DESIGN-SYSTEM UTILITIES
+ ============================================
+ Defined via `@utility` (Tailwind v4) so they integrate with the variant
+ system (`hover:`, `dark:`, breakpoints) and don't rely on `@apply`
+ chains. Colors reference the mode-switching semantic vars defined in
+ `:root`/`.dark` above, so most utilities need no `dark:` variant in
+ their definition or at call sites. */
+
+@utility border-subtle {
+ border-color: var(--color-border-subtle);
+}
+
+/* Same color as border-subtle, applied via background-color — for 1px
+ dividers, inline separator strips, and other hairlines that aren't
+ element borders. */
+@utility bg-subtle {
+ background-color: var(--color-border-subtle);
+}
+
+/* Muted text color — paired with `border-subtle` naming. The previous
+ name `text-secondary` collided with Tailwind v4 auto-generating a
+ utility from `--color-secondary` (the shadcn near-white surface token
+ registered in `@theme`), which made every consumer effectively
+ invisible (near-white text on light backgrounds). */
+@utility text-subtle {
+ color: var(--color-text-subtle);
+}
+
+@utility focus-ring {
+ &:focus-visible {
+ outline: 2px solid transparent;
+ outline-offset: 2px;
+ box-shadow: 0 0 0 2px var(--color-background, white), 0 0 0 4px var(--color-brand);
}
}
+/* ── Surface utilities ────────────────────────────────────────── */
+
+@utility surface-canvas {
+ background-color: var(--color-surface);
+}
+
+@utility surface-card {
+ background-color: var(--color-paper);
+ border: 1px solid var(--color-border-subtle);
+}
+
+@utility surface-card-elevated {
+ background-color: var(--color-paper);
+ border: 1px solid var(--color-border-subtle);
+ box-shadow: var(--shadow-rest);
+}
+
+@utility surface-popover {
+ background-color: var(--color-paper);
+ border: 1px solid var(--color-border-subtle);
+ box-shadow: var(--shadow-popover);
+}
+
+@utility surface-floating {
+ background-color: color-mix(in srgb, var(--color-surface) 80%, transparent);
+ backdrop-filter: blur(12px);
+ border: 1px solid var(--color-border-subtle);
+}
+
+/* ── Shape / layout ───────────────────────────────────────────── */
+
+@utility flex-center {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+@utility skeleton-fill {
+ background-color: color-mix(in srgb, var(--color-skeleton) 70%, transparent);
+}
+
+/* Subtle dotted-grid overlay used as a decorative background on the
+ comparison paper surface. Color and intensity auto-switch via
+ --color-grid-line. `bg-grid-sm` uses a tighter cell — typical mobile
+ choice; `bg-grid` is the default desktop cell. Pair with absolute /
+ pointer-events-none on the overlay element. */
+@utility bg-grid {
+ background-image:
+ linear-gradient(var(--color-grid-line) 1px, transparent 1px),
+ linear-gradient(90deg, var(--color-grid-line) 1px, transparent 1px);
+ background-size: 20px 20px;
+}
+
+@utility bg-grid-sm {
+ background-image:
+ linear-gradient(var(--color-grid-line) 1px, transparent 1px),
+ linear-gradient(90deg, var(--color-grid-line) 1px, transparent 1px);
+ background-size: 10px 10px;
+}
+
+/* ── Typography ───────────────────────────────────────────────── */
+
+@utility text-label-mono {
+ font-family: var(--font-primary);
+ font-weight: 700;
+ letter-spacing: -0.025em;
+ text-transform: uppercase;
+}
+
/* Global utility - useful across your app */
@media (prefers-reduced-motion: reduce) {
* {
diff --git a/src/app/ui/Layout.svelte b/src/app/ui/Layout.svelte
index bee32cc..42a85c7 100644
--- a/src/app/ui/Layout.svelte
+++ b/src/app/ui/Layout.svelte
@@ -74,7 +74,7 @@ onDestroy(() => themeManager.destroy());
diff --git a/src/entities/Breadcrumb/ui/BreadcrumbHeader/BreadcrumbHeader.svelte b/src/entities/Breadcrumb/ui/BreadcrumbHeader/BreadcrumbHeader.svelte
index f419995..e48dcf9 100644
--- a/src/entities/Breadcrumb/ui/BreadcrumbHeader/BreadcrumbHeader.svelte
+++ b/src/entities/Breadcrumb/ui/BreadcrumbHeader/BreadcrumbHeader.svelte
@@ -43,8 +43,8 @@ function createButtonText(item: BreadcrumbItem) {
md:h-16 px-4 md:px-6 lg:px-8
flex items-center justify-between
z-40
- bg-surface/90 dark:bg-dark-bg/90 backdrop-blur-md
- border-b border-subtle
+ surface-floating bg-surface/90 dark:bg-dark-bg/90
+ border-x-0 border-t-0
"
>
diff --git a/src/entities/Font/api/index.ts b/src/entities/Font/api/index.ts
index 0a1dde1..aad2be4 100644
--- a/src/entities/Font/api/index.ts
+++ b/src/entities/Font/api/index.ts
@@ -9,6 +9,7 @@ export {
fetchFontsByIds,
fetchProxyFontById,
fetchProxyFonts,
+ seedFontCache,
} from './proxy/proxyFonts';
export type {
ProxyFontsParams,
diff --git a/src/entities/Font/api/proxy/proxyFonts.ts b/src/entities/Font/api/proxy/proxyFonts.ts
index 3fdccbe..de299dc 100644
--- a/src/entities/Font/api/proxy/proxyFonts.ts
+++ b/src/entities/Font/api/proxy/proxyFonts.ts
@@ -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
diff --git a/src/entities/Font/lib/mocks/stores.mock.ts b/src/entities/Font/lib/mocks/stores.mock.ts
index fd54060..030b581 100644
--- a/src/entities/Font/lib/mocks/stores.mock.ts
+++ b/src/entities/Font/lib/mocks/stores.mock.ts
@@ -667,10 +667,10 @@ export const MOCK_STORES = {
};
},
/**
- * Create a mock FontStore object
- * Matches FontStore's public API for Storybook use
+ * Create a mock FontCatalogStore object
+ * Matches FontCatalogStore's public API for Storybook use
*/
- fontStore: (config: {
+ fontCatalogStore: (config: {
/**
* Preset font list
*/
diff --git a/src/entities/Font/lib/sizeResolver/createFontRowSizeResolver.test.ts b/src/entities/Font/lib/sizeResolver/createFontRowSizeResolver.test.ts
index 33525d6..a274b40 100644
--- a/src/entities/Font/lib/sizeResolver/createFontRowSizeResolver.test.ts
+++ b/src/entities/Font/lib/sizeResolver/createFontRowSizeResolver.test.ts
@@ -1,7 +1,19 @@
// @vitest-environment jsdom
-import { TextLayoutEngine } from '$shared/lib';
import { installCanvasMock } from '$shared/lib/helpers/__mocks__/canvas';
-import { clearCache } from '@chenglou/pretext';
+import {
+ clearCache,
+ layout,
+} from '@chenglou/pretext';
+
+// Wrap pretext's `layout` in a spy-able mock so tests can assert call counts.
+// `vi.mock` is hoisted, so the import above receives the mocked module.
+vi.mock('@chenglou/pretext', async () => {
+ const actual = await vi.importActual
('@chenglou/pretext');
+ return {
+ ...actual,
+ layout: vi.fn(actual.layout),
+ };
+});
import {
beforeEach,
describe,
@@ -112,13 +124,13 @@ describe('createFontRowSizeResolver', () => {
const { resolver } = makeResolver();
statusMap.set('inter@400', 'loaded');
- const layoutSpy = vi.spyOn(TextLayoutEngine.prototype, 'layout');
+ const layoutSpy = vi.mocked(layout);
+ layoutSpy.mockClear();
resolver(0);
resolver(0);
expect(layoutSpy).toHaveBeenCalledTimes(1);
- layoutSpy.mockRestore();
});
it('calls layout() again when containerWidth changes (cache miss)', () => {
@@ -126,14 +138,14 @@ describe('createFontRowSizeResolver', () => {
const { resolver } = makeResolver({ getContainerWidth: () => width });
statusMap.set('inter@400', 'loaded');
- const layoutSpy = vi.spyOn(TextLayoutEngine.prototype, 'layout');
+ const layoutSpy = vi.mocked(layout);
+ layoutSpy.mockClear();
resolver(0);
width = 100;
resolver(0);
expect(layoutSpy).toHaveBeenCalledTimes(2);
- layoutSpy.mockRestore();
});
it('returns greater height when container narrows (more wrapping)', () => {
diff --git a/src/entities/Font/lib/sizeResolver/createFontRowSizeResolver.ts b/src/entities/Font/lib/sizeResolver/createFontRowSizeResolver.ts
index 29afcf5..97e089c 100644
--- a/src/entities/Font/lib/sizeResolver/createFontRowSizeResolver.ts
+++ b/src/entities/Font/lib/sizeResolver/createFontRowSizeResolver.ts
@@ -1,5 +1,8 @@
-import { TextLayoutEngine } from '$shared/lib';
-import { generateFontKey } from '../../model/store/appliedFontsStore/utils/generateFontKey/generateFontKey';
+import {
+ layout,
+ prepare,
+} from '@chenglou/pretext';
+import { generateFontKey } from '../../model/store/fontLifecycleManager/utils/generateFontKey/generateFontKey';
import type {
FontLoadStatus,
UnifiedFont,
@@ -41,7 +44,7 @@ export interface FontRowSizeResolverOptions {
/**
* Returns the font load status for a given font key (`'{id}@{weight}'` or `'{id}@vf'`).
*
- * In production: `(key) => appliedFontsManager.statuses.get(key)`.
+ * In production: `(key) => fontLifecycleManager.statuses.get(key)`.
* Injected for testability — avoids a module-level singleton dependency in tests.
* The call to `.get()` on a `SvelteMap` must happen inside a `$derived.by` context
* for reactivity to work. This is satisfied when `itemHeight` is called by
@@ -79,14 +82,13 @@ export interface FontRowSizeResolverOptions {
* no DOM snap occurs.
*
* **Caching:** A `Map` keyed by `fontCssString|text|contentWidth|lineHeightPx`
- * prevents redundant `TextLayoutEngine.layout()` calls. The cache is invalidated
+ * prevents redundant `pretext.layout()` calls. The cache is invalidated
* naturally because a change in any input produces a different cache key.
*
* @param options - Configuration and getter functions (all injected for testability).
* @returns A function `(rowIndex: number) => number` for use as `VirtualList.itemHeight`.
*/
export function createFontRowSizeResolver(options: FontRowSizeResolverOptions): (rowIndex: number) => number {
- const engine = new TextLayoutEngine();
// Key: `${fontCssString}|${text}|${contentWidth}|${lineHeightPx}`
const cache = new Map();
@@ -108,7 +110,7 @@ export function createFontRowSizeResolver(options: FontRowSizeResolverOptions):
// generateFontKey: '{id}@{weight}' for static fonts, '{id}@vf' for variable fonts.
const fontKey = generateFontKey({ id: font.id, weight, isVariable: font.features?.isVariable });
- // Reading via getStatus() allows the caller to pass appliedFontsManager.statuses.get(),
+ // Reading via getStatus() allows the caller to pass fontLifecycleManager.statuses.get(),
// which creates a Svelte 5 reactive dependency when called inside $derived.by.
const status = options.getStatus(fontKey);
if (status !== 'loaded') {
@@ -126,7 +128,11 @@ export function createFontRowSizeResolver(options: FontRowSizeResolverOptions):
return cached;
}
- const { totalHeight } = engine.layout(previewText, fontCssString, contentWidth, lineHeightPx);
+ // Pretext docs recommend `layout()` (not `layoutWithLines`) for the
+ // resize hot path — pure arithmetic on cached segment widths, no canvas
+ // calls, no string allocations.
+ const prepared = prepare(previewText, fontCssString);
+ const { height: totalHeight } = layout(prepared, contentWidth, lineHeightPx);
const result = totalHeight + options.chromeHeight;
cache.set(cacheKey, result);
return result;
diff --git a/src/entities/Font/model/store/fontStore/fontStore.svelte.spec.ts b/src/entities/Font/model/store/fontCatalogStore/fontCatalogStore.svelte.spec.ts
similarity index 96%
rename from src/entities/Font/model/store/fontStore/fontStore.svelte.spec.ts
rename to src/entities/Font/model/store/fontCatalogStore/fontCatalogStore.svelte.spec.ts
index d2e058c..f287091 100644
--- a/src/entities/Font/model/store/fontStore/fontStore.svelte.spec.ts
+++ b/src/entities/Font/model/store/fontCatalogStore/fontCatalogStore.svelte.spec.ts
@@ -17,13 +17,17 @@ import {
generateMockFonts,
} from '../../../lib/mocks/fonts.mock';
import type { UnifiedFont } from '../../types';
-import { FontStore } from './fontStore.svelte';
+import { FontCatalogStore } from './fontCatalogStore.svelte';
-vi.mock('$shared/api/queryClient', () => ({
- queryClient: new QueryClient({
- defaultOptions: { queries: { retry: 0, gcTime: 0 } },
- }),
-}));
+vi.mock('$shared/api/queryClient', async importOriginal => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ queryClient: new QueryClient({
+ defaultOptions: { queries: { retry: 0, gcTime: 0 } },
+ }),
+ };
+});
vi.mock('../../../api', () => ({ fetchProxyFonts: vi.fn() }));
import { queryClient } from '$shared/api/queryClient';
@@ -44,7 +48,7 @@ const makeResponse = (
});
function makeStore(params = {}) {
- return new FontStore({ limit: 10, ...params });
+ return new FontCatalogStore({ limit: 10, ...params });
}
async function fetchedStore(params = {}, fonts = generateMockFonts(5), meta: Parameters[1] = {}) {
@@ -55,7 +59,7 @@ async function fetchedStore(params = {}, fonts = generateMockFonts(5), meta: Par
return store;
}
-describe('FontStore', () => {
+describe('FontCatalogStore', () => {
afterEach(() => {
queryClient.clear();
vi.resetAllMocks();
@@ -69,7 +73,7 @@ describe('FontStore', () => {
});
it('defaults limit to 50 when not provided', () => {
- const store = new FontStore();
+ const store = new FontCatalogStore();
expect(store.params.limit).toBe(50);
store.destroy();
});
@@ -390,11 +394,11 @@ describe('FontStore', () => {
});
describe('nextPage', () => {
- let store: FontStore;
+ let store: FontCatalogStore;
beforeEach(async () => {
fetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 30, limit: 10, offset: 0 }));
- store = new FontStore({ limit: 10 });
+ store = new FontCatalogStore({ limit: 10 });
await store.refetch();
flushSync();
});
@@ -415,7 +419,7 @@ describe('FontStore', () => {
// Set up a store where all fonts fit in one page (hasMore = false)
queryClient.clear();
fetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 10, limit: 10, offset: 0 }));
- store = new FontStore({ limit: 10 });
+ store = new FontCatalogStore({ limit: 10 });
await store.refetch();
flushSync();
@@ -454,7 +458,7 @@ describe('FontStore', () => {
describe('getCachedData / setQueryData', () => {
it('getCachedData returns undefined before any fetch', () => {
queryClient.clear();
- const store = new FontStore({ limit: 10 });
+ const store = new FontCatalogStore({ limit: 10 });
expect(store.getCachedData()).toBeUndefined();
store.destroy();
});
@@ -502,7 +506,7 @@ describe('FontStore', () => {
});
describe('filter shortcut methods', () => {
- let store: FontStore;
+ let store: FontCatalogStore;
beforeEach(() => {
store = makeStore();
diff --git a/src/entities/Font/model/store/fontStore/fontStore.svelte.ts b/src/entities/Font/model/store/fontCatalogStore/fontCatalogStore.svelte.ts
similarity index 96%
rename from src/entities/Font/model/store/fontStore/fontStore.svelte.ts
rename to src/entities/Font/model/store/fontCatalogStore/fontCatalogStore.svelte.ts
index aeeb9cc..aea0ed0 100644
--- a/src/entities/Font/model/store/fontStore/fontStore.svelte.ts
+++ b/src/entities/Font/model/store/fontCatalogStore/fontCatalogStore.svelte.ts
@@ -1,4 +1,8 @@
-import { queryClient } from '$shared/api/queryClient';
+import {
+ DEFAULT_QUERY_GC_TIME_MS,
+ DEFAULT_QUERY_STALE_TIME_MS,
+ queryClient,
+} from '$shared/api/queryClient';
import {
type InfiniteData,
InfiniteQueryObserver,
@@ -25,7 +29,7 @@ type FontStoreParams = Omit;
type FontStoreResult = InfiniteQueryObserverResult, Error>;
-export class FontStore {
+export class FontCatalogStore {
#params = $state({ limit: 50 });
#result = $state({} as FontStoreResult);
#observer: InfiniteQueryObserver<
@@ -427,8 +431,8 @@ export class FontStore {
const next = lastPage.offset + lastPage.limit;
return next < lastPage.total ? { offset: next } : undefined;
},
- staleTime: hasFilters ? 0 : 5 * 60 * 1000,
- gcTime: 10 * 60 * 1000,
+ staleTime: hasFilters ? 0 : DEFAULT_QUERY_STALE_TIME_MS,
+ gcTime: DEFAULT_QUERY_GC_TIME_MS,
};
}
@@ -459,8 +463,8 @@ export class FontStore {
}
}
-export function createFontStore(params: FontStoreParams = {}): FontStore {
- return new FontStore(params);
+export function createFontCatalogStore(params: FontStoreParams = {}): FontCatalogStore {
+ return new FontCatalogStore(params);
}
-export const fontStore = new FontStore({ limit: 50 });
+export const fontCatalogStore = new FontCatalogStore({ limit: 50 });
diff --git a/src/entities/Font/model/store/appliedFontsStore/errors.ts b/src/entities/Font/model/store/fontLifecycleManager/errors.ts
similarity index 100%
rename from src/entities/Font/model/store/appliedFontsStore/errors.ts
rename to src/entities/Font/model/store/fontLifecycleManager/errors.ts
diff --git a/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts b/src/entities/Font/model/store/fontLifecycleManager/fontLifecycleManager.svelte.ts
similarity index 92%
rename from src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts
rename to src/entities/Font/model/store/fontLifecycleManager/fontLifecycleManager.svelte.ts
index 48bf55c..9439a87 100644
--- a/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts
+++ b/src/entities/Font/model/store/fontLifecycleManager/fontLifecycleManager.svelte.ts
@@ -17,7 +17,36 @@ import { FontBufferCache } from './utils/fontBufferCache/FontBufferCache';
import { FontEvictionPolicy } from './utils/fontEvictionPolicy/FontEvictionPolicy';
import { FontLoadQueue } from './utils/fontLoadQueue/FontLoadQueue';
-interface AppliedFontsManagerDeps {
+/**
+ * How often the periodic eviction sweep runs.
+ */
+const PURGE_INTERVAL_MS = 60000;
+
+/**
+ * Timeout for `requestIdleCallback`. After this elapses, the callback is
+ * forced to run regardless of whether the browser is idle.
+ */
+const IDLE_CALLBACK_TIMEOUT_MS = 150;
+
+/**
+ * setTimeout fallback delay when `requestIdleCallback` is unavailable.
+ * ~16ms ≈ one frame at 60fps.
+ */
+const SCHEDULE_FALLBACK_MS = 16;
+
+/**
+ * How often the parse loop yields back to the main thread when the browser
+ * does not provide `isInputPending` (non-Chromium fallback).
+ */
+const YIELD_INTERVAL_MS = 8;
+
+/**
+ * Font weights treated as "critical" in data-saver mode. Other weights are
+ * skipped to reduce network usage; variable fonts bypass this filter.
+ */
+const CRITICAL_FONT_WEIGHTS = [400, 700];
+
+interface FontLifecycleManagerDeps {
cache?: FontBufferCache;
eviction?: FontEvictionPolicy;
queue?: FontLoadQueue;
@@ -46,7 +75,7 @@ interface AppliedFontsManagerDeps {
*
* **Browser APIs Used:** `scheduler.yield()`, `isInputPending()`, `requestIdleCallback`, Cache API, Network Information API
*/
-export class AppliedFontsManager {
+export class FontLifecycleManager {
// Injected collaborators - each handles one concern for better testability
readonly #cache: FontBufferCache;
readonly #eviction: FontEvictionPolicy;
@@ -70,22 +99,20 @@ export class AppliedFontsManager {
// Tracks which callback type is pending ('idle' | 'timeout' | null) for proper cancellation
#pendingType: 'idle' | 'timeout' | null = null;
- readonly #PURGE_INTERVAL = 60000;
-
// Reactive status map for Svelte components to track font states
statuses = new SvelteMap();
// Starts periodic cleanup timer (browser-only).
constructor(
{ cache = new FontBufferCache(), eviction = new FontEvictionPolicy(), queue = new FontLoadQueue() }:
- AppliedFontsManagerDeps = {},
+ FontLifecycleManagerDeps = {},
) {
// Inject collaborators - defaults provided for production, fakes for testing
this.#cache = cache;
this.#eviction = eviction;
this.#queue = queue;
if (typeof window !== 'undefined') {
- this.#intervalId = setInterval(() => this.#purgeUnused(), this.#PURGE_INTERVAL);
+ this.#intervalId = setInterval(() => this.#purgeUnused(), PURGE_INTERVAL_MS);
}
}
@@ -147,11 +174,11 @@ export class AppliedFontsManager {
if (typeof requestIdleCallback !== 'undefined') {
this.#timeoutId = requestIdleCallback(
() => this.#processQueue(),
- { timeout: 150 },
+ { timeout: IDLE_CALLBACK_TIMEOUT_MS },
) as unknown as ReturnType;
this.#pendingType = 'idle';
} else {
- this.#timeoutId = setTimeout(() => this.#processQueue(), 16);
+ this.#timeoutId = setTimeout(() => this.#processQueue(), SCHEDULE_FALLBACK_MS);
this.#pendingType = 'timeout';
}
}
@@ -183,7 +210,7 @@ export class AppliedFontsManager {
// In data-saver mode, only load variable fonts and common weights (400, 700)
if (this.#shouldDeferNonCritical()) {
- entries = entries.filter(([, c]) => c.isVariable || [400, 700].includes(c.weight));
+ entries = entries.filter(([, c]) => c.isVariable || CRITICAL_FONT_WEIGHTS.includes(c.weight));
}
// Determine optimal concurrent fetches based on network speed (1-4)
@@ -198,7 +225,6 @@ export class AppliedFontsManager {
// Parse buffers one at a time with periodic yields to avoid blocking UI
const hasInputPending = !!(navigator as any).scheduling?.isInputPending;
let lastYield = performance.now();
- const YIELD_INTERVAL = 8;
for (const [key, config] of entries) {
const buffer = buffers.get(key);
@@ -214,7 +240,7 @@ export class AppliedFontsManager {
// Others: yield every 8ms as fallback
const shouldYield = hasInputPending
? (navigator as any).scheduling.isInputPending({ includeContinuous: true })
- : performance.now() - lastYield > YIELD_INTERVAL;
+ : performance.now() - lastYield > YIELD_INTERVAL_MS;
if (shouldYield) {
await yieldToMainThread();
@@ -396,4 +422,4 @@ export class AppliedFontsManager {
/**
* Singleton instance — use throughout the application for unified font loading state.
*/
-export const appliedFontsManager = new AppliedFontsManager();
+export const fontLifecycleManager = new FontLifecycleManager();
diff --git a/src/entities/Font/model/store/appliedFontsStore/appliedFontStore.test.ts b/src/entities/Font/model/store/fontLifecycleManager/fontLifecycleManager.test.ts
similarity index 94%
rename from src/entities/Font/model/store/appliedFontsStore/appliedFontStore.test.ts
rename to src/entities/Font/model/store/fontLifecycleManager/fontLifecycleManager.test.ts
index b1183ba..4c7e4ed 100644
--- a/src/entities/Font/model/store/appliedFontsStore/appliedFontStore.test.ts
+++ b/src/entities/Font/model/store/fontLifecycleManager/fontLifecycleManager.test.ts
@@ -1,8 +1,8 @@
/**
* @vitest-environment jsdom
*/
-import { AppliedFontsManager } from './appliedFontsStore.svelte';
import { FontFetchError } from './errors';
+import { FontLifecycleManager } from './fontLifecycleManager.svelte';
import { FontEvictionPolicy } from './utils/fontEvictionPolicy/FontEvictionPolicy';
class FakeBufferCache {
@@ -32,8 +32,8 @@ const makeConfig = (id: string, overrides: Partial<{ weight: number; isVariable:
...overrides,
});
-describe('AppliedFontsManager', () => {
- let manager: AppliedFontsManager;
+describe('FontLifecycleManager', () => {
+ let manager: FontLifecycleManager;
let eviction: FontEvictionPolicy;
let mockFontFaceSet: { add: ReturnType; delete: ReturnType };
@@ -55,7 +55,7 @@ describe('AppliedFontsManager', () => {
});
vi.stubGlobal('FontFace', MockFontFace);
- manager = new AppliedFontsManager({ cache: new FakeBufferCache() as any, eviction });
+ manager = new FontLifecycleManager({ cache: new FakeBufferCache() as any, eviction });
});
afterEach(() => {
@@ -101,7 +101,7 @@ describe('AppliedFontsManager', () => {
it('skips fonts that have exhausted retries', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
- const failManager = new AppliedFontsManager({ cache: new FailingBufferCache() as any, eviction });
+ const failManager = new FontLifecycleManager({ cache: new FailingBufferCache() as any, eviction });
// exhaust all 3 retries
for (let i = 0; i < 3; i++) {
@@ -160,7 +160,7 @@ describe('AppliedFontsManager', () => {
describe('Phase 1 — fetch', () => {
it('sets status to error on fetch failure', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
- const failManager = new AppliedFontsManager({ cache: new FailingBufferCache() as any, eviction });
+ const failManager = new FontLifecycleManager({ cache: new FailingBufferCache() as any, eviction });
failManager.touch([makeConfig('broken')]);
await vi.advanceTimersByTimeAsync(50);
@@ -171,7 +171,7 @@ describe('AppliedFontsManager', () => {
it('logs a console error on fetch failure', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
- const failManager = new AppliedFontsManager({ cache: new FailingBufferCache() as any, eviction });
+ const failManager = new FontLifecycleManager({ cache: new FailingBufferCache() as any, eviction });
failManager.touch([makeConfig('broken')]);
await vi.advanceTimersByTimeAsync(50);
@@ -189,7 +189,7 @@ describe('AppliedFontsManager', () => {
evict() {},
clear() {},
};
- const abortManager = new AppliedFontsManager({ cache: abortingCache as any, eviction });
+ const abortManager = new FontLifecycleManager({ cache: abortingCache as any, eviction });
abortManager.touch([makeConfig('aborted')]);
await vi.advanceTimersByTimeAsync(50);
diff --git a/src/entities/Font/model/store/appliedFontsStore/utils/fontBufferCache/FontBufferCache.test.ts b/src/entities/Font/model/store/fontLifecycleManager/utils/fontBufferCache/FontBufferCache.test.ts
similarity index 100%
rename from src/entities/Font/model/store/appliedFontsStore/utils/fontBufferCache/FontBufferCache.test.ts
rename to src/entities/Font/model/store/fontLifecycleManager/utils/fontBufferCache/FontBufferCache.test.ts
diff --git a/src/entities/Font/model/store/appliedFontsStore/utils/fontBufferCache/FontBufferCache.ts b/src/entities/Font/model/store/fontLifecycleManager/utils/fontBufferCache/FontBufferCache.ts
similarity index 100%
rename from src/entities/Font/model/store/appliedFontsStore/utils/fontBufferCache/FontBufferCache.ts
rename to src/entities/Font/model/store/fontLifecycleManager/utils/fontBufferCache/FontBufferCache.ts
diff --git a/src/entities/Font/model/store/appliedFontsStore/utils/fontEvictionPolicy/FontEvictionPolicy.test.ts b/src/entities/Font/model/store/fontLifecycleManager/utils/fontEvictionPolicy/FontEvictionPolicy.test.ts
similarity index 100%
rename from src/entities/Font/model/store/appliedFontsStore/utils/fontEvictionPolicy/FontEvictionPolicy.test.ts
rename to src/entities/Font/model/store/fontLifecycleManager/utils/fontEvictionPolicy/FontEvictionPolicy.test.ts
diff --git a/src/entities/Font/model/store/appliedFontsStore/utils/fontEvictionPolicy/FontEvictionPolicy.ts b/src/entities/Font/model/store/fontLifecycleManager/utils/fontEvictionPolicy/FontEvictionPolicy.ts
similarity index 89%
rename from src/entities/Font/model/store/appliedFontsStore/utils/fontEvictionPolicy/FontEvictionPolicy.ts
rename to src/entities/Font/model/store/fontLifecycleManager/utils/fontEvictionPolicy/FontEvictionPolicy.ts
index 2a64cc6..d49d296 100644
--- a/src/entities/Font/model/store/appliedFontsStore/utils/fontEvictionPolicy/FontEvictionPolicy.ts
+++ b/src/entities/Font/model/store/fontLifecycleManager/utils/fontEvictionPolicy/FontEvictionPolicy.ts
@@ -1,6 +1,11 @@
+/**
+ * Default TTL after which an unpinned font is eligible for eviction.
+ */
+export const DEFAULT_FONT_TTL_MS = 5 * 60 * 1000;
+
interface FontEvictionPolicyOptions {
/**
- * TTL in milliseconds. Defaults to 5 minutes.
+ * TTL in milliseconds. Defaults to {@link DEFAULT_FONT_TTL_MS}.
*/
ttl?: number;
}
@@ -17,7 +22,7 @@ export class FontEvictionPolicy {
readonly #TTL: number;
- constructor({ ttl = 5 * 60 * 1000 }: FontEvictionPolicyOptions = {}) {
+ constructor({ ttl = DEFAULT_FONT_TTL_MS }: FontEvictionPolicyOptions = {}) {
this.#TTL = ttl;
}
diff --git a/src/entities/Font/model/store/appliedFontsStore/utils/fontLoadQueue/FontLoadQueue.test.ts b/src/entities/Font/model/store/fontLifecycleManager/utils/fontLoadQueue/FontLoadQueue.test.ts
similarity index 100%
rename from src/entities/Font/model/store/appliedFontsStore/utils/fontLoadQueue/FontLoadQueue.test.ts
rename to src/entities/Font/model/store/fontLifecycleManager/utils/fontLoadQueue/FontLoadQueue.test.ts
diff --git a/src/entities/Font/model/store/appliedFontsStore/utils/fontLoadQueue/FontLoadQueue.ts b/src/entities/Font/model/store/fontLifecycleManager/utils/fontLoadQueue/FontLoadQueue.ts
similarity index 88%
rename from src/entities/Font/model/store/appliedFontsStore/utils/fontLoadQueue/FontLoadQueue.ts
rename to src/entities/Font/model/store/fontLifecycleManager/utils/fontLoadQueue/FontLoadQueue.ts
index e921eb9..576b7f8 100644
--- a/src/entities/Font/model/store/appliedFontsStore/utils/fontLoadQueue/FontLoadQueue.ts
+++ b/src/entities/Font/model/store/fontLifecycleManager/utils/fontLoadQueue/FontLoadQueue.ts
@@ -1,5 +1,11 @@
import type { FontLoadRequestConfig } from '../../../../types';
+/**
+ * Maximum number of times a single font key will be retried before it is
+ * considered permanently failed.
+ */
+export const FONT_LOAD_MAX_RETRIES = 3;
+
/**
* Manages the font load queue and per-font retry counts.
*
@@ -10,8 +16,6 @@ export class FontLoadQueue {
#queue = new Map();
#retryCounts = new Map();
- readonly #MAX_RETRIES = 3;
-
/**
* Adds a font to the queue.
* @returns `true` if the key was newly enqueued, `false` if it was already present.
@@ -52,7 +56,7 @@ export class FontLoadQueue {
* Returns `true` if the font has reached or exceeded the maximum retry limit.
*/
isMaxRetriesReached(key: string): boolean {
- return (this.#retryCounts.get(key) ?? 0) >= this.#MAX_RETRIES;
+ return (this.#retryCounts.get(key) ?? 0) >= FONT_LOAD_MAX_RETRIES;
}
/**
diff --git a/src/entities/Font/model/store/appliedFontsStore/utils/generateFontKey/generateFontKey.test.ts b/src/entities/Font/model/store/fontLifecycleManager/utils/generateFontKey/generateFontKey.test.ts
similarity index 100%
rename from src/entities/Font/model/store/appliedFontsStore/utils/generateFontKey/generateFontKey.test.ts
rename to src/entities/Font/model/store/fontLifecycleManager/utils/generateFontKey/generateFontKey.test.ts
diff --git a/src/entities/Font/model/store/appliedFontsStore/utils/generateFontKey/generateFontKey.ts b/src/entities/Font/model/store/fontLifecycleManager/utils/generateFontKey/generateFontKey.ts
similarity index 100%
rename from src/entities/Font/model/store/appliedFontsStore/utils/generateFontKey/generateFontKey.ts
rename to src/entities/Font/model/store/fontLifecycleManager/utils/generateFontKey/generateFontKey.ts
diff --git a/src/entities/Font/model/store/appliedFontsStore/utils/getEffectiveConcurrency/getEffectiveConcurrency.test.ts b/src/entities/Font/model/store/fontLifecycleManager/utils/getEffectiveConcurrency/getEffectiveConcurrency.test.ts
similarity index 100%
rename from src/entities/Font/model/store/appliedFontsStore/utils/getEffectiveConcurrency/getEffectiveConcurrency.test.ts
rename to src/entities/Font/model/store/fontLifecycleManager/utils/getEffectiveConcurrency/getEffectiveConcurrency.test.ts
diff --git a/src/entities/Font/model/store/appliedFontsStore/utils/getEffectiveConcurrency/getEffectiveConcurrency.ts b/src/entities/Font/model/store/fontLifecycleManager/utils/getEffectiveConcurrency/getEffectiveConcurrency.ts
similarity index 100%
rename from src/entities/Font/model/store/appliedFontsStore/utils/getEffectiveConcurrency/getEffectiveConcurrency.ts
rename to src/entities/Font/model/store/fontLifecycleManager/utils/getEffectiveConcurrency/getEffectiveConcurrency.ts
diff --git a/src/entities/Font/model/store/appliedFontsStore/utils/index.ts b/src/entities/Font/model/store/fontLifecycleManager/utils/index.ts
similarity index 100%
rename from src/entities/Font/model/store/appliedFontsStore/utils/index.ts
rename to src/entities/Font/model/store/fontLifecycleManager/utils/index.ts
diff --git a/src/entities/Font/model/store/appliedFontsStore/utils/loadFont/loadFont.test.ts b/src/entities/Font/model/store/fontLifecycleManager/utils/loadFont/loadFont.test.ts
similarity index 100%
rename from src/entities/Font/model/store/appliedFontsStore/utils/loadFont/loadFont.test.ts
rename to src/entities/Font/model/store/fontLifecycleManager/utils/loadFont/loadFont.test.ts
diff --git a/src/entities/Font/model/store/appliedFontsStore/utils/loadFont/loadFont.ts b/src/entities/Font/model/store/fontLifecycleManager/utils/loadFont/loadFont.ts
similarity index 100%
rename from src/entities/Font/model/store/appliedFontsStore/utils/loadFont/loadFont.ts
rename to src/entities/Font/model/store/fontLifecycleManager/utils/loadFont/loadFont.ts
diff --git a/src/entities/Font/model/store/appliedFontsStore/utils/yieldToMainThread/yieldToMainThread.test.ts b/src/entities/Font/model/store/fontLifecycleManager/utils/yieldToMainThread/yieldToMainThread.test.ts
similarity index 100%
rename from src/entities/Font/model/store/appliedFontsStore/utils/yieldToMainThread/yieldToMainThread.test.ts
rename to src/entities/Font/model/store/fontLifecycleManager/utils/yieldToMainThread/yieldToMainThread.test.ts
diff --git a/src/entities/Font/model/store/appliedFontsStore/utils/yieldToMainThread/yieldToMainThread.ts b/src/entities/Font/model/store/fontLifecycleManager/utils/yieldToMainThread/yieldToMainThread.ts
similarity index 100%
rename from src/entities/Font/model/store/appliedFontsStore/utils/yieldToMainThread/yieldToMainThread.ts
rename to src/entities/Font/model/store/fontLifecycleManager/utils/yieldToMainThread/yieldToMainThread.ts
diff --git a/src/entities/Font/model/store/index.ts b/src/entities/Font/model/store/index.ts
index 3ef8061..97ebad0 100644
--- a/src/entities/Font/model/store/index.ts
+++ b/src/entities/Font/model/store/index.ts
@@ -1,12 +1,9 @@
-// Applied fonts manager
-export * from './appliedFontsStore/appliedFontsStore.svelte';
+// Font lifecycle manager (browser-side load + cache + eviction)
+export * from './fontLifecycleManager/fontLifecycleManager.svelte';
-// Batch font store
-export { BatchFontStore } from './batchFontStore.svelte';
-
-// Single FontStore
+// Paginated catalog
export {
- createFontStore,
- FontStore,
- fontStore,
-} from './fontStore/fontStore.svelte';
+ createFontCatalogStore,
+ FontCatalogStore,
+ fontCatalogStore,
+} from './fontCatalogStore/fontCatalogStore.svelte';
diff --git a/src/entities/Font/model/types/index.ts b/src/entities/Font/model/types/index.ts
index f4edb26..b23390c 100644
--- a/src/entities/Font/model/types/index.ts
+++ b/src/entities/Font/model/types/index.ts
@@ -23,5 +23,5 @@ export type {
FontCollectionState,
} from './store';
-export * from './store/appliedFonts';
+export * from './store/fontLifecycle';
export * from './typography';
diff --git a/src/entities/Font/model/types/store/appliedFonts.ts b/src/entities/Font/model/types/store/fontLifecycle.ts
similarity index 100%
rename from src/entities/Font/model/types/store/appliedFonts.ts
rename to src/entities/Font/model/types/store/fontLifecycle.ts
diff --git a/src/entities/Font/ui/FontApplicator/FontApplicator.stories.svelte b/src/entities/Font/ui/FontApplicator/FontApplicator.stories.svelte
index ff14ba7..0e370a6 100644
--- a/src/entities/Font/ui/FontApplicator/FontApplicator.stories.svelte
+++ b/src/entities/Font/ui/FontApplicator/FontApplicator.stories.svelte
@@ -39,7 +39,7 @@ const fontArialBold = mockUnifiedFont({ id: 'arial-bold', name: 'Arial' });
docs: {
description: {
story:
- 'Font that has never been loaded by appliedFontsManager. The component renders in its pending state: blurred, scaled down, and semi-transparent.',
+ 'Font that has never been loaded by fontLifecycleManager. The component renders in its pending state: blurred, scaled down, and semi-transparent.',
},
},
}}
@@ -58,7 +58,7 @@ const fontArialBold = mockUnifiedFont({ id: 'arial-bold', name: 'Arial' });
docs: {
description: {
story:
- 'Uses Arial, a system font available in all browsers. Because appliedFontsManager has not loaded it via FontFace, the manager status may remain pending — meaning the blur/scale state may still show. In a real app the manager would load the font and transition to the revealed state.',
+ 'Uses Arial, a system font available in all browsers. Because fontLifecycleManager has not loaded it via FontFace, the manager status may remain pending — meaning the blur/scale state may still show. In a real app the manager would load the font and transition to the revealed state.',
},
},
}}
@@ -77,7 +77,7 @@ const fontArialBold = mockUnifiedFont({ id: 'arial-bold', name: 'Arial' });
docs: {
description: {
story:
- 'Demonstrates passing a custom weight (700). The weight is forwarded to appliedFontsManager for font resolution; visually identical to the loaded state story until the manager confirms the font.',
+ 'Demonstrates passing a custom weight (700). The weight is forwarded to fontLifecycleManager for font resolution; visually identical to the loaded state story until the manager confirms the font.',
},
},
}}
diff --git a/src/entities/Font/ui/FontApplicator/FontApplicator.svelte b/src/entities/Font/ui/FontApplicator/FontApplicator.svelte
index fe12255..ae1e97c 100644
--- a/src/entities/Font/ui/FontApplicator/FontApplicator.svelte
+++ b/src/entities/Font/ui/FontApplicator/FontApplicator.svelte
@@ -9,7 +9,7 @@ import type { Snippet } from 'svelte';
import {
DEFAULT_FONT_WEIGHT,
type UnifiedFont,
- appliedFontsManager,
+ fontLifecycleManager,
} from '../../model';
interface Props {
@@ -46,7 +46,7 @@ let {
}: Props = $props();
const status = $derived(
- appliedFontsManager.getFontStatus(
+ fontLifecycleManager.getFontStatus(
font.id,
weight,
font.features?.isVariable,
diff --git a/src/entities/Font/ui/FontVirtualList/FontVirtualList.stories.svelte b/src/entities/Font/ui/FontVirtualList/FontVirtualList.stories.svelte
index caf0107..460dfbf 100644
--- a/src/entities/Font/ui/FontVirtualList/FontVirtualList.stories.svelte
+++ b/src/entities/Font/ui/FontVirtualList/FontVirtualList.stories.svelte
@@ -10,7 +10,7 @@ const { Story } = defineMeta({
docs: {
description: {
component:
- 'Virtualized font list backed by the `fontStore` singleton. Handles font loading registration (pin/touch) for visible items and triggers infinite scroll pagination via `fontStore.nextPage()`. Because the component reads directly from the `fontStore` singleton, stories render against a live (but empty/loading) store — no font data will appear unless the API is reachable from the Storybook host.',
+ 'Virtualized font list backed by the `fontCatalogStore` singleton. Handles font loading registration (pin/touch) for visible items and triggers infinite scroll pagination via `fontCatalogStore.nextPage()`. Because the component reads directly from the `fontCatalogStore` singleton, stories render against a live (but empty/loading) store — no font data will appear unless the API is reachable from the Storybook host.',
},
story: { inline: false },
},
@@ -33,7 +33,7 @@ import type { ComponentProps } from 'svelte';
docs: {
description: {
story:
- 'Skeleton state shown while `fontStore.fonts` is empty and `fontStore.isLoading` is true. In a real session the skeleton fades out once the first page loads.',
+ 'Skeleton state shown while `fontCatalogStore.fonts` is empty and `fontCatalogStore.isLoading` is true. In a real session the skeleton fades out once the first page loads.',
},
},
}}
@@ -63,7 +63,7 @@ import type { ComponentProps } from 'svelte';
docs: {
description: {
story:
- 'No `skeleton` snippet provided. When `fontStore.fonts` is empty the underlying VirtualList renders its empty state directly.',
+ 'No `skeleton` snippet provided. When `fontCatalogStore.fonts` is empty the underlying VirtualList renders its empty state directly.',
},
},
}}
@@ -86,7 +86,7 @@ import type { ComponentProps } from 'svelte';
docs: {
description: {
story:
- 'Demonstrates how to configure a `children` snippet for item rendering. The list will be empty because `fontStore` is not populated in Storybook, but the template shows the expected slot shape: `{ item: UnifiedFont }`.',
+ 'Demonstrates how to configure a `children` snippet for item rendering. The list will be empty because `fontCatalogStore` is not populated in Storybook, but the template shows the expected slot shape: `{ item: UnifiedFont }`.',
},
},
}}
diff --git a/src/entities/Font/ui/FontVirtualList/FontVirtualList.svelte b/src/entities/Font/ui/FontVirtualList/FontVirtualList.svelte
index 7d4cc84..957c86d 100644
--- a/src/entities/Font/ui/FontVirtualList/FontVirtualList.svelte
+++ b/src/entities/Font/ui/FontVirtualList/FontVirtualList.svelte
@@ -18,8 +18,8 @@ import { getFontUrl } from '../../lib';
import {
type FontLoadRequestConfig,
type UnifiedFont,
- appliedFontsManager,
- fontStore,
+ fontCatalogStore,
+ fontLifecycleManager,
} from '../../model';
interface Props extends
@@ -51,13 +51,13 @@ let {
}: Props = $props();
const isLoading = $derived(
- fontStore.isFetching || fontStore.isLoading,
+ fontCatalogStore.isFetching || fontCatalogStore.isLoading,
);
let visibleFonts = $state([]);
let isCatchingUp = $state(false);
-const showInitialSkeleton = $derived(!!skeleton && isLoading && fontStore.fonts.length === 0);
+const showInitialSkeleton = $derived(!!skeleton && isLoading && fontCatalogStore.fonts.length === 0);
const showCatchupSkeleton = $derived(!!skeleton && isCatchingUp);
function handleInternalVisibleChange(items: UnifiedFont[]) {
@@ -68,24 +68,30 @@ function handleInternalVisibleChange(items: UnifiedFont[]) {
/**
* Handle jump scroll — batch-load all missing pages then re-enable font loading.
- * Suppresses appliedFontsManager.touch() during catch-up to avoid loading
+ * Suppresses fontLifecycleManager.touch() during catch-up to avoid loading
* font files for thousands of intermediate fonts.
*/
async function handleJump(targetIndex: number) {
- if (isCatchingUp || !fontStore.pagination.hasMore) {
+ if (isCatchingUp || !fontCatalogStore.pagination.hasMore) {
return;
}
isCatchingUp = true;
try {
- await fontStore.fetchAllPagesTo(targetIndex);
+ await fontCatalogStore.fetchAllPagesTo(targetIndex);
} finally {
isCatchingUp = false;
}
}
+/**
+ * Debounce wait before asking the font lifecycle manager to load fonts
+ * for the current visible window. Coalesces rapid scroll into one batch.
+ */
+const TOUCH_DEBOUNCE_MS = 150;
+
const debouncedTouch = debounce((configs: FontLoadRequestConfig[]) => {
- appliedFontsManager.touch(configs);
-}, 150);
+ fontLifecycleManager.touch(configs);
+}, TOUCH_DEBOUNCE_MS);
// Re-touch whenever visible set or weight changes — fixes weight-change gap
$effect(() => {
@@ -111,11 +117,11 @@ $effect(() => {
const w = weight;
const fonts = visibleFonts;
for (const f of fonts) {
- appliedFontsManager.pin(f.id, w, f.features?.isVariable);
+ fontLifecycleManager.pin(f.id, w, f.features?.isVariable);
}
return () => {
for (const f of fonts) {
- appliedFontsManager.unpin(f.id, w, f.features?.isVariable);
+ fontLifecycleManager.unpin(f.id, w, f.features?.isVariable);
}
};
});
@@ -125,12 +131,12 @@ $effect(() => {
*/
function loadMore() {
if (
- !fontStore.pagination.hasMore
- || fontStore.isFetching
+ !fontCatalogStore.pagination.hasMore
+ || fontCatalogStore.isFetching
) {
return;
}
- fontStore.nextPage();
+ fontCatalogStore.nextPage();
}
/**
@@ -140,12 +146,12 @@ function loadMore() {
* of the loaded items. Only fetches if there are more pages available.
*/
function handleNearBottom(_lastVisibleIndex: number) {
- const { hasMore } = fontStore.pagination;
+ const { hasMore } = fontCatalogStore.pagination;
// VirtualList already checks if we're near the bottom of loaded items.
// Guard isCatchingUp: fetchAllPagesTo bypasses TQ so isFetching stays false
// during batch catch-up, which would otherwise let nextPage() race with it.
- if (hasMore && !fontStore.isFetching && !isCatchingUp) {
+ if (hasMore && !fontCatalogStore.isFetching && !isCatchingUp) {
loadMore();
}
}
@@ -160,8 +166,8 @@ function handleNearBottom(_lastVisibleIndex: number) {
{:else}
= Omit, keyof ControlDataModel>;
/**
@@ -67,7 +76,7 @@ export interface TypographySettings {
* Manages multiple typography controls with persistent storage and
* responsive scaling support for font size.
*/
-export class TypographySettingsManager {
+export class TypographySettingsStore {
/**
* Internal map of reactive controls keyed by their identifier
*/
@@ -138,7 +147,7 @@ export class TypographySettingsManager {
const calculatedBase = currentDisplayValue / this.#multiplier;
// Only update if the difference is significant (prevents rounding jitter)
- if (Math.abs(this.#baseSize - calculatedBase) > 0.01) {
+ if (Math.abs(this.#baseSize - calculatedBase) > BASE_SIZE_EPSILON) {
this.#baseSize = calculatedBase;
}
});
@@ -296,6 +305,16 @@ export class TypographySettingsManager {
}
}
+/**
+ * Default factory storage key — used when a caller doesn't pass one.
+ */
+const DEFAULT_STORAGE_KEY = 'glyphdiff:typography';
+
+/**
+ * Storage key used by the app-wide singleton (scoped to comparison view).
+ */
+const COMPARISON_STORAGE_KEY = 'glyphdiff:comparison:typography';
+
/**
* Creates a typography control manager
*
@@ -303,9 +322,9 @@ export class TypographySettingsManager {
* @param storageId - Persistent storage identifier
* @returns Typography control manager instance
*/
-export function createTypographySettingsManager(
+export function createTypographySettingsStore(
configs: ControlModel[],
- storageId: string = 'glyphdiff:typography',
+ storageId: string = DEFAULT_STORAGE_KEY,
) {
const storage = createPersistentStore(storageId, {
fontSize: DEFAULT_FONT_SIZE,
@@ -313,5 +332,13 @@ export function createTypographySettingsManager(
lineHeight: DEFAULT_LINE_HEIGHT,
letterSpacing: DEFAULT_LETTER_SPACING,
});
- return new TypographySettingsManager(configs, storage);
+ return new TypographySettingsStore(configs, storage);
}
+
+/**
+ * App-wide typography settings singleton, keyed for the comparison view.
+ */
+export const typographySettingsStore = createTypographySettingsStore(
+ DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
+ COMPARISON_STORAGE_KEY,
+);
diff --git a/src/features/SetupFont/lib/settingsManager/settingsManager.test.ts b/src/features/AdjustTypography/model/store/typographySettingsStore/typographySettingsStore.test.ts
similarity index 89%
rename from src/features/SetupFont/lib/settingsManager/settingsManager.test.ts
rename to src/features/AdjustTypography/model/store/typographySettingsStore/typographySettingsStore.test.ts
index 31d4c8d..094bf75 100644
--- a/src/features/SetupFont/lib/settingsManager/settingsManager.test.ts
+++ b/src/features/AdjustTypography/model/store/typographySettingsStore/typographySettingsStore.test.ts
@@ -17,13 +17,13 @@ import {
} from 'vitest';
import {
type TypographySettings,
- TypographySettingsManager,
-} from './settingsManager.svelte';
+ TypographySettingsStore,
+} from './typographySettingsStore.svelte';
/**
- * Test Strategy for TypographySettingsManager
+ * Test Strategy for TypographySettingsStore
*
- * This test suite validates the TypographySettingsManager state management logic.
+ * This test suite validates the TypographySettingsStore state management logic.
* These are unit tests for the manager logic, separate from component rendering.
*
* NOTE: Svelte 5's $effect runs in microtasks, so we need to flush effects
@@ -46,7 +46,7 @@ async function flushEffects() {
await Promise.resolve();
}
-describe('TypographySettingsManager - Unit Tests', () => {
+describe('TypographySettingsStore - Unit Tests', () => {
let mockStorage: TypographySettings;
let mockPersistentStore: {
value: TypographySettings;
@@ -86,7 +86,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
describe('Initialization', () => {
it('creates manager with default values from storage', () => {
- const manager = new TypographySettingsManager(
+ const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -106,7 +106,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
};
mockPersistentStore = createMockPersistentStore(mockStorage);
- const manager = new TypographySettingsManager(
+ const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -118,7 +118,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
});
it('initializes font size control with base size multiplied by current multiplier (1)', () => {
- const manager = new TypographySettingsManager(
+ const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -127,7 +127,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
});
it('returns all controls via controls getter', () => {
- const manager = new TypographySettingsManager(
+ const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -143,7 +143,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
});
it('returns individual controls via specific getters', () => {
- const manager = new TypographySettingsManager(
+ const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -161,7 +161,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
});
it('control instances have expected interface', () => {
- const manager = new TypographySettingsManager(
+ const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -180,7 +180,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
describe('Multiplier System', () => {
it('has default multiplier of 1', () => {
- const manager = new TypographySettingsManager(
+ const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -189,7 +189,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
});
it('updates multiplier when set', () => {
- const manager = new TypographySettingsManager(
+ const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -202,7 +202,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
});
it('does not update multiplier if set to same value', () => {
- const manager = new TypographySettingsManager(
+ const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -218,7 +218,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
mockStorage = { fontSize: 48, fontWeight: 400, lineHeight: 1.5, letterSpacing: 0 };
mockPersistentStore = createMockPersistentStore(mockStorage);
- const manager = new TypographySettingsManager(
+ const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -242,7 +242,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
});
it('updates font size control display value when multiplier increases', () => {
- const manager = new TypographySettingsManager(
+ const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -263,7 +263,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
describe('Base Size Setter', () => {
it('updates baseSize when set directly', () => {
- const manager = new TypographySettingsManager(
+ const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -274,7 +274,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
});
it('updates size control value when baseSize is set', () => {
- const manager = new TypographySettingsManager(
+ const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -285,7 +285,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
});
it('applies multiplier to size control when baseSize is set', () => {
- const manager = new TypographySettingsManager(
+ const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -299,7 +299,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
describe('Rendered Size Calculation', () => {
it('calculates renderedSize as baseSize * multiplier', () => {
- const manager = new TypographySettingsManager(
+ const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -308,7 +308,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
});
it('updates renderedSize when multiplier changes', () => {
- const manager = new TypographySettingsManager(
+ const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -321,7 +321,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
});
it('updates renderedSize when baseSize changes', () => {
- const manager = new TypographySettingsManager(
+ const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -341,7 +341,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
// proxy effect behavior should be tested in E2E tests.
it('does NOT immediately update baseSize from control change (effect is async)', () => {
- const manager = new TypographySettingsManager(
+ const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -356,7 +356,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
});
it('updates baseSize via direct setter (synchronous)', () => {
- const manager = new TypographySettingsManager(
+ const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -381,7 +381,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
};
mockPersistentStore = createMockPersistentStore(mockStorage);
- const manager = new TypographySettingsManager(
+ const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -394,7 +394,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
});
it('syncs to storage after effect flush (async)', async () => {
- const manager = new TypographySettingsManager(
+ const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -410,7 +410,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
});
it('syncs control changes to storage after effect flush (async)', async () => {
- const manager = new TypographySettingsManager(
+ const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -423,7 +423,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
});
it('syncs height control changes to storage after effect flush (async)', async () => {
- const manager = new TypographySettingsManager(
+ const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -435,7 +435,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
});
it('syncs spacing control changes to storage after effect flush (async)', async () => {
- const manager = new TypographySettingsManager(
+ const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -449,7 +449,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
describe('Control Value Getters', () => {
it('returns current weight value', () => {
- const manager = new TypographySettingsManager(
+ const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -461,7 +461,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
});
it('returns current height value', () => {
- const manager = new TypographySettingsManager(
+ const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -473,7 +473,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
});
it('returns current spacing value', () => {
- const manager = new TypographySettingsManager(
+ const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -486,7 +486,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
it('returns default value when control is not found', () => {
// Create a manager with empty configs (no controls)
- const manager = new TypographySettingsManager([], mockPersistentStore);
+ const manager = new TypographySettingsStore([], mockPersistentStore);
expect(manager.weight).toBe(DEFAULT_FONT_WEIGHT);
expect(manager.height).toBe(DEFAULT_LINE_HEIGHT);
@@ -504,7 +504,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
};
mockPersistentStore = createMockPersistentStore(mockStorage);
- const manager = new TypographySettingsManager(
+ const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -537,7 +537,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
clear: clearSpy,
};
- const manager = new TypographySettingsManager(
+ const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -548,7 +548,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
});
it('respects multiplier when resetting font size control', () => {
- const manager = new TypographySettingsManager(
+ const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -566,7 +566,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
describe('Complex Scenarios', () => {
it('handles changing multiplier then modifying baseSize', () => {
- const manager = new TypographySettingsManager(
+ const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -587,7 +587,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
});
it('maintains correct renderedSize throughout changes', () => {
- const manager = new TypographySettingsManager(
+ const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -609,7 +609,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
});
it('handles multiple control changes in sequence', async () => {
- const manager = new TypographySettingsManager(
+ const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -634,7 +634,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
mockStorage = { fontSize: 48, fontWeight: 400, lineHeight: 1.5, letterSpacing: 0 };
mockPersistentStore = createMockPersistentStore(mockStorage);
- const manager = new TypographySettingsManager(
+ const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -646,7 +646,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
});
it('handles very small multiplier', () => {
- const manager = new TypographySettingsManager(
+ const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -659,7 +659,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
});
it('handles large base size with multiplier', () => {
- const manager = new TypographySettingsManager(
+ const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -672,7 +672,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
});
it('handles floating point precision in multiplier', () => {
- const manager = new TypographySettingsManager(
+ const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -691,7 +691,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
});
it('handles control methods (increase/decrease)', () => {
- const manager = new TypographySettingsManager(
+ const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -705,7 +705,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
});
it('handles control boundary conditions', () => {
- const manager = new TypographySettingsManager(
+ const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
diff --git a/src/features/SetupFont/ui/TypographyMenu/TypographyMenu.stories.svelte b/src/features/AdjustTypography/ui/TypographyMenu/TypographyMenu.stories.svelte
similarity index 100%
rename from src/features/SetupFont/ui/TypographyMenu/TypographyMenu.stories.svelte
rename to src/features/AdjustTypography/ui/TypographyMenu/TypographyMenu.stories.svelte
diff --git a/src/features/SetupFont/ui/TypographyMenu/TypographyMenu.svelte b/src/features/AdjustTypography/ui/TypographyMenu/TypographyMenu.svelte
similarity index 87%
rename from src/features/SetupFont/ui/TypographyMenu/TypographyMenu.svelte
rename to src/features/AdjustTypography/ui/TypographyMenu/TypographyMenu.svelte
index 1376cf1..1e234dd 100644
--- a/src/features/SetupFont/ui/TypographyMenu/TypographyMenu.svelte
+++ b/src/features/AdjustTypography/ui/TypographyMenu/TypographyMenu.svelte
@@ -90,11 +90,8 @@ $effect(() => {
align="end"
sideOffset={8}
class={cn(
- 'z-50 w-72',
- 'bg-surface dark:bg-dark-card',
- 'border border-subtle',
- 'shadow-[0_20px_40px_-10px_rgba(0,0,0,0.15)]',
- 'rounded-none p-4',
+ 'z-50 w-72 p-4 rounded-none',
+ 'surface-popover',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
@@ -118,7 +115,7 @@ $effect(() => {
{#snippet child({ props })}