From 40346aa9aa094a8980d52732310041aa716e2420 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Thu, 5 Feb 2026 11:38:38 +0300 Subject: [PATCH 1/7] chore(Font): move font types related to weight to common types --- src/entities/Font/model/types/common.ts | 24 +++++++++++++++++++++++ src/entities/Font/model/types/google.ts | 26 ++----------------------- 2 files changed, 26 insertions(+), 24 deletions(-) diff --git a/src/entities/Font/model/types/common.ts b/src/entities/Font/model/types/common.ts index eddcb80..bd6536e 100644 --- a/src/entities/Font/model/types/common.ts +++ b/src/entities/Font/model/types/common.ts @@ -32,3 +32,27 @@ export interface FontFilters { export type CheckboxFilter = 'providers' | 'categories' | 'subsets'; export type FilterType = CheckboxFilter | 'searchQuery'; + +/** + * Standard font weights + */ +export type FontWeight = '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900'; + +/** + * Italic variant format: e.g., "100italic", "400italic", "700italic" + */ +export type FontWeightItalic = `${FontWeight}italic`; + +/** + * All possible font variants + * - Numeric weights: "400", "700", etc. + * - Italic variants: "400italic", "700italic", etc. + * - Legacy names: "regular", "italic", "bold", "bolditalic" + */ +export type FontVariant = + | FontWeight + | FontWeightItalic + | 'regular' + | 'italic' + | 'bold' + | 'bolditalic'; diff --git a/src/entities/Font/model/types/google.ts b/src/entities/Font/model/types/google.ts index c69c54d..d42ab9f 100644 --- a/src/entities/Font/model/types/google.ts +++ b/src/entities/Font/model/types/google.ts @@ -4,6 +4,8 @@ * ============================================================================ */ +import type { FontVariant } from './common'; + export type FontCategory = 'sans-serif' | 'serif' | 'display' | 'handwriting' | 'monospace'; /** @@ -86,30 +88,6 @@ export interface FontItem { */ export type GoogleFontItem = FontItem; -/** - * Standard font weights that can appear in Google Fonts API - */ -export type FontWeight = '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900'; - -/** - * Italic variant format: e.g., "100italic", "400italic", "700italic" - */ -export type FontWeightItalic = `${FontWeight}italic`; - -/** - * All possible font variants in Google Fonts API - * - Numeric weights: "400", "700", etc. - * - Italic variants: "400italic", "700italic", etc. - * - Legacy names: "regular", "italic", "bold", "bolditalic" - */ -export type FontVariant = - | FontWeight - | FontWeightItalic - | 'regular' - | 'italic' - | 'bold' - | 'bolditalic'; - /** * Google Fonts API file mapping * Dynamic keys that match the variants array -- 2.49.1 From 9a794b626b881614107a5ab50a89f47fb2cfba78 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Thu, 5 Feb 2026 11:39:20 +0300 Subject: [PATCH 2/7] feat(normalize): use type FontVariant instead of string --- src/entities/Font/model/types/normalize.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/entities/Font/model/types/normalize.ts b/src/entities/Font/model/types/normalize.ts index 91f58eb..954b8ae 100644 --- a/src/entities/Font/model/types/normalize.ts +++ b/src/entities/Font/model/types/normalize.ts @@ -8,17 +8,18 @@ import type { FontCategory, FontProvider, FontSubset, + FontVariant, } from './common'; /** * Font variant types (standardized) */ -export type UnifiedFontVariant = string; +export type UnifiedFontVariant = FontVariant; /** * Font style URLs */ -export interface FontStyleUrls { +export interface LegacyFontStyleUrls { /** Regular weight URL */ regular?: string; /** Italic URL */ @@ -29,6 +30,10 @@ export interface FontStyleUrls { boldItalic?: string; } +export interface FontStyleUrls extends LegacyFontStyleUrls { + variants?: Partial>; +} + /** * Font metadata */ -- 2.49.1 From 0554fcada7c0092272e894a9837d580683a581a3 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Thu, 5 Feb 2026 11:39:56 +0300 Subject: [PATCH 3/7] feat(normalize): use type UnifiedFontVariant instead of string --- src/entities/Font/lib/normalize/normalize.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/entities/Font/lib/normalize/normalize.ts b/src/entities/Font/lib/normalize/normalize.ts index 981e951..3ad73e5 100644 --- a/src/entities/Font/lib/normalize/normalize.ts +++ b/src/entities/Font/lib/normalize/normalize.ts @@ -12,6 +12,7 @@ import type { FontshareFont, GoogleFontItem, UnifiedFont, + UnifiedFontVariant, } from '../../model/types'; /** @@ -186,7 +187,7 @@ export function normalizeFontshareFont(apiFont: FontshareFont): UnifiedFont { const variants = apiFont.styles.map(style => { const weightLabel = style.weight.label; const isItalic = style.is_italic; - return isItalic ? `${weightLabel}italic` : weightLabel; + return (isItalic ? `${weightLabel}italic` : weightLabel) as UnifiedFontVariant; }); // Map styles to URLs -- 2.49.1 From 8195e9baa8bc20b2109cbd8d5f6cf214f90ac4eb Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Thu, 5 Feb 2026 11:40:23 +0300 Subject: [PATCH 4/7] feat(getFontUrl): create a helper function to choose font url --- .../Font/lib/getFontUrl/getFontUrl.test.ts | 592 ++++++++++++++++++ .../Font/lib/getFontUrl/getFontUrl.ts | 29 + src/entities/Font/lib/index.ts | 2 + 3 files changed, 623 insertions(+) create mode 100644 src/entities/Font/lib/getFontUrl/getFontUrl.test.ts create mode 100644 src/entities/Font/lib/getFontUrl/getFontUrl.ts diff --git a/src/entities/Font/lib/getFontUrl/getFontUrl.test.ts b/src/entities/Font/lib/getFontUrl/getFontUrl.test.ts new file mode 100644 index 0000000..3902421 --- /dev/null +++ b/src/entities/Font/lib/getFontUrl/getFontUrl.test.ts @@ -0,0 +1,592 @@ +import { + describe, + expect, + it, +} from 'vitest'; +import type { UnifiedFont } from '../../model/types'; +import { getFontUrl } from './getFontUrl'; + +/** + * Helper function to create a minimal UnifiedFont mock for testing + */ +function createMockFont( + overrides: Partial = {}, +): UnifiedFont { + const baseFont: UnifiedFont = { + id: 'test-font', + name: 'Test Font', + provider: 'google', + category: 'sans-serif', + subsets: ['latin'], + variants: [], + styles: {}, + metadata: { + cachedAt: Date.now(), + }, + features: { + isVariable: false, + tags: [], + }, + }; + + return { ...baseFont, ...overrides }; +} + +describe('getFontUrl', () => { + describe('basic logic', () => { + it('returns URL for exact weight match in variants', () => { + const font = createMockFont({ + styles: { + variants: { + '400': 'https://example.com/font-400.woff2', + '700': 'https://example.com/font-700.woff2', + }, + }, + }); + + const result = getFontUrl(font, 400); + + expect(result).toBe('https://example.com/font-400.woff2'); + }); + + it('returns URL for weight 700', () => { + const font = createMockFont({ + styles: { + variants: { + '700': 'https://example.com/font-700.woff2', + }, + }, + }); + + const result = getFontUrl(font, 700); + + expect(result).toBe('https://example.com/font-700.woff2'); + }); + + it('returns URL for weight 100 (lightest)', () => { + const font = createMockFont({ + styles: { + variants: { + '100': 'https://example.com/font-100.woff2', + }, + }, + }); + + const result = getFontUrl(font, 100); + + expect(result).toBe('https://example.com/font-100.woff2'); + }); + + it('returns URL for weight 900 (boldest)', () => { + const font = createMockFont({ + styles: { + variants: { + '900': 'https://example.com/font-900.woff2', + }, + }, + }); + + const result = getFontUrl(font, 900); + + expect(result).toBe('https://example.com/font-900.woff2'); + }); + + it('returns URL for variable font (backend maps weight to VF URL)', () => { + const font = createMockFont({ + styles: { + variants: { + '400': 'https://example.com/font-variable.woff2', + '700': 'https://example.com/font-variable.woff2', + }, + }, + }); + + const result400 = getFontUrl(font, 400); + const result700 = getFontUrl(font, 700); + + expect(result400).toBe('https://example.com/font-variable.woff2'); + expect(result700).toBe('https://example.com/font-variable.woff2'); + }); + }); + + describe('fallback logic', () => { + it('falls back to regular when exact weight not found', () => { + const font = createMockFont({ + styles: { + regular: 'https://example.com/font-regular.woff2', + variants: { + '400': 'https://example.com/font-400.woff2', + }, + }, + }); + + const result = getFontUrl(font, 700); + + expect(result).toBe('https://example.com/font-regular.woff2'); + }); + + it('falls back to variant 400 when exact weight and regular not found', () => { + const font = createMockFont({ + styles: { + variants: { + '400': 'https://example.com/font-400.woff2', + }, + }, + }); + + const result = getFontUrl(font, 700); + + expect(result).toBe('https://example.com/font-400.woff2'); + }); + + it('falls back to variant regular when exact weight, regular, and 400 not found', () => { + const font = createMockFont({ + styles: { + variants: { + '700': 'https://example.com/font-700.woff2', + 'regular': 'https://example.com/font-regular.woff2', + }, + }, + }); + + const result = getFontUrl(font, 400); + + expect(result).toBe('https://example.com/font-regular.woff2'); + }); + + it('prefers regular over variants.400 for fallback', () => { + const font = createMockFont({ + styles: { + regular: 'https://example.com/font-regular.woff2', + variants: { + '400': 'https://example.com/font-400.woff2', + }, + }, + }); + + const result = getFontUrl(font, 700); + + expect(result).toBe('https://example.com/font-regular.woff2'); + }); + + it('returns undefined when no fallback options available', () => { + const font = createMockFont({ + styles: { + variants: { + '700': 'https://example.com/font-700.woff2', + }, + }, + }); + + const result = getFontUrl(font, 400); + + expect(result).toBeUndefined(); + }); + + it('returns undefined for font with empty styles', () => { + const font = createMockFont({ + styles: {}, + }); + + const result = getFontUrl(font, 400); + + expect(result).toBeUndefined(); + }); + + it('throws error for font with undefined styles (invalid font data)', () => { + const font = createMockFont({ + styles: undefined as any, + }); + + expect(() => getFontUrl(font, 400)).toThrow(); + }); + }); + + describe('edge cases', () => { + it('handles font with only regular URL (legacy format)', () => { + const font = createMockFont({ + styles: { + regular: 'https://example.com/font-regular.woff2', + }, + }); + + const result = getFontUrl(font, 700); + + expect(result).toBe('https://example.com/font-regular.woff2'); + }); + + it('handles font with only variants object', () => { + const font = createMockFont({ + styles: { + variants: { + '400': 'https://example.com/font-400.woff2', + '700': 'https://example.com/font-700.woff2', + }, + }, + }); + + const result400 = getFontUrl(font, 400); + const result700 = getFontUrl(font, 700); + + expect(result400).toBe('https://example.com/font-400.woff2'); + expect(result700).toBe('https://example.com/font-700.woff2'); + }); + + it('handles font with variants but no requested weight', () => { + const font = createMockFont({ + styles: { + variants: { + '400': 'https://example.com/font-400.woff2', + }, + }, + }); + + const result = getFontUrl(font, 700); + + expect(result).toBe('https://example.com/font-400.woff2'); + }); + + it('handles Google Fonts style with legacy URLs', () => { + const font = createMockFont({ + styles: { + regular: 'https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu72xKOzY.woff2', + bold: 'https://fonts.gstatic.com/s/roboto/v30/KFOlCnqEu92Fr1Mu72xWUlvAx05IsDqlA.woff2', + }, + }); + + const result = getFontUrl(font, 700); + + expect(result).toBe('https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu72xKOzY.woff2'); + }); + + it('handles Fontshare fonts with multiple weights', () => { + const font = createMockFont({ + styles: { + variants: { + '100': 'https://cdn.fontshare.com/wf/font-100.woff2', + '200': 'https://cdn.fontshare.com/wf/font-200.woff2', + '300': 'https://cdn.fontshare.com/wf/font-300.woff2', + '400': 'https://cdn.fontshare.com/wf/font-400.woff2', + '500': 'https://cdn.fontshare.com/wf/font-500.woff2', + '600': 'https://cdn.fontshare.com/wf/font-600.woff2', + '700': 'https://cdn.fontshare.com/wf/font-700.woff2', + '800': 'https://cdn.fontshare.com/wf/font-800.woff2', + '900': 'https://cdn.fontshare.com/wf/font-900.woff2', + }, + }, + }); + + // Test all valid weights + for (const weight of [100, 200, 300, 400, 500, 600, 700, 800, 900]) { + const result = getFontUrl(font, weight); + expect(result).toBe(`https://cdn.fontshare.com/wf/font-${weight}.woff2`); + } + }); + + it('handles font with partial weight coverage', () => { + const font = createMockFont({ + styles: { + variants: { + '400': 'https://example.com/font-regular.woff2', + '700': 'https://example.com/font-bold.woff2', + }, + }, + }); + + const result400 = getFontUrl(font, 400); + const result700 = getFontUrl(font, 700); + const result500 = getFontUrl(font, 500); + + expect(result400).toBe('https://example.com/font-regular.woff2'); + expect(result700).toBe('https://example.com/font-bold.woff2'); + expect(result500).toBe('https://example.com/font-regular.woff2'); // Fallback + }); + + it('handles font with variants.regular as fallback', () => { + const font = createMockFont({ + styles: { + variants: { + '700': 'https://example.com/font-bold.woff2', + 'regular': 'https://example.com/font-regular.woff2', + }, + }, + }); + + const result = getFontUrl(font, 400); + + expect(result).toBe('https://example.com/font-regular.woff2'); + }); + + it('handles empty variants object', () => { + const font = createMockFont({ + styles: { + variants: {}, + }, + }); + + const result = getFontUrl(font, 400); + + expect(result).toBeUndefined(); + }); + + it('returns undefined when variant URL is null and no fallback available', () => { + const font = createMockFont({ + styles: { + variants: { + '400': null as any, + '700': 'https://example.com/font-bold.woff2', + }, + }, + }); + + const result = getFontUrl(font, 400); + + // null is falsy, so it falls back to regular, 400, and then regular variant + // All are undefined, so returns undefined + expect(result).toBeUndefined(); + }); + }); + + describe('boundary tests', () => { + it('handles lowest valid weight (100)', () => { + const font = createMockFont({ + styles: { + variants: { + '100': 'https://example.com/font-100.woff2', + }, + }, + }); + + const result = getFontUrl(font, 100); + + expect(result).toBe('https://example.com/font-100.woff2'); + }); + + it('handles highest valid weight (900)', () => { + const font = createMockFont({ + styles: { + variants: { + '900': 'https://example.com/font-900.woff2', + }, + }, + }); + + const result = getFontUrl(font, 900); + + expect(result).toBe('https://example.com/font-900.woff2'); + }); + + it('handles middle weight (500)', () => { + const font = createMockFont({ + styles: { + variants: { + '500': 'https://example.com/font-500.woff2', + }, + }, + }); + + const result = getFontUrl(font, 500); + + expect(result).toBe('https://example.com/font-500.woff2'); + }); + }); + + describe('invalid weights', () => { + it('throws error for weight below 100', () => { + const font = createMockFont({ + styles: { + variants: { + '400': 'https://example.com/font-400.woff2', + }, + }, + }); + + expect(() => getFontUrl(font, 99)).toThrow('Invalid weight: 99'); + }); + + it('throws error for weight above 900', () => { + const font = createMockFont({ + styles: { + variants: { + '400': 'https://example.com/font-400.woff2', + }, + }, + }); + + expect(() => getFontUrl(font, 901)).toThrow('Invalid weight: 901'); + }); + + it('throws error for weight 0', () => { + const font = createMockFont({ + styles: { + variants: { + '400': 'https://example.com/font-400.woff2', + }, + }, + }); + + expect(() => getFontUrl(font, 0)).toThrow('Invalid weight: 0'); + }); + + it('throws error for negative weight', () => { + const font = createMockFont({ + styles: { + variants: { + '400': 'https://example.com/font-400.woff2', + }, + }, + }); + + expect(() => getFontUrl(font, -100)).toThrow('Invalid weight: -100'); + }); + + it('throws error for non-numeric weight', () => { + const font = createMockFont({ + styles: { + variants: { + '400': 'https://example.com/font-400.woff2', + }, + }, + }); + + // @ts-ignore - Testing invalid input type + expect(() => getFontUrl(font, '400' as any)).toThrow('Invalid weight: 400'); + }); + + it('throws error for decimal weight', () => { + const font = createMockFont({ + styles: { + variants: { + '400': 'https://example.com/font-400.woff2', + }, + }, + }); + + expect(() => getFontUrl(font, 450.5)).toThrow('Invalid weight: 450.5'); + }); + + it('throws error for weight with step of 50 (not supported)', () => { + const font = createMockFont({ + styles: { + variants: { + '400': 'https://example.com/font-400.woff2', + }, + }, + }); + + expect(() => getFontUrl(font, 450)).toThrow('Invalid weight: 450'); + }); + + it('throws error for weight with step of 10 (not supported)', () => { + const font = createMockFont({ + styles: { + variants: { + '400': 'https://example.com/font-400.woff2', + }, + }, + }); + + expect(() => getFontUrl(font, 410)).toThrow('Invalid weight: 410'); + }); + + it('throws error for NaN weight', () => { + const font = createMockFont({ + styles: { + variants: { + '400': 'https://example.com/font-400.woff2', + }, + }, + }); + + expect(() => getFontUrl(font, NaN)).toThrow('Invalid weight: NaN'); + }); + + it('throws error for Infinity weight', () => { + const font = createMockFont({ + styles: { + variants: { + '400': 'https://example.com/font-400.woff2', + }, + }, + }); + + expect(() => getFontUrl(font, Infinity)).toThrow('Invalid weight: Infinity'); + }); + + it('throws descriptive error message', () => { + const font = createMockFont({ + styles: { + variants: { + '400': 'https://example.com/font-400.woff2', + }, + }, + }); + + try { + getFontUrl(font, 999); + expect.fail('Expected function to throw'); + } catch (error) { + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe('Invalid weight: 999'); + } + }); + }); + + describe('provider-specific tests', () => { + it('handles Google Fonts with variable fonts', () => { + const font = createMockFont({ + provider: 'google', + styles: { + variants: { + '400': 'https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu72xKOzY.woff2', + '700': 'https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu72xKOzY.woff2', + }, + }, + }); + + const result400 = getFontUrl(font, 400); + const result700 = getFontUrl(font, 700); + + // Variable fonts return the same URL for all weights + expect(result400).toBe(result700); + }); + + it('handles Fontshare fonts with static weights', () => { + const font = createMockFont({ + provider: 'fontshare', + styles: { + variants: { + '400': 'https://cdn.fontshare.com/wf/satoshi-regular.woff2', + '700': 'https://cdn.fontshare.com/wf/satoshi-bold.woff2', + }, + }, + }); + + const result400 = getFontUrl(font, 400); + const result700 = getFontUrl(font, 700); + + expect(result400).toBe('https://cdn.fontshare.com/wf/satoshi-regular.woff2'); + expect(result700).toBe('https://cdn.fontshare.com/wf/satoshi-bold.woff2'); + expect(result400).not.toBe(result700); + }); + }); + + describe('all valid weights test', () => { + it('handles all valid weight values', () => { + const validWeights = [100, 200, 300, 400, 500, 600, 700, 800, 900]; + + validWeights.forEach(weight => { + const font = createMockFont({ + styles: { + variants: { + [weight.toString()]: `https://example.com/font-${weight}.woff2`, + }, + }, + }); + + const result = getFontUrl(font, weight); + expect(result).toBe(`https://example.com/font-${weight}.woff2`); + }); + }); + }); +}); diff --git a/src/entities/Font/lib/getFontUrl/getFontUrl.ts b/src/entities/Font/lib/getFontUrl/getFontUrl.ts new file mode 100644 index 0000000..667cf37 --- /dev/null +++ b/src/entities/Font/lib/getFontUrl/getFontUrl.ts @@ -0,0 +1,29 @@ +import type { + FontWeight, + UnifiedFont, +} from '../../model'; + +const SIZES = [100, 200, 300, 400, 500, 600, 700, 800, 900]; + +/** + * Constructs a URL for a font based on the provided font and weight. + * @param font - The font object. + * @param weight - The weight of the font. + * @returns The URL for the font. + */ +export function getFontUrl(font: UnifiedFont, weight: number): string | undefined { + if (!SIZES.includes(weight)) { + throw new Error(`Invalid weight: ${weight}`); + } + + const weightKey = weight.toString() as FontWeight; + + // 1. Try exact match (Backend now maps "100".."900" to VF URL if variable) + if (font.styles.variants?.[weightKey]) { + return font.styles.variants[weightKey]; + } + + // 2. Fallbacks for Static Fonts (if exact weight missing) + // Try 'regular' or '400' as safe defaults + return font.styles.regular || font.styles.variants?.['400'] || font.styles.variants?.['regular']; +} diff --git a/src/entities/Font/lib/index.ts b/src/entities/Font/lib/index.ts index d2b3e0d..8d7bad8 100644 --- a/src/entities/Font/lib/index.ts +++ b/src/entities/Font/lib/index.ts @@ -4,3 +4,5 @@ export { normalizeGoogleFont, normalizeGoogleFonts, } from './normalize/normalize'; + +export { getFontUrl } from './getFontUrl/getFontUrl'; -- 2.49.1 From 596a023d24fa70110cbf3b0047c82d9cbde91071 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Thu, 5 Feb 2026 11:40:59 +0300 Subject: [PATCH 5/7] chore: add export/import --- src/entities/Font/model/index.ts | 1 + src/entities/Font/model/store/index.ts | 5 ++++- src/entities/Font/model/types/index.ts | 6 +++--- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/entities/Font/model/index.ts b/src/entities/Font/model/index.ts index 7fa38b2..47daa00 100644 --- a/src/entities/Font/model/index.ts +++ b/src/entities/Font/model/index.ts @@ -37,6 +37,7 @@ export type { export { appliedFontsManager, createUnifiedFontStore, + type FontConfigRequest, selectedFontsStore, type UnifiedFontStore, unifiedFontStore, diff --git a/src/entities/Font/model/store/index.ts b/src/entities/Font/model/store/index.ts index cd890ba..eacd64e 100644 --- a/src/entities/Font/model/store/index.ts +++ b/src/entities/Font/model/store/index.ts @@ -14,7 +14,10 @@ export { } from './unifiedFontStore.svelte'; // Applied fonts manager (CSS loading - unchanged) -export { appliedFontsManager } from './appliedFontsStore/appliedFontsStore.svelte'; +export { + appliedFontsManager, + type FontConfigRequest, +} from './appliedFontsStore/appliedFontsStore.svelte'; // Selected fonts store (user selection - unchanged) export { selectedFontsStore } from './selectedFontsStore/selectedFontsStore.svelte'; diff --git a/src/entities/Font/model/types/index.ts b/src/entities/Font/model/types/index.ts index 80460aa..5dfa1c9 100644 --- a/src/entities/Font/model/types/index.ts +++ b/src/entities/Font/model/types/index.ts @@ -12,15 +12,15 @@ export type { FontCategory, FontProvider, FontSubset, + FontVariant, + FontWeight, + FontWeightItalic, } from './common'; // Google Fonts API types export type { FontFiles, FontItem, - FontVariant, - FontWeight, - FontWeightItalic, GoogleFontItem, GoogleFontsApiModel, } from './google'; -- 2.49.1 From adf6dc93ea5a5ec17a4116bdd346bc15de7fade5 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Thu, 5 Feb 2026 11:44:16 +0300 Subject: [PATCH 6/7] feat(appliedFontsStore): improvement that allow to use correct urls for variable fonts and fixes font weight problems --- .../appliedFontsStore.svelte.ts | 52 ++++++++++++------- .../ui/FontApplicator/FontApplicator.svelte | 29 ++++++++--- .../ui/FontVirtualList/FontVirtualList.svelte | 26 +++++++--- 3 files changed, 75 insertions(+), 32 deletions(-) diff --git a/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts b/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts index 4517c91..4629d06 100644 --- a/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts +++ b/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts @@ -52,16 +52,27 @@ class AppliedFontsManager { } } - #getFontKey(id: string, weight: number): string { - return `${id.toLowerCase()}@${weight}`; + #getFontKey(config: FontConfigRequest): string { + if (config.isVariable) { + // For variable fonts, the ID is unique enough. + // Loading "Roboto" once covers "Roboto 400" and "Roboto 700" + return `${config.id.toLowerCase()}@vf`; + } + // For static fonts, we still need weight separation + return `${config.id.toLowerCase()}@${config.weight}`; } touch(configs: FontConfigRequest[]) { const now = Date.now(); configs.forEach(config => { - const key = this.#getFontKey(config.id, config.weight); + // Pass the whole config to get key + const key = this.#getFontKey(config); + this.#usageTracker.set(key, now); + // If it's already loaded, we don't need to do anything + if (this.statuses.get(key) === 'loaded') return; + if (!this.#idToBatch.has(key) && !this.#queue.has(key)) { this.#queue.set(key, config); @@ -71,8 +82,10 @@ class AppliedFontsManager { }); } - getFontStatus(id: string, weight: number) { - return this.statuses.get(this.#getFontKey(id, weight)); + getFontStatus(id: string, weight: number, isVariable: boolean = false) { + // Construct a temp config to generate key + const key = this.#getFontKey({ id, weight, name: '', url: '', isVariable }); + return this.statuses.get(key); } #processQueue() { @@ -97,27 +110,31 @@ class AppliedFontsManager { this.statuses.set(key, 'loading'); this.#idToBatch.set(key, batchId); - // Construct the @font-face rule - // Using format('truetype') for .ttf + // If variable, allow the full weight range. + // If static, lock it to the specific weight. + const weightRule = config.isVariable + ? '100 900' // Variable range (standard coverage) + : config.weight; + const fontFormat = config.isVariable ? 'truetype-variations' : 'truetype'; + cssRules += ` - @font-face { - font-family: '${config.name}'; - src: url('${config.url}') format('truetype'); - font-weight: ${config.weight}; - font-style: normal; - font-display: swap; - } - `; + @font-face { + font-family: '${config.name}'; + src: url('${config.url}') format('${fontFormat}'); + font-weight: ${weightRule}; + font-style: normal; + font-display: swap; + } + `; }); - // Create and inject the style tag const style = document.createElement('style'); style.dataset.batchId = batchId; style.innerHTML = cssRules; document.head.appendChild(style); this.#batchElements.set(batchId, style); - // Verify loading via Font Loading API + // Use the requested weight for verification, even if the rule covers a range batchEntries.forEach(([key, config]) => { document.fonts.load(`${config.weight} 1em "${config.name}"`) .then(loaded => { @@ -126,7 +143,6 @@ class AppliedFontsManager { .catch(() => this.statuses.set(key, 'error')); }); } - #purgeUnused() { const now = Date.now(); const batchesToRemove = new Set(); diff --git a/src/entities/Font/ui/FontApplicator/FontApplicator.svelte b/src/entities/Font/ui/FontApplicator/FontApplicator.svelte index fbde458..ebfd362 100644 --- a/src/entities/Font/ui/FontApplicator/FontApplicator.svelte +++ b/src/entities/Font/ui/FontApplicator/FontApplicator.svelte @@ -26,6 +26,8 @@ interface Props { * Font weight */ weight?: number; + + isVariable?: boolean; /** * Additional classes */ @@ -36,27 +38,42 @@ interface Props { children?: Snippet; } -let { name, id, url, weight = 400, className, children }: Props = $props(); +let { name, id, url, weight = 400, isVariable = false, className, children }: Props = $props(); let element: Element; // Track if the user has actually scrolled this into view let hasEnteredViewport = $state(false); +const status = $derived(appliedFontsManager.getFontStatus(id, weight, isVariable)); $effect(() => { + if (status === 'loaded' || status === 'error') { + hasEnteredViewport = true; + return; + } + const observer = new IntersectionObserver(entries => { if (entries[0].isIntersecting) { hasEnteredViewport = true; - appliedFontsManager.touch([{ id, weight, name, url }]); - // Once it has entered, we can stop observing to save CPU + // Touch ensures it's in the queue. + // It's safe to call this even if VirtualList called it + // (Manager dedupes based on key) + appliedFontsManager.touch([{ + id, + weight, + name, + url, + isVariable, + }]); + observer.unobserve(element); } }); - observer.observe(element); + + if (element) observer.observe(element); return () => observer.disconnect(); }); -const status = $derived(appliedFontsManager.getFontStatus(id, weight)); // The "Show" condition: Element is in view AND (Font is ready OR it errored out) const shouldReveal = $derived(hasEnteredViewport && (status === 'loaded' || status === 'error')); @@ -69,7 +86,7 @@ const transitionClasses = $derived(