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 })}