From deaf38f8eca6bde9d476384bf25e48ed1c2ae501 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Fri, 16 Jan 2026 10:24:06 +0300 Subject: [PATCH 01/48] fix(Page): remove unused code and misleading comments --- src/routes/Page.svelte | 21 +-------------------- 1 file changed, 1 insertion(+), 20 deletions(-) diff --git a/src/routes/Page.svelte b/src/routes/Page.svelte index dae8224..b2ae25e 100644 --- a/src/routes/Page.svelte +++ b/src/routes/Page.svelte @@ -1,27 +1,8 @@ - +
From b0812ff606c79ae3ee69965013931e2665d5ae19 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Fri, 16 Jan 2026 12:24:30 +0300 Subject: [PATCH 02/48] chore: delete unused code --- .../createDebouncedState.svelte.ts | 33 ++----------------- 1 file changed, 2 insertions(+), 31 deletions(-) diff --git a/src/shared/lib/helpers/createDebouncedState/createDebouncedState.svelte.ts b/src/shared/lib/helpers/createDebouncedState/createDebouncedState.svelte.ts index 2093514..047f676 100644 --- a/src/shared/lib/helpers/createDebouncedState/createDebouncedState.svelte.ts +++ b/src/shared/lib/helpers/createDebouncedState/createDebouncedState.svelte.ts @@ -14,7 +14,8 @@ export function createDebouncedState(initialValue: T, wait: number = 300) { }, set immediate(value: T) { immediate = value; - updateDebounced(value); // Manually trigger the debounce on write + // Manually trigger the debounce on write + updateDebounced(value); }, get debounced() { return debounced; @@ -26,33 +27,3 @@ export function createDebouncedState(initialValue: T, wait: number = 300) { }, }; } - -// export function createDebouncedState(initialValue: T, wait: number = 300) { -// let immediate = $state(initialValue); -// let debounced = $state(initialValue); - -// const updateDebounced = debounce((value: T) => { -// debounced = value; -// }, wait); - -// $effect(() => { -// updateDebounced(immediate); -// }); - -// return { -// get immediate() { -// return immediate; -// }, -// set immediate(value: T) { -// immediate = value; -// }, -// get debounced() { -// return debounced; -// }, -// reset(value?: T) { -// const resetValue = value ?? initialValue; -// immediate = resetValue; -// debounced = resetValue; -// }, -// }; -// } From 86adec01a013ef2bd7f4b87ea2de8fa06906d150 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Fri, 16 Jan 2026 12:27:14 +0300 Subject: [PATCH 03/48] doc(createVirtualizer): add JSDoc for createVirtualizer --- .../createVirtualizer.svelte.ts | 148 +++++++++++++++--- 1 file changed, 122 insertions(+), 26 deletions(-) diff --git a/src/shared/lib/helpers/createVirtualizer/createVirtualizer.svelte.ts b/src/shared/lib/helpers/createVirtualizer/createVirtualizer.svelte.ts index 5d0fcec..3482292 100644 --- a/src/shared/lib/helpers/createVirtualizer/createVirtualizer.svelte.ts +++ b/src/shared/lib/helpers/createVirtualizer/createVirtualizer.svelte.ts @@ -1,3 +1,84 @@ +/** + * Represents a virtualized list item with layout information. + * + * Used to render visible items with absolute positioning based on computed offsets. + */ +export interface VirtualItem { + /** Index of the item in the data array */ + index: number; + /** Offset from the top of the list in pixels */ + start: number; + /** Height/size of the item in pixels */ + size: number; + /** End position in pixels (start + size) */ + end: number; + /** Unique key for the item (for Svelte's {#each} keying) */ + key: string | number; +} + +/** + * Configuration options for {@link createVirtualizer}. + * + * Options are reactive - pass them through a function getter to enable updates. + */ +export interface VirtualizerOptions { + /** Total number of items in the data array */ + count: number; + /** + * Function to estimate the size of an item at a given index. + * Used for initial layout before actual measurements are available. + */ + estimateSize: (index: number) => number; + /** Number of extra items to render outside viewport for smoother scrolling (default: 5) */ + overscan?: number; + /** + * Function to get the key of an item at a given index. + * Defaults to using the index directly. Useful for stable keys when items reorder. + */ + getItemKey?: (index: number) => string | number; + /** + * Optional margin in pixels for scroll calculations. + * Can be useful for handling sticky headers or other UI elements. + */ + scrollMargin?: number; +} + +/** + * Creates a reactive virtualizer for efficiently rendering large lists by only rendering visible items. + * + * Uses Svelte 5 runes ($state, $derived) for reactive state management and optimizes rendering + * through scroll position tracking and item height measurement. Supports dynamic item heights + * and programmatic scrolling. + * + * @param optionsGetter - Function that returns reactive virtualizer options + * @returns Virtualizer instance with computed properties and action functions + * + * @example + * ```svelte + * + * + *
+ *
+ * {#each virtualizer.items as item (item.key)} + *
+ * Item {item.index} + *
+ * {/each} + *
+ *
+ * ``` + */ export function createVirtualizer(optionsGetter: () => VirtualizerOptions) { // Reactive State let scrollOffset = $state(0); @@ -71,6 +152,15 @@ export function createVirtualizer(optionsGetter: () => VirtualizerOptions) { }); // Svelte Actions (The DOM Interface) + + /** + * Svelte action to attach to the scrollable container element. + * + * Sets up scroll tracking, container height monitoring, and cleanup on destroy. + * + * @param node - The DOM element to attach to (should be the scrollable container) + * @returns Object with destroy method for cleanup + */ function container(node: HTMLElement) { elementRef = node; containerHeight = node.offsetHeight; @@ -95,6 +185,15 @@ export function createVirtualizer(optionsGetter: () => VirtualizerOptions) { }; } + /** + * Svelte action to measure individual item elements for dynamic height support. + * + * Attaches a ResizeObserver to track actual element height and updates + * measured sizes when dimensions change. Requires `data-index` attribute on the element. + * + * @param node - The DOM element to measure (should have `data-index` attribute) + * @returns Object with destroy method for cleanup + */ function measureElement(node: HTMLElement) { // Use a ResizeObserver on individual items for dynamic height support const resizeObserver = new ResizeObserver(([entry]) => { @@ -116,6 +215,18 @@ export function createVirtualizer(optionsGetter: () => VirtualizerOptions) { } // Programmatic Scroll + + /** + * Scrolls the container to bring the specified item into view. + * + * @param index - Index of the item to scroll to + * @param align - Scroll alignment: 'start', 'center', 'end', or 'auto' (default) + * + * @example + * ```ts + * virtualizer.scrollToIndex(50, 'center'); // Scroll to item 50 and center it + * ``` + */ function scrollToIndex(index: number, align: 'start' | 'center' | 'end' | 'auto' = 'auto') { if (!elementRef || index < 0 || index >= options.count) return; @@ -130,42 +241,27 @@ export function createVirtualizer(optionsGetter: () => VirtualizerOptions) { } return { + /** Computed array of visible items to render (reactive) */ get items() { return items; }, + /** Total height of all items in pixels (reactive) */ get totalSize() { return totalSize; }, + /** Svelte action for the scrollable container element */ container, + /** Svelte action for measuring individual item elements */ measureElement, + /** Programmatic scroll method to scroll to a specific item */ scrollToIndex, }; } -export interface VirtualItem { - /** Index of the item in the data array */ - index: number; - /** Offset from the top of the list */ - start: number; - /** Height of the item */ - size: number; - /** End position (start + size) */ - end: number; - /** Unique key for the item (for Svelte's {#each} keying) */ - key: string | number; -} - -export interface VirtualizerOptions { - /** Total number of items in the data array */ - count: number; - /** Function to estimate the size of an item at a given index */ - estimateSize: (index: number) => number; - /** Number of extra items to render outside viewport (default: 5) */ - overscan?: number; - /** Function to get the key of an item at a given index (defaults to index) */ - getItemKey?: (index: number) => string | number; - /** Optional margin in pixels for scroll calculations */ - scrollMargin?: number; -} - +/** + * Virtualizer instance returned by {@link createVirtualizer}. + * + * Provides reactive computed properties for visible items and total size, + * along with action functions for DOM integration and element measurement. + */ export type Virtualizer = ReturnType; From 42e941083a7f6292b430d953a3ed1e6b4b1da208 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Fri, 16 Jan 2026 12:38:57 +0300 Subject: [PATCH 04/48] doc(createDeboucnedState): add JSDoc for createDebouncedState --- .../createDebouncedState.svelte.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/shared/lib/helpers/createDebouncedState/createDebouncedState.svelte.ts b/src/shared/lib/helpers/createDebouncedState/createDebouncedState.svelte.ts index 047f676..93840cc 100644 --- a/src/shared/lib/helpers/createDebouncedState/createDebouncedState.svelte.ts +++ b/src/shared/lib/helpers/createDebouncedState/createDebouncedState.svelte.ts @@ -1,5 +1,28 @@ import { debounce } from '$shared/lib/utils'; +/** + * Creates reactive state with immediate and debounced values. + * + * Useful for UI inputs that need instant feedback but debounced logic + * (e.g., search fields with API calls). The immediate value updates on + * every change for UI binding, while debounced updates after a delay. + * + * @param initialValue - Initial value for both immediate and debounced state + * @param wait - Delay in milliseconds before updating debounced value (default: 300) + * @returns Object with immediate/debounced getters, immediate setter, and reset method + * + * @example + * ```svelte + * + * + * + * + *

Typing: {search.immediate}

+ *

Searching: {search.debounced}

+ * ``` + */ export function createDebouncedState(initialValue: T, wait: number = 300) { let immediate = $state(initialValue); let debounced = $state(initialValue); @@ -9,6 +32,7 @@ export function createDebouncedState(initialValue: T, wait: number = 300) { }, wait); return { + /** Current value with immediate updates (for UI binding) */ get immediate() { return immediate; }, @@ -17,9 +41,14 @@ export function createDebouncedState(initialValue: T, wait: number = 300) { // Manually trigger the debounce on write updateDebounced(value); }, + /** Current value with debounced updates (for logic/operations) */ get debounced() { return debounced; }, + /** + * Resets both values to initial or specified value. + * @param value - Optional value to reset to (defaults to initialValue) + */ reset(value?: T) { const resetValue = value ?? initialValue; immediate = resetValue; From f3de6c49a330933b106465b4a6ecc4d8f3ae4bf7 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Fri, 16 Jan 2026 12:41:30 +0300 Subject: [PATCH 05/48] chore: delete unused code --- src/shared/lib/fetch/collectionCache.test.ts | 444 ------------------- src/shared/lib/fetch/collectionCache.ts | 334 -------------- src/shared/lib/fetch/index.ts | 14 - src/shared/lib/fetch/reactiveQueryArgs.ts | 37 -- 4 files changed, 829 deletions(-) delete mode 100644 src/shared/lib/fetch/collectionCache.test.ts delete mode 100644 src/shared/lib/fetch/collectionCache.ts delete mode 100644 src/shared/lib/fetch/index.ts delete mode 100644 src/shared/lib/fetch/reactiveQueryArgs.ts diff --git a/src/shared/lib/fetch/collectionCache.test.ts b/src/shared/lib/fetch/collectionCache.test.ts deleted file mode 100644 index 090c951..0000000 --- a/src/shared/lib/fetch/collectionCache.test.ts +++ /dev/null @@ -1,444 +0,0 @@ -import { get } from 'svelte/store'; -import { - beforeEach, - describe, - expect, - it, - vi, -} from 'vitest'; -import { - type CacheOptions, - createCollectionCache, -} from './collectionCache'; - -describe('createCollectionCache', () => { - let cache: ReturnType>; - - beforeEach(() => { - cache = createCollectionCache(); - }); - - describe('initialization', () => { - it('initializes with empty cache', () => { - const data = get(cache.data); - expect(data).toEqual({}); - }); - - it('initializes with default options', () => { - const stats = cache.getStats(); - expect(stats.total).toBe(0); - expect(stats.cached).toBe(0); - expect(stats.fetching).toBe(0); - expect(stats.errors).toBe(0); - expect(stats.hits).toBe(0); - expect(stats.misses).toBe(0); - }); - - it('accepts custom cache options', () => { - const options: CacheOptions = { - defaultTTL: 10 * 60 * 1000, // 10 minutes - maxSize: 500, - }; - const customCache = createCollectionCache(options); - expect(customCache).toBeDefined(); - }); - }); - - describe('set and get', () => { - it('sets a value in cache', () => { - cache.set('key1', 100); - const value = cache.get('key1'); - expect(value).toBe(100); - }); - - it('sets multiple values in cache', () => { - cache.set('key1', 100); - cache.set('key2', 200); - cache.set('key3', 300); - - expect(cache.get('key1')).toBe(100); - expect(cache.get('key2')).toBe(200); - expect(cache.get('key3')).toBe(300); - }); - - it('updates existing value', () => { - cache.set('key1', 100); - cache.set('key1', 150); - expect(cache.get('key1')).toBe(150); - }); - - it('returns undefined for non-existent key', () => { - const value = cache.get('non-existent'); - expect(value).toBeUndefined(); - }); - - it('marks item as ready after set', () => { - cache.set('key1', 100); - const internalState = cache.getInternalState('key1'); - expect(internalState?.ready).toBe(true); - expect(internalState?.fetching).toBe(false); - }); - }); - - describe('has and hasFresh', () => { - it('returns false for non-existent key', () => { - expect(cache.has('non-existent')).toBe(false); - expect(cache.hasFresh('non-existent')).toBe(false); - }); - - it('returns true after setting value', () => { - cache.set('key1', 100); - expect(cache.has('key1')).toBe(true); - expect(cache.hasFresh('key1')).toBe(true); - }); - - it('returns false for fetching items', () => { - cache.markFetching('key1'); - expect(cache.has('key1')).toBe(false); - expect(cache.hasFresh('key1')).toBe(false); - }); - - it('returns false for failed items', () => { - cache.markFailed('key1', 'Network error'); - expect(cache.has('key1')).toBe(false); - expect(cache.hasFresh('key1')).toBe(false); - }); - }); - - describe('remove', () => { - it('removes a value from cache', () => { - cache.set('key1', 100); - cache.set('key2', 200); - - cache.remove('key1'); - - expect(cache.get('key1')).toBeUndefined(); - expect(cache.get('key2')).toBe(200); - }); - - it('removes internal state', () => { - cache.set('key1', 100); - cache.remove('key1'); - const state = cache.getInternalState('key1'); - expect(state).toBeUndefined(); - }); - - it('does nothing for non-existent key', () => { - expect(() => cache.remove('non-existent')).not.toThrow(); - }); - }); - - describe('clear', () => { - it('clears all values from cache', () => { - cache.set('key1', 100); - cache.set('key2', 200); - cache.set('key3', 300); - - cache.clear(); - - expect(cache.get('key1')).toBeUndefined(); - expect(cache.get('key2')).toBeUndefined(); - expect(cache.get('key3')).toBeUndefined(); - }); - - it('clears internal state', () => { - cache.set('key1', 100); - cache.clear(); - - const state = cache.getInternalState('key1'); - expect(state).toBeUndefined(); - }); - - it('resets cache statistics', () => { - cache.set('key1', 100); // This increments hits - const _statsBefore = cache.getStats(); - - cache.clear(); - const statsAfter = cache.getStats(); - - expect(statsAfter.hits).toBe(0); - expect(statsAfter.misses).toBe(0); - }); - }); - - describe('markFetching', () => { - it('marks item as fetching', () => { - cache.markFetching('key1'); - - expect(cache.isFetching('key1')).toBe(true); - - const state = cache.getInternalState('key1'); - expect(state?.fetching).toBe(true); - expect(state?.ready).toBe(false); - expect(state?.startTime).toBeDefined(); - }); - - it('updates existing state when called again', () => { - cache.markFetching('key1'); - const startTime1 = cache.getInternalState('key1')?.startTime; - - // Wait a bit to ensure different timestamp - vi.useFakeTimers(); - vi.advanceTimersByTime(100); - - cache.markFetching('key1'); - const startTime2 = cache.getInternalState('key1')?.startTime; - - expect(startTime2).toBeGreaterThan(startTime1!); - vi.useRealTimers(); - }); - - it('sets endTime to undefined', () => { - cache.markFetching('key1'); - const state = cache.getInternalState('key1'); - expect(state?.endTime).toBeUndefined(); - }); - }); - - describe('markFailed', () => { - it('marks item as failed with error message', () => { - cache.markFailed('key1', 'Network error'); - - expect(cache.isFetching('key1')).toBe(false); - - const error = cache.getError('key1'); - expect(error).toBe('Network error'); - - const state = cache.getInternalState('key1'); - expect(state?.fetching).toBe(false); - expect(state?.ready).toBe(false); - expect(state?.error).toBe('Network error'); - }); - - it('preserves start time from fetching state', () => { - cache.markFetching('key1'); - const startTime = cache.getInternalState('key1')?.startTime; - - cache.markFailed('key1', 'Error'); - - const state = cache.getInternalState('key1'); - expect(state?.startTime).toBe(startTime); - }); - - it('sets end time', () => { - cache.markFailed('key1', 'Error'); - const state = cache.getInternalState('key1'); - expect(state?.endTime).toBeDefined(); - }); - - it('increments error counter', () => { - const statsBefore = cache.getStats(); - - cache.markFailed('key1', 'Error1'); - const statsAfter1 = cache.getStats(); - expect(statsAfter1.errors).toBe(statsBefore.errors + 1); - - cache.markFailed('key2', 'Error2'); - const statsAfter2 = cache.getStats(); - expect(statsAfter2.errors).toBe(statsAfter1.errors + 1); - }); - }); - - describe('markMiss', () => { - it('increments miss counter', () => { - const statsBefore = cache.getStats(); - - cache.markMiss(); - - const statsAfter = cache.getStats(); - expect(statsAfter.misses).toBe(statsBefore.misses + 1); - }); - - it('increments miss counter multiple times', () => { - const statsBefore = cache.getStats(); - - cache.markMiss(); - cache.markMiss(); - cache.markMiss(); - - const statsAfter = cache.getStats(); - expect(statsAfter.misses).toBe(statsBefore.misses + 3); - }); - }); - - describe('statistics', () => { - it('tracks total number of items', () => { - expect(cache.getStats().total).toBe(0); - - cache.set('key1', 100); - expect(cache.getStats().total).toBe(1); - - cache.set('key2', 200); - expect(cache.getStats().total).toBe(2); - - cache.remove('key1'); - expect(cache.getStats().total).toBe(1); - }); - - it('tracks number of cached (ready) items', () => { - expect(cache.getStats().cached).toBe(0); - - cache.set('key1', 100); - expect(cache.getStats().cached).toBe(1); - - cache.set('key2', 200); - expect(cache.getStats().cached).toBe(2); - - cache.markFetching('key3'); - expect(cache.getStats().cached).toBe(2); - }); - - it('tracks number of fetching items', () => { - expect(cache.getStats().fetching).toBe(0); - - cache.markFetching('key1'); - expect(cache.getStats().fetching).toBe(1); - - cache.markFetching('key2'); - expect(cache.getStats().fetching).toBe(2); - - cache.set('key1', 100); - expect(cache.getStats().fetching).toBe(1); - }); - - it('tracks cache hits', () => { - const statsBefore = cache.getStats(); - - cache.set('key1', 100); - const statsAfter1 = cache.getStats(); - expect(statsAfter1.hits).toBe(statsBefore.hits + 1); - - cache.set('key2', 200); - const statsAfter2 = cache.getStats(); - expect(statsAfter2.hits).toBe(statsAfter1.hits + 1); - }); - - it('provides derived stats store', () => { - cache.set('key1', 100); - cache.markFetching('key2'); - - const stats = get(cache.stats); - expect(stats.total).toBe(1); - expect(stats.cached).toBe(1); - expect(stats.fetching).toBe(1); - }); - }); - - describe('store reactivity', () => { - it('updates data store reactively', () => { - let dataUpdates = 0; - const unsubscribe = cache.data.subscribe(() => { - dataUpdates++; - }); - - cache.set('key1', 100); - cache.set('key2', 200); - - expect(dataUpdates).toBeGreaterThan(0); - unsubscribe(); - }); - - it('updates internal state store reactively', () => { - let internalUpdates = 0; - const unsubscribe = cache.internal.subscribe(() => { - internalUpdates++; - }); - - cache.markFetching('key1'); - cache.set('key1', 100); - cache.markFailed('key2', 'Error'); - - expect(internalUpdates).toBeGreaterThan(0); - unsubscribe(); - }); - - it('updates stats store reactively', () => { - let statsUpdates = 0; - const unsubscribe = cache.stats.subscribe(() => { - statsUpdates++; - }); - - cache.set('key1', 100); - cache.markMiss(); - - expect(statsUpdates).toBeGreaterThan(0); - unsubscribe(); - }); - }); - - describe('edge cases', () => { - it('handles complex types', () => { - interface ComplexType { - id: string; - value: number; - tags: string[]; - } - - const complexCache = createCollectionCache(); - const item: ComplexType = { - id: '1', - value: 42, - tags: ['a', 'b', 'c'], - }; - - complexCache.set('item1', item); - const retrieved = complexCache.get('item1'); - - expect(retrieved).toEqual(item); - expect(retrieved?.tags).toEqual(['a', 'b', 'c']); - }); - - it('handles special characters in keys', () => { - cache.set('key with spaces', 1); - cache.set('key/with/slashes', 2); - cache.set('key-with-dashes', 3); - - expect(cache.get('key with spaces')).toBe(1); - expect(cache.get('key/with/slashes')).toBe(2); - expect(cache.get('key-with-dashes')).toBe(3); - }); - - it('handles rapid set and remove operations', () => { - for (let i = 0; i < 100; i++) { - cache.set(`key${i}`, i); - } - - for (let i = 0; i < 100; i += 2) { - cache.remove(`key${i}`); - } - - expect(cache.getStats().total).toBe(50); - expect(cache.get('key0')).toBeUndefined(); - expect(cache.get('key1')).toBe(1); - }); - }); - - describe('error handling', () => { - it('handles concurrent markFetching for same key', () => { - cache.markFetching('key1'); - cache.markFetching('key1'); - - const state = cache.getInternalState('key1'); - expect(state?.fetching).toBe(true); - expect(state?.startTime).toBeDefined(); - }); - - it('handles marking failed without prior fetching', () => { - cache.markFailed('key1', 'Error'); - - const state = cache.getInternalState('key1'); - expect(state?.fetching).toBe(false); - expect(state?.ready).toBe(false); - expect(state?.error).toBe('Error'); - }); - - it('handles operations on removed keys', () => { - cache.set('key1', 100); - cache.remove('key1'); - - expect(() => cache.set('key1', 200)).not.toThrow(); - expect(() => cache.remove('key1')).not.toThrow(); - expect(() => cache.getError('key1')).not.toThrow(); - }); - }); -}); diff --git a/src/shared/lib/fetch/collectionCache.ts b/src/shared/lib/fetch/collectionCache.ts deleted file mode 100644 index 4182d1e..0000000 --- a/src/shared/lib/fetch/collectionCache.ts +++ /dev/null @@ -1,334 +0,0 @@ -/** - * Collection cache manager - * - * Provides key-based caching, deduplication, and request tracking - * for any collection type. Integrates with Svelte stores for reactive updates. - * - * Key features: - * - Key-based caching (any ID, query hash) - * - Request deduplication (prevents concurrent requests for same key) - * - Request state tracking (fetching, ready, error) - * - TTL/staleness management - * - Performance timing tracking - */ - -import type { - Readable, - Writable, -} from 'svelte/store'; -import { - derived, - get, - writable, -} from 'svelte/store'; - -/** - * Internal state for a cached item - * Tracks request lifecycle (fetching → ready/error) - */ -export interface CacheItemInternalState { - /** Whether a fetch is currently in progress */ - fetching: boolean; - /** Whether data is ready and cached */ - ready: boolean; - /** Error message if fetch failed */ - error?: string; - /** Request start timestamp (performance tracking) */ - startTime?: number; - /** Request end timestamp (performance tracking) */ - endTime?: number; -} - -/** - * Cache configuration options - */ -export interface CacheOptions { - /** Default time-to-live for cached items (in milliseconds) */ - defaultTTL?: number; - /** Maximum number of items to cache (LRU eviction) */ - maxSize?: number; -} - -/** - * Statistics about cache performance - */ -export interface CacheStats { - /** Total number of items in cache */ - total: number; - /** Number of items marked as ready */ - cached: number; - /** Number of items currently fetching */ - fetching: number; - /** Number of items with errors */ - errors: number; - /** Total cache hits (data returned from cache) */ - hits: number; - /** Total cache misses (data fetched from API) */ - misses: number; -} - -/** - * Cache manager interface - * Type-safe interface for collection caching operations - */ -export interface CollectionCacheManager { - /** Get an item from cache by key */ - get: (key: string) => T | undefined; - /** Check if item exists in cache and is ready */ - has: (key: string) => boolean; - /** Check if item exists and is not stale */ - hasFresh: (key: string) => boolean; - /** Set an item in cache (manual cache write) */ - set: (key: string, value: T, ttl?: number) => void; - /** Remove item from cache */ - remove: (key: string) => void; - /** Clear all items from cache */ - clear: () => void; - /** Check if key is currently being fetched */ - isFetching: (key: string) => boolean; - /** Get error for a key */ - getError: (key: string) => string | undefined; - /** Get internal state for a key (for debugging) */ - getInternalState: (key: string) => CacheItemInternalState | undefined; - /** Get cache statistics */ - getStats: () => CacheStats; - /** Mark item as fetching (used when starting API request) */ - markFetching: (key: string) => void; - /** Mark item as failed (used when API request fails) */ - markFailed: (key: string, error: string) => void; - /** Increment cache miss counter */ - markMiss: () => void; - /** Store containing cached data */ - data: Writable>; - /** Store containing internal state (fetching, ready, error) */ - internal: Writable>; - /** Derived store containing cache statistics */ - stats: Readable; -} - -/** - * Creates a collection cache manager - * - * @typeParam T - Type of data being cached (e.g., UnifiedFont, Product, User) - * @param options - Cache configuration options - * @returns Cache manager instance - * - * @example - * ```ts - * const fontCache = createCollectionCache({ - * defaultTTL: 5 * 60 * 1000, // 5 minutes - * maxSize: 1000 - * }); - * - * // Set font in cache - * fontCache.set('Roboto', robotoFont); - * - * // Get font from cache - * const font = fontCache.get('Roboto'); - * if (fontCache.hasFresh('Roboto')) { - * // Use cached font - * } - * ``` - */ -export function createCollectionCache(_options: CacheOptions = {}): CollectionCacheManager { - // const { defaultTTL = 5 * 60 * 1000, maxSize = 1000 } = options; - - // Stores for reactive data - const data: Writable> = writable({}); - const internal: Writable> = writable({}); - - // Cache statistics store - const statsState = writable({ - total: 0, - cached: 0, - fetching: 0, - errors: 0, - hits: 0, - misses: 0, - }); - - // Derived stats store for reactive updates - const stats = derived([data, internal, statsState], ([$data, $internal, $statsState]) => ({ - ...$statsState, - total: Object.keys($data).length, - cached: Object.values($internal).filter(s => s.ready).length, - fetching: Object.values($internal).filter(s => s.fetching).length, - errors: Object.values($internal).filter(s => s.error).length, - })); - - return { - /** - * Get cached data by key - * Returns undefined if not found - */ - get: (key: string) => { - const currentData = get(data); - return currentData[key]; - }, - - /** - * Check if key exists in cache and is ready - */ - has: (key: string) => { - const currentInternal = get(internal); - const state = currentInternal[key]; - return state?.ready === true; - }, - - /** - * Check if key exists and is not stale (still within TTL) - */ - hasFresh: (key: string) => { - const currentInternal = get(internal); - const currentData = get(data); - - const state = currentInternal[key]; - if (!state?.ready) { - return false; - } - - // Check if item exists in data store - if (!currentData[key]) { - return false; - } - - // TODO: Implement TTL check with cachedAt timestamps - // For now, just check ready state - return true; - }, - - /** - * Set data in cache - * Marks entry as ready and stops fetching state - */ - set: (key: string, value: T, _ttl?: number) => { - data.update(d => ({ - ...d, - [key]: value, - })); - - internal.update(i => { - const existingState = i[key]; - return { - ...i, - [key]: { - fetching: false, - ready: true, - error: undefined, - startTime: existingState?.startTime, - endTime: Date.now(), - }, - }; - }); - - // Update statistics (cache hit) - statsState.update(s => ({ ...s, hits: s.hits + 1 })); - }, - - /** - * Remove item from cache - */ - remove: (key: string) => { - data.update(d => { - const { [key]: _, ...rest } = d; - return rest; - }); - - internal.update(i => { - const { [key]: _, ...rest } = i; - return rest; - }); - }, - - /** - * Clear all items from cache - */ - clear: () => { - data.set({}); - internal.set({}); - statsState.update(s => ({ ...s, hits: 0, misses: 0 })); - }, - - /** - * Check if key is currently being fetched - */ - isFetching: (key: string) => { - const currentInternal = get(internal); - return currentInternal[key]?.fetching === true; - }, - - /** - * Get error for a key - */ - getError: (key: string) => { - const currentInternal = get(internal); - return currentInternal[key]?.error; - }, - - /** - * Get internal state for debugging - */ - getInternalState: (key: string) => { - const currentInternal = get(internal); - return currentInternal[key]; - }, - - /** - * Get current cache statistics - */ - getStats: () => { - return get(stats); - }, - - /** - * Mark item as fetching (used when starting API request) - */ - markFetching: (key: string) => { - internal.update(internal => ({ - ...internal, - [key]: { - fetching: true, - ready: false, - error: undefined, - startTime: Date.now(), - endTime: undefined, - }, - })); - }, - - /** - * Mark item as failed (used when API request fails) - */ - markFailed: (key: string, error: string) => { - internal.update(internal => { - const existingState = internal[key]; - return { - ...internal, - [key]: { - fetching: false, - ready: false, - error, - startTime: existingState?.startTime, - endTime: Date.now(), - }, - }; - }); - - // Update statistics - const currentStats = get(stats); - statsState.update(s => ({ ...s, errors: currentStats.errors + 1 })); - }, - - /** - * Increment cache miss counter - */ - markMiss: () => { - statsState.update(s => ({ ...s, misses: s.misses + 1 })); - }, - - // Expose stores for reactive binding - data, - internal, - stats, - }; -} diff --git a/src/shared/lib/fetch/index.ts b/src/shared/lib/fetch/index.ts deleted file mode 100644 index b123ac0..0000000 --- a/src/shared/lib/fetch/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Shared fetch layer exports - * - * Exports collection caching utilities and reactive patterns for Svelte 5 - */ - -export { createCollectionCache } from './collectionCache'; -export type { - CacheItemInternalState, - CacheOptions, - CacheStats, - CollectionCacheManager, -} from './collectionCache'; -export { reactiveQueryArgs } from './reactiveQueryArgs'; diff --git a/src/shared/lib/fetch/reactiveQueryArgs.ts b/src/shared/lib/fetch/reactiveQueryArgs.ts deleted file mode 100644 index 4095ead..0000000 --- a/src/shared/lib/fetch/reactiveQueryArgs.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { Readable } from 'svelte/store'; -import { writable } from 'svelte/store'; - -/** - * Creates a reactive store that maintains stable references for query arguments - * - * This function wraps a callback in a Svelte store that updates via `$effect.pre()`, - * ensuring that the callback is called before DOM updates while maintaining object - * reference stability. - * - * @typeParam T - Type of query arguments (e.g., CreateQueryOptions) - * @param cb - Callback function that computes query arguments - * @returns Readable store containing current query arguments - * - * @example - * ```ts - * const queryArgsStore = reactiveQueryArgs(() => ({ - * queryKey: ['fonts', search], - * queryFn: fetchFonts, - * staleTime: 5000 - * })); - * - * // Use in component with TanStack Query - * const query = createQuery(queryArgsStore); - * ``` - */ -export const reactiveQueryArgs = (cb: () => T): Readable => { - const store = writable(); - - // Use $effect.pre() to run before DOM updates - // This ensures stable references while staying reactive - $effect.pre(() => { - store.set(cb()); - }); - - return store; -}; From 1976affdff2ed8fe04d7e88a84168221384255e8 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Fri, 16 Jan 2026 13:14:33 +0300 Subject: [PATCH 06/48] fix(tsconfig): add noEmit param to awoid errors --- tsconfig.json | 1 + 1 file changed, 1 insertion(+) diff --git a/tsconfig.json b/tsconfig.json index 1c223e3..c215265 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,6 +10,7 @@ /* Strictness & Safety */ "strict": true, "allowJs": true, + "noEmit": true, "checkJs": true, "esModuleInterop": true, "forceConsistentCasingInFileNames": true, From 53c71a437f93cc8f44f6ace55a86ebcf260603b3 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Fri, 16 Jan 2026 13:14:54 +0300 Subject: [PATCH 07/48] chore: simplify scripts --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 99594e4..3bdf137 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "build": "vite build", "preview": "vite preview", "prepare": "svelte-check --tsconfig ./tsconfig.json || echo ''", - "check": "svelte-check --tsconfig ./tsconfig.json", + "check": "svelte-check", "check:watch": "svelte-check --tsconfig ./tsconfig.json --watch", "check:shadcn-excluded": "svelte-check --no-tsconfig --ignore \"src/shared/shadcn\"", "lint": "oxlint", @@ -23,7 +23,7 @@ "test:component": "vitest run --config vitest.config.component.ts", "test:component:browser": "vitest run --config vitest.config.browser.ts", "test:component:browser:watch": "vitest --config vitest.config.browser.ts", - "test": "npm run test:e2e && npm run test:unit", + "test": "yarn run test:unit", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build" }, From 62ae0799cc658e9bd3013b20acaf39ee005f4fa2 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Fri, 16 Jan 2026 13:15:10 +0300 Subject: [PATCH 08/48] chore(lib): add export --- src/shared/lib/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/shared/lib/index.ts b/src/shared/lib/index.ts index 23ce93e..7d971c1 100644 --- a/src/shared/lib/index.ts +++ b/src/shared/lib/index.ts @@ -1,6 +1,7 @@ export { type ControlDataModel, type ControlModel, + createDebouncedState, createFilter, createTypographyControl, createVirtualizer, From 4c8b5764b3c63cb9b3b181e26c4a3cd03e68c47d Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Fri, 16 Jan 2026 13:58:50 +0300 Subject: [PATCH 09/48] chore: delete unused code --- vitest.config.browser.ts | 46 -------------------------------------- vitest.config.component.ts | 46 -------------------------------------- 2 files changed, 92 deletions(-) delete mode 100644 vitest.config.browser.ts delete mode 100644 vitest.config.component.ts diff --git a/vitest.config.browser.ts b/vitest.config.browser.ts deleted file mode 100644 index 39777e7..0000000 --- a/vitest.config.browser.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { svelte } from '@sveltejs/vite-plugin-svelte'; -import { playwright } from '@vitest/browser-playwright'; -import path from 'node:path'; -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - plugins: [svelte()], - - test: { - name: 'component-browser', - include: ['src/**/*.svelte.test.ts', 'src/**/*.svelte.test.js'], - exclude: [ - 'node_modules', - 'dist', - 'e2e', - '.storybook', - 'src/shared/shadcn/**/*', - ], - testTimeout: 10000, - hookTimeout: 10000, - restoreMocks: true, - setupFiles: ['./vitest.setup.component.ts'], - globals: false, - // Use browser environment with Playwright (Vitest 4 format) - browser: { - enabled: true, - headless: true, - provider: playwright(), - instances: [{ browser: 'chromium' }], - screenshotFailures: true, - screenshotDirectory: '.playwright/screenshots', - }, - }, - - resolve: { - alias: { - $lib: path.resolve(__dirname, './src/lib'), - $app: path.resolve(__dirname, './src/app'), - $shared: path.resolve(__dirname, './src/shared'), - $entities: path.resolve(__dirname, './src/entities'), - $features: path.resolve(__dirname, './src/features'), - $routes: path.resolve(__dirname, './src/routes'), - $widgets: path.resolve(__dirname, './src/widgets'), - }, - }, -}); diff --git a/vitest.config.component.ts b/vitest.config.component.ts deleted file mode 100644 index 6f26d3e..0000000 --- a/vitest.config.component.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { svelte } from '@sveltejs/vite-plugin-svelte'; -import { playwright } from '@vitest/browser-playwright'; -import path from 'node:path'; -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - plugins: [svelte()], - - test: { - name: 'component-browser', - include: ['src/**/*.svelte.test.ts', 'src/**/*.svelte.test.js'], - exclude: [ - 'node_modules', - 'dist', - 'e2e', - '.storybook', - 'src/shared/shadcn/**/*', - ], - testTimeout: 10000, - hookTimeout: 10000, - restoreMocks: true, - setupFiles: ['./vitest.setup.component.ts'], - globals: false, - // Use browser environment with Playwright for Svelte 5 support - browser: { - enabled: true, - headless: true, - provider: playwright(), - instances: [{ browser: 'chromium' }], - screenshotFailures: true, - screenshotDirectory: '.playwright/screenshots', - }, - }, - - resolve: { - alias: { - $lib: path.resolve(__dirname, './src/lib'), - $app: path.resolve(__dirname, './src/app'), - $shared: path.resolve(__dirname, './src/shared'), - $entities: path.resolve(__dirname, './src/entities'), - $features: path.resolve(__dirname, './src/features'), - $routes: path.resolve(__dirname, './src/routes'), - $widgets: path.resolve(__dirname, './src/widgets'), - }, - }, -}); From 3cd9b3641101cd307b59478e6e41de3ed1265c73 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Fri, 16 Jan 2026 13:59:39 +0300 Subject: [PATCH 10/48] fix(createFilter): remove dirived from selectedProperties compute --- .../createFilter/createFilter.svelte.ts | 82 ++++++------------- 1 file changed, 25 insertions(+), 57 deletions(-) diff --git a/src/shared/lib/helpers/createFilter/createFilter.svelte.ts b/src/shared/lib/helpers/createFilter/createFilter.svelte.ts index b48521d..193a58f 100644 --- a/src/shared/lib/helpers/createFilter/createFilter.svelte.ts +++ b/src/shared/lib/helpers/createFilter/createFilter.svelte.ts @@ -26,84 +26,52 @@ export interface FilterModel { /** * Create a filter store. - * @param initialState - Initial state of the filter store + * @param initialState - Initial state of filter store */ -export function createFilter( - initialState: FilterModel, -) { - let properties = $state( +export function createFilter(initialState: FilterModel) { + // We map the initial properties into a reactive state array + const properties = $state( initialState.properties.map(p => ({ ...p, selected: p.selected ?? false, })), ); - const selectedProperties = $derived(properties.filter(p => p.selected)); - const selectedCount = $derived(selectedProperties.length); - return { - /** - * Get all properties. - */ get properties() { return properties; }, - /** - * Get selected properties. - */ + get selectedProperties() { - return selectedProperties; + return properties.filter(p => p.selected); }, - /** - * Get selected count. - */ + get selectedCount() { - return selectedCount; + return this.selectedProperties.length; }, - /** - * Toggle property selection. - */ - toggleProperty: (id: string) => { - properties = properties.map(p => ({ - ...p, - selected: p.id === id ? !p.selected : p.selected, - })); + + // 3. Methods mutate the reactive state directly + toggleProperty(id: string) { + const prop = properties.find(p => p.id === id); + if (prop) prop.selected = !prop.selected; }, - /** - * Select property. - */ + selectProperty(id: string) { - properties = properties.map(p => ({ - ...p, - selected: p.id === id ? true : p.selected, - })); + const prop = properties.find(p => p.id === id); + if (prop) prop.selected = true; }, - /** - * Deselect property. - */ + deselectProperty(id: string) { - properties = properties.map(p => ({ - ...p, - selected: p.id === id ? false : p.selected, - })); + const prop = properties.find(p => p.id === id); + if (prop) prop.selected = false; }, - /** - * Select all properties. - */ - selectAll: () => { - properties = properties.map(p => ({ - ...p, - selected: true, - })); + + selectAll() { + properties.forEach(p => p.selected = true); }, - /** - * Deselect all properties. - */ - deselectAll: () => { - properties = properties.map(p => ({ - ...p, - selected: false, - })); + + deselectAll() { + properties.forEach(p => p.selected = false); }, }; } From 14f9b8768089a073785416b8eaad4832cb85c691 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Fri, 16 Jan 2026 14:00:20 +0300 Subject: [PATCH 11/48] test(createDebouncedState): create test coverage for createDebouncedState --- .../createDebouncedState.test.ts | 444 ++++++++++++++++++ 1 file changed, 444 insertions(+) create mode 100644 src/shared/lib/helpers/createDebouncedState/createDebouncedState.test.ts diff --git a/src/shared/lib/helpers/createDebouncedState/createDebouncedState.test.ts b/src/shared/lib/helpers/createDebouncedState/createDebouncedState.test.ts new file mode 100644 index 0000000..5987822 --- /dev/null +++ b/src/shared/lib/helpers/createDebouncedState/createDebouncedState.test.ts @@ -0,0 +1,444 @@ +import { createDebouncedState } from '$shared/lib'; +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; + +/** + * Test Suite for createDebouncedState Helper Function + * + * This suite tests the debounced state management logic, + * including immediate vs debounced updates, timing behavior, + * and reset functionality. + */ + +describe('createDebouncedState - Basic Logic', () => { + it('creates state with initial value', () => { + const state = createDebouncedState('initial'); + + expect(state.immediate).toBe('initial'); + expect(state.debounced).toBe('initial'); + }); + + it('supports custom debounce delay', () => { + const state = createDebouncedState('test', 100); + + expect(state.immediate).toBe('test'); + expect(state.debounced).toBe('test'); + }); + + it('uses default delay of 300ms when not specified', () => { + const state = createDebouncedState('test'); + + expect(state.immediate).toBe('test'); + expect(state.debounced).toBe('test'); + }); + + it('allows updating immediate value', () => { + const state = createDebouncedState('initial'); + + state.immediate = 'updated'; + + expect(state.immediate).toBe('updated'); + }); +}); + +describe('createDebouncedState - Debounce Timing', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('immediate value updates instantly', () => { + const state = createDebouncedState('initial', 100); + + state.immediate = 'updated'; + + expect(state.immediate).toBe('updated'); + expect(state.debounced).toBe('initial'); + }); + + it('debounced value updates after delay', () => { + const state = createDebouncedState('initial', 100); + + state.immediate = 'updated'; + expect(state.debounced).toBe('initial'); + + vi.advanceTimersByTime(99); + expect(state.debounced).toBe('initial'); + + vi.advanceTimersByTime(1); + expect(state.debounced).toBe('updated'); + }); + + it('rapid changes reset the debounce timer', () => { + const state = createDebouncedState('initial', 100); + + state.immediate = 'change1'; + vi.advanceTimersByTime(50); + + state.immediate = 'change2'; + vi.advanceTimersByTime(50); + + state.immediate = 'change3'; + vi.advanceTimersByTime(50); + + expect(state.debounced).toBe('initial'); + expect(state.immediate).toBe('change3'); + + vi.advanceTimersByTime(50); + expect(state.debounced).toBe('change3'); + }); + + it('debounced value remains unchanged during rapid updates', () => { + const state = createDebouncedState('initial', 100); + + for (let i = 0; i < 5; i++) { + state.immediate = `update${i}`; + vi.advanceTimersByTime(25); + } + + expect(state.immediate).toBe('update4'); + expect(state.debounced).toBe('initial'); + }); +}); + +describe('createDebouncedState - Reset Functionality', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('resets to initial value when called without argument', () => { + const state = createDebouncedState('initial', 100); + + state.immediate = 'changed'; + vi.advanceTimersByTime(100); + + expect(state.immediate).toBe('changed'); + expect(state.debounced).toBe('changed'); + + state.reset(); + + expect(state.immediate).toBe('initial'); + expect(state.debounced).toBe('initial'); + }); + + it('resets to custom value when argument provided', () => { + const state = createDebouncedState('initial', 100); + + state.immediate = 'changed'; + state.reset('custom'); + + expect(state.immediate).toBe('custom'); + expect(state.debounced).toBe('custom'); + }); + + it('resets immediately without debounce delay', () => { + const state = createDebouncedState('initial', 100); + + state.immediate = 'changed'; + vi.advanceTimersByTime(50); + + state.reset(); + + expect(state.immediate).toBe('initial'); + expect(state.debounced).toBe('initial'); + + // Pending debounce from 'changed' will still fire after the delay + vi.advanceTimersByTime(50); + expect(state.debounced).toBe('changed'); + }); + + it('resets sets both values immediately', () => { + const state = createDebouncedState('initial', 100); + + state.immediate = 'changed'; + vi.advanceTimersByTime(50); + + state.reset('new'); + + expect(state.immediate).toBe('new'); + expect(state.debounced).toBe('new'); + + // Pending debounce from 'changed' will fire after remaining delay + vi.advanceTimersByTime(50); + expect(state.debounced).toBe('changed'); + }); +}); + +describe('createDebouncedState - Type Support', () => { + it('works with string type', () => { + const state = createDebouncedState('hello', 100); + + state.immediate = 'world'; + + expect(state.immediate).toBe('world'); + }); + + it('works with number type', () => { + const state = createDebouncedState(0, 100); + + state.immediate = 42; + + expect(state.immediate).toBe(42); + }); + + it('works with boolean type', () => { + const state = createDebouncedState(false, 100); + + state.immediate = true; + + expect(state.immediate).toBe(true); + }); + + it('works with object type', () => { + interface TestObject { + value: number; + label: string; + } + const initial: TestObject = { value: 0, label: 'initial' }; + const state = createDebouncedState(initial, 100); + + const updated: TestObject = { value: 1, label: 'updated' }; + state.immediate = updated; + + expect(state.immediate).toBe(updated); + expect(state.immediate.value).toBe(1); + }); + + it('works with array type', () => { + const initial = [1, 2, 3]; + const state = createDebouncedState(initial, 100); + + const updated = [4, 5, 6]; + state.immediate = updated; + + expect(state.immediate).toEqual(updated); + }); + + it('works with null type', () => { + const state = createDebouncedState(null, 100); + + state.immediate = 'not null'; + + expect(state.immediate).toBe('not null'); + }); + + it('works with undefined type', () => { + const state = createDebouncedState(undefined, 100); + + state.immediate = 42; + + expect(state.immediate).toBe(42); + }); +}); + +describe('createDebouncedState - Corner Cases', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('handles empty string', () => { + const state = createDebouncedState('', 100); + + state.immediate = ''; + vi.advanceTimersByTime(100); + + expect(state.immediate).toBe(''); + expect(state.debounced).toBe(''); + }); + + it('handles zero value', () => { + const state = createDebouncedState(0, 100); + + expect(state.immediate).toBe(0); + expect(state.debounced).toBe(0); + + state.immediate = 0; + vi.advanceTimersByTime(100); + + expect(state.immediate).toBe(0); + expect(state.debounced).toBe(0); + }); + + it('handles very short debounce delay (1ms)', () => { + const state = createDebouncedState('initial', 1); + + state.immediate = 'changed'; + expect(state.debounced).toBe('initial'); + + vi.advanceTimersByTime(1); + expect(state.debounced).toBe('changed'); + }); + + it('handles very long debounce delay (5000ms)', () => { + const state = createDebouncedState('initial', 5000); + + state.immediate = 'changed'; + vi.advanceTimersByTime(4999); + + expect(state.debounced).toBe('initial'); + + vi.advanceTimersByTime(1); + expect(state.debounced).toBe('changed'); + }); + + it('handles setting to same value multiple times', () => { + const state = createDebouncedState('initial', 100); + + state.immediate = 'same'; + vi.advanceTimersByTime(50); + + state.immediate = 'same'; + vi.advanceTimersByTime(50); + + expect(state.immediate).toBe('same'); + vi.advanceTimersByTime(100); + + expect(state.debounced).toBe('same'); + }); + + it('handles alternating between two values rapidly', () => { + const state = createDebouncedState('initial', 50); + + for (let i = 0; i < 5; i++) { + state.immediate = 'value1'; + vi.advanceTimersByTime(25); + state.immediate = 'value2'; + vi.advanceTimersByTime(25); + } + + expect(state.immediate).toBe('value2'); + expect(state.debounced).toBe('initial'); + + vi.advanceTimersByTime(50); + expect(state.debounced).toBe('value2'); + }); + + it('handles reset during pending debounce', () => { + const state = createDebouncedState('initial', 100); + + state.immediate = 'changed'; + vi.advanceTimersByTime(50); + + state.reset(); + + expect(state.immediate).toBe('initial'); + expect(state.debounced).toBe('initial'); + + // Pending debounce from 'changed' will fire after remaining delay + vi.advanceTimersByTime(50); + expect(state.debounced).toBe('changed'); + }); + + it('handles immediate value changes after reset', () => { + const state = createDebouncedState('initial', 100); + + state.reset('new'); + + expect(state.immediate).toBe('new'); + + state.immediate = 'newer'; + vi.advanceTimersByTime(100); + + expect(state.immediate).toBe('newer'); + expect(state.debounced).toBe('newer'); + }); +}); + +describe('createDebouncedState - Multiple Instances', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('handles multiple independent instances', () => { + const state1 = createDebouncedState('one', 100); + const state2 = createDebouncedState('two', 100); + + state1.immediate = 'changed1'; + state2.immediate = 'changed2'; + + expect(state1.immediate).toBe('changed1'); + expect(state2.immediate).toBe('changed2'); + + vi.advanceTimersByTime(100); + + expect(state1.debounced).toBe('changed1'); + expect(state2.debounced).toBe('changed2'); + }); + + it('independent timers for each instance', () => { + const state1 = createDebouncedState('one', 100); + const state2 = createDebouncedState('two', 200); + + state1.immediate = 'changed1'; + state2.immediate = 'changed2'; + + vi.advanceTimersByTime(100); + + expect(state1.debounced).toBe('changed1'); + expect(state2.debounced).toBe('two'); + + vi.advanceTimersByTime(100); + + expect(state2.debounced).toBe('changed2'); + }); +}); + +describe('createDebouncedState - Interface Compliance', () => { + it('exposes immediate getter', () => { + const state = createDebouncedState('test'); + + expect(() => { + const _ = state.immediate; + }).not.toThrow(); + }); + + it('exposes immediate setter', () => { + const state = createDebouncedState('test'); + + expect(() => { + state.immediate = 'new'; + }).not.toThrow(); + }); + + it('exposes debounced getter', () => { + const state = createDebouncedState('test'); + + expect(() => { + const _ = state.debounced; + }).not.toThrow(); + }); + + it('exposes reset method', () => { + const state = createDebouncedState('test'); + + expect(typeof state.reset).toBe('function'); + }); + + it('does not expose debounced setter', () => { + const state = createDebouncedState('test'); + + // TypeScript should prevent this, but we can check the runtime behavior + expect(state).not.toHaveProperty('set debounced'); + }); +}); From 0daf0bf3bf7235605fb61d48f7d461822e2bddc7 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Fri, 16 Jan 2026 14:00:35 +0300 Subject: [PATCH 12/48] chore: minor vitest adjustment --- vitest.config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/vitest.config.ts b/vitest.config.ts index 96e7de8..d65fb1a 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -53,6 +53,7 @@ export default defineConfig({ }, resolve: { + conditions: process.env.VITEST ? ['browser'] : undefined, alias: { $lib: path.resolve(__dirname, './src/lib'), $app: path.resolve(__dirname, './src/app'), From 4dbf91f600cf464c24dd999c8851b6942cf7f78f Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Fri, 16 Jan 2026 17:44:07 +0300 Subject: [PATCH 13/48] chore(FontList): Move documentation and remove default height --- src/entities/Font/ui/FontList/FontList.svelte | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/entities/Font/ui/FontList/FontList.svelte b/src/entities/Font/ui/FontList/FontList.svelte index 528c0fb..0a05019 100644 --- a/src/entities/Font/ui/FontList/FontList.svelte +++ b/src/entities/Font/ui/FontList/FontList.svelte @@ -1,3 +1,9 @@ + - + {#snippet children({ item: font })} From f02b19eff523abd903f02d27e64df19484cac4d4 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Fri, 16 Jan 2026 17:45:11 +0300 Subject: [PATCH 14/48] chore(createFilter): change format --- .../createFilter/createFilter.svelte.ts | 34 ++++++++++--------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/src/shared/lib/helpers/createFilter/createFilter.svelte.ts b/src/shared/lib/helpers/createFilter/createFilter.svelte.ts index 193a58f..5789d0c 100644 --- a/src/shared/lib/helpers/createFilter/createFilter.svelte.ts +++ b/src/shared/lib/helpers/createFilter/createFilter.svelte.ts @@ -37,41 +37,43 @@ export function createFilter(initialState: FilterModel properties.find(p => p.id === id); + return { get properties() { return properties; }, - get selectedProperties() { return properties.filter(p => p.selected); }, - get selectedCount() { - return this.selectedProperties.length; + return properties.filter(p => p.selected)?.length; }, - // 3. Methods mutate the reactive state directly toggleProperty(id: string) { - const prop = properties.find(p => p.id === id); - if (prop) prop.selected = !prop.selected; + const property = findProp(id); + if (property) { + property.selected = !property.selected; + } }, - selectProperty(id: string) { - const prop = properties.find(p => p.id === id); - if (prop) prop.selected = true; + const property = findProp(id); + if (property) { + property.selected = true; + } }, - deselectProperty(id: string) { - const prop = properties.find(p => p.id === id); - if (prop) prop.selected = false; + const property = findProp(id); + if (property) { + property.selected = false; + } }, - selectAll() { - properties.forEach(p => p.selected = true); + properties.forEach(property => property.selected = true); }, - deselectAll() { - properties.forEach(p => p.selected = false); + properties.forEach(property => property.selected = false); }, }; } From a85b3cf217e6029887152e3beaec882bb520b4bd Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Fri, 16 Jan 2026 17:46:06 +0300 Subject: [PATCH 15/48] fix(VirtualList): change styles to show the correct scroll instantly --- src/shared/ui/VirtualList/VirtualList.svelte | 21 ++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/src/shared/ui/VirtualList/VirtualList.svelte b/src/shared/ui/VirtualList/VirtualList.svelte index f6471f7..8b8c945 100644 --- a/src/shared/ui/VirtualList/VirtualList.svelte +++ b/src/shared/ui/VirtualList/VirtualList.svelte @@ -10,10 +10,7 @@ - + {#snippet child({ props })} -
+ {@const { onclick, ...rest } = props} +
{#if label} {/if} @@ -68,6 +69,7 @@ function handleInputClick() { placeholder={placeholder} bind:value={value} onkeydown={handleKeyDown} + onclick={handleInputClick} class="flex flex-row flex-1" />
@@ -76,7 +78,12 @@ function handleInputClick() { e.preventDefault()} - class="w-max" + onInteractOutside={(e => { + if (e.target === triggerRef) { + e.preventDefault(); + } + })} + class="w-(--bits-popover-anchor-width) min-w-(--bits-popover-anchor-width)" > {@render children?.({ id: contentId })} From 8c0c91deb7bb6795b00da55207f60dadeda5dbf2 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Fri, 16 Jan 2026 17:48:33 +0300 Subject: [PATCH 17/48] feat(createVirtualizer): enhance logic with binary search and requestAnimationFrame --- .../createVirtualizer.svelte.ts | 77 +++++++++++-------- 1 file changed, 44 insertions(+), 33 deletions(-) diff --git a/src/shared/lib/helpers/createVirtualizer/createVirtualizer.svelte.ts b/src/shared/lib/helpers/createVirtualizer/createVirtualizer.svelte.ts index 3482292..ea96535 100644 --- a/src/shared/lib/helpers/createVirtualizer/createVirtualizer.svelte.ts +++ b/src/shared/lib/helpers/createVirtualizer/createVirtualizer.svelte.ts @@ -79,27 +79,23 @@ export interface VirtualizerOptions { *
* ``` */ -export function createVirtualizer(optionsGetter: () => VirtualizerOptions) { - // Reactive State +export function createVirtualizer(optionsGetter: () => VirtualizerOptions & { data: T[] }) { let scrollOffset = $state(0); let containerHeight = $state(0); let measuredSizes = $state>({}); - - // Non-reactive ref for DOM manipulation (avoiding unnecessary state tracking) let elementRef: HTMLElement | null = null; - // Reactive Options + // By wrapping the getter in $derived, we track everything inside it const options = $derived(optionsGetter()); - // Optimized Memoization (The Cache Layer) - // Only recalculates when item count or measured sizes change. + // This derivation now tracks: count, measuredSizes, AND the data array itself const offsets = $derived.by(() => { const count = options.count; - const result = Array.from({ length: count }); + const result = new Float64Array(count); let accumulated = 0; - for (let i = 0; i < count; i++) { result[i] = accumulated; + // Accessing measuredSizes here creates the subscription accumulated += measuredSizes[i] ?? options.estimateSize(i); } return result; @@ -112,24 +108,30 @@ export function createVirtualizer(optionsGetter: () => VirtualizerOptions) { : 0, ); - // Visible Range Calculation - // Svelte tracks dependencies automatically here. const items = $derived.by((): VirtualItem[] => { - const count = options.count; - if (count === 0 || containerHeight === 0) return []; + // We MUST read options.data here so Svelte knows to re-run + // this derivation when the items array is replaced! + const { count, data } = options; + if (count === 0 || containerHeight === 0 || !data) return []; const overscan = options.overscan ?? 5; - const viewportStart = scrollOffset; - const viewportEnd = scrollOffset + containerHeight; - // Find Start (Linear Scan) + // Binary search for efficiency + let low = 0; + let high = count - 1; let startIdx = 0; - while (startIdx < count && offsets[startIdx + 1] < viewportStart) { - startIdx++; + while (low <= high) { + const mid = Math.floor((low + high) / 2); + if (offsets[mid] <= scrollOffset) { + startIdx = mid; + low = mid + 1; + } else { + high = mid - 1; + } } - // Find End let endIdx = startIdx; + const viewportEnd = scrollOffset + containerHeight; while (endIdx < count && offsets[endIdx] < viewportEnd) { endIdx++; } @@ -139,18 +141,16 @@ export function createVirtualizer(optionsGetter: () => VirtualizerOptions) { const result: VirtualItem[] = []; for (let i = start; i < end; i++) { - const size = measuredSizes[i] ?? options.estimateSize(i); result.push({ index: i, start: offsets[i], - size, - end: offsets[i] + size, + size: measuredSizes[i] ?? options.estimateSize(i), + end: offsets[i] + (measuredSizes[i] ?? options.estimateSize(i)), key: options.getItemKey?.(i) ?? i, }); } return result; }); - // Svelte Actions (The DOM Interface) /** @@ -185,6 +185,8 @@ export function createVirtualizer(optionsGetter: () => VirtualizerOptions) { }; } + let measurementBuffer: Record = {}; + let frameId: number | null = null; /** * Svelte action to measure individual item elements for dynamic height support. * @@ -195,23 +197,32 @@ export function createVirtualizer(optionsGetter: () => VirtualizerOptions) { * @returns Object with destroy method for cleanup */ function measureElement(node: HTMLElement) { - // Use a ResizeObserver on individual items for dynamic height support const resizeObserver = new ResizeObserver(([entry]) => { - if (entry) { - const index = parseInt(node.dataset.index || '', 10); - const height = entry.borderBoxSize[0]?.blockSize ?? node.offsetHeight; + if (!entry) return; + const index = parseInt(node.dataset.index || '', 10); + const height = entry.borderBoxSize[0]?.blockSize ?? node.offsetHeight; - // Only update if height actually changed to prevent loops - if (!isNaN(index) && measuredSizes[index] !== height) { - measuredSizes[index] = height; + if (!isNaN(index) && measuredSizes[index] !== height) { + // 1. Stuff the measurement into a temporary buffer + measurementBuffer[index] = height; + + // 2. Schedule a single update for the next animation frame + if (frameId === null) { + frameId = requestAnimationFrame(() => { + // 3. Update the state once for all collected measurements + // We use spread to trigger a single fine-grained update + measuredSizes = { ...measuredSizes, ...measurementBuffer }; + + // 4. Reset the buffer + measurementBuffer = {}; + frameId = null; + }); } } }); resizeObserver.observe(node); - return { - destroy: () => resizeObserver.disconnect(), - }; + return { destroy: () => resizeObserver.disconnect() }; } // Programmatic Scroll From 247b683c87ce02710f964c4dafb698cc1c8965b4 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Sat, 17 Jan 2026 09:19:47 +0300 Subject: [PATCH 18/48] chore(FontSearch): documentation change --- .../GetFonts/ui/FontSearch/FontSearch.svelte | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/features/GetFonts/ui/FontSearch/FontSearch.svelte b/src/features/GetFonts/ui/FontSearch/FontSearch.svelte index 78a0715..8e636a2 100644 --- a/src/features/GetFonts/ui/FontSearch/FontSearch.svelte +++ b/src/features/GetFonts/ui/FontSearch/FontSearch.svelte @@ -1,3 +1,8 @@ + Date: Sat, 17 Jan 2026 09:20:58 +0300 Subject: [PATCH 19/48] feat(FontView): create a FontView component that adds a link to the head tag and applies font-family to the children --- src/features/ShowFont/ui/FontView.svelte | 40 ++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 src/features/ShowFont/ui/FontView.svelte diff --git a/src/features/ShowFont/ui/FontView.svelte b/src/features/ShowFont/ui/FontView.svelte new file mode 100644 index 0000000..3b387d3 --- /dev/null +++ b/src/features/ShowFont/ui/FontView.svelte @@ -0,0 +1,40 @@ + + + + + + + +
+ {@render children?.()} +
From 71d320535ee5babb06c0e0520c2a741634b77666 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Sat, 17 Jan 2026 09:21:34 +0300 Subject: [PATCH 20/48] feat(FontView): integrate FontView into FontList --- src/entities/Font/ui/FontList/FontList.svelte | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/entities/Font/ui/FontList/FontList.svelte b/src/entities/Font/ui/FontList/FontList.svelte index 0a05019..fcf9d75 100644 --- a/src/entities/Font/ui/FontList/FontList.svelte +++ b/src/entities/Font/ui/FontList/FontList.svelte @@ -5,23 +5,27 @@ - Uses unifiedFontStore from context for data, but can accept explicit fonts via props. --> {#snippet children({ item: font })} - {font.name} + - {font.category} • {font.provider} + {font.provider} • {font.category} + {font.name} {/snippet} From 32da012b26a20c210726db6e62beed3766e44277 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Sat, 17 Jan 2026 14:29:10 +0300 Subject: [PATCH 21/48] feat(MotionPreference): Create common logic to store information about prefers-reduced-motion --- src/shared/lib/accessibility/motion.svelte.ts | 37 +++++++++++++++++++ src/shared/lib/index.ts | 2 + .../ui/CheckboxFilter/CheckboxFilter.svelte | 21 +---------- 3 files changed, 41 insertions(+), 19 deletions(-) create mode 100644 src/shared/lib/accessibility/motion.svelte.ts diff --git a/src/shared/lib/accessibility/motion.svelte.ts b/src/shared/lib/accessibility/motion.svelte.ts new file mode 100644 index 0000000..939819a --- /dev/null +++ b/src/shared/lib/accessibility/motion.svelte.ts @@ -0,0 +1,37 @@ +// Check if we are in a browser environment +const isBrowser = typeof window !== 'undefined'; + +class MotionPreference { + // Reactive state + #reduced = $state(false); + #mediaQuery: MediaQueryList = new MediaQueryList(); + + private handleChange = (e: MediaQueryListEvent) => { + this.#reduced = e.matches; + }; + + constructor() { + if (isBrowser) { + const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)'); + + // Set initial value immediately + this.#reduced = mediaQuery.matches; + + mediaQuery.addEventListener('change', this.handleChange); + + this.#mediaQuery = mediaQuery; + } + } + + // Getter allows us to use 'motion.reduced' reactively in components + get reduced() { + return this.#reduced; + } + + destroy() { + this.#mediaQuery.removeEventListener('change', this.handleChange); + } +} + +// Export a single instance to be used everywhere +export const motion = new MotionPreference(); diff --git a/src/shared/lib/index.ts b/src/shared/lib/index.ts index 7d971c1..cbbfad9 100644 --- a/src/shared/lib/index.ts +++ b/src/shared/lib/index.ts @@ -13,3 +13,5 @@ export { type Virtualizer, type VirtualizerOptions, } from './helpers'; + +export { motion } from './accessibility/motion.svelte'; diff --git a/src/shared/ui/CheckboxFilter/CheckboxFilter.svelte b/src/shared/ui/CheckboxFilter/CheckboxFilter.svelte index 0dc9acc..8372dd4 100644 --- a/src/shared/ui/CheckboxFilter/CheckboxFilter.svelte +++ b/src/shared/ui/CheckboxFilter/CheckboxFilter.svelte @@ -1,5 +1,6 @@
Date: Sun, 18 Jan 2026 12:51:55 +0300 Subject: [PATCH 24/48] feat(ContentEditable): create ContentEditable shared component that displays text and allows editing --- .../ui/ContentEditable/ContentEditable.svelte | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 src/shared/ui/ContentEditable/ContentEditable.svelte diff --git a/src/shared/ui/ContentEditable/ContentEditable.svelte b/src/shared/ui/ContentEditable/ContentEditable.svelte new file mode 100644 index 0000000..15508ef --- /dev/null +++ b/src/shared/ui/ContentEditable/ContentEditable.svelte @@ -0,0 +1,44 @@ + + + +
+ {text} +
From 0444f8c11497010f6b9b7db4eba14dd440e9a4ed Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Sun, 18 Jan 2026 12:55:25 +0300 Subject: [PATCH 25/48] chore(FontVirtualList): transform FontList into reusable FontVirtualList component with appliedFontsManager support --- src/entities/Font/ui/FontList/FontList.svelte | 32 ------------------- .../ui/FontVirtualList/FontVirtualList.svelte | 30 +++++++++++++++++ 2 files changed, 30 insertions(+), 32 deletions(-) delete mode 100644 src/entities/Font/ui/FontList/FontList.svelte create mode 100644 src/entities/Font/ui/FontVirtualList/FontVirtualList.svelte diff --git a/src/entities/Font/ui/FontList/FontList.svelte b/src/entities/Font/ui/FontList/FontList.svelte deleted file mode 100644 index fcf9d75..0000000 --- a/src/entities/Font/ui/FontList/FontList.svelte +++ /dev/null @@ -1,32 +0,0 @@ - - - - - {#snippet children({ item: font })} - - - - - {font.provider} • {font.category} - - {font.name} - - - {/snippet} - diff --git a/src/entities/Font/ui/FontVirtualList/FontVirtualList.svelte b/src/entities/Font/ui/FontVirtualList/FontVirtualList.svelte new file mode 100644 index 0000000..71c9838 --- /dev/null +++ b/src/entities/Font/ui/FontVirtualList/FontVirtualList.svelte @@ -0,0 +1,30 @@ + + + + {#snippet children(scope)} + {@render children(scope)} + {/snippet} + From da0612942c496749b31be2f622f3f276fbc63160 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Sun, 18 Jan 2026 12:57:56 +0300 Subject: [PATCH 26/48] feat(FontApplicator): create FontApplicator component that register certain font and applies it to the children --- .../ui/FontApplicator/FontApplicator.svelte | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 src/entities/Font/ui/FontApplicator/FontApplicator.svelte diff --git a/src/entities/Font/ui/FontApplicator/FontApplicator.svelte b/src/entities/Font/ui/FontApplicator/FontApplicator.svelte new file mode 100644 index 0000000..61f4680 --- /dev/null +++ b/src/entities/Font/ui/FontApplicator/FontApplicator.svelte @@ -0,0 +1,47 @@ + + + +
+ {@render children?.()} +
From 86e7b2c1ec6d105d22b14433a6d812575fab12ac Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Sun, 18 Jan 2026 12:59:12 +0300 Subject: [PATCH 27/48] feat(FontListItem): create FontListItem component that visualize selection of a certain font --- .../Font/ui/FontListItem/FontListItem.svelte | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 src/entities/Font/ui/FontListItem/FontListItem.svelte diff --git a/src/entities/Font/ui/FontListItem/FontListItem.svelte b/src/entities/Font/ui/FontListItem/FontListItem.svelte new file mode 100644 index 0000000..9ded8aa --- /dev/null +++ b/src/entities/Font/ui/FontListItem/FontListItem.svelte @@ -0,0 +1,76 @@ + + +
+ +
From 7e62acce49c245c02374c1da53d6fd536272def8 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Sun, 18 Jan 2026 14:35:35 +0300 Subject: [PATCH 28/48] fix(ContentEditable): change logic to support controlled state --- .../ui/FontSampler/FontSampler.svelte | 37 +++++++++++++++++++ .../ui/ContentEditable/ContentEditable.svelte | 24 ++++++++++-- 2 files changed, 58 insertions(+), 3 deletions(-) create mode 100644 src/features/DisplayFont/ui/FontSampler/FontSampler.svelte diff --git a/src/features/DisplayFont/ui/FontSampler/FontSampler.svelte b/src/features/DisplayFont/ui/FontSampler/FontSampler.svelte new file mode 100644 index 0000000..68705fb --- /dev/null +++ b/src/features/DisplayFont/ui/FontSampler/FontSampler.svelte @@ -0,0 +1,37 @@ + + + +
+ +
diff --git a/src/shared/ui/ContentEditable/ContentEditable.svelte b/src/shared/ui/ContentEditable/ContentEditable.svelte index 15508ef..0c9bc0b 100644 --- a/src/shared/ui/ContentEditable/ContentEditable.svelte +++ b/src/shared/ui/ContentEditable/ContentEditable.svelte @@ -7,7 +7,7 @@ interface Props { /** * Visible text */ - text?: string; + text: string; /** * Font settings */ @@ -17,19 +17,38 @@ interface Props { } let { - text = 'The quick brown fox jumps over the lazy dog', + text = $bindable('The quick brown fox jumps over the lazy dog.'), fontSize = 48, lineHeight = 1.2, letterSpacing = 0, }: Props = $props(); + +let element: HTMLDivElement | undefined = $state(); + +// Initial Sync: Set the text ONLY ONCE when the element is created. +// This prevents Svelte from "owning" the innerHTML/innerText. +$effect(() => { + if (element && element.innerText !== text) { + element.innerText = text; + } +}); + +// Handle changes: Update the outer state without re-rendering the div. +function handleInput(e: Event) { + const target = e.target as HTMLDivElement; + // Update the bindable prop directly + text = target.innerText; +}
- {text}
From df8eca6ef2de1ee1915ac01d24a694db541d1c74 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Sun, 18 Jan 2026 14:37:23 +0300 Subject: [PATCH 29/48] feat(splitArray): create a util to split an array based on a boolean resulting callback --- src/shared/lib/utils/splitArray/splitArray.ts | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 src/shared/lib/utils/splitArray/splitArray.ts diff --git a/src/shared/lib/utils/splitArray/splitArray.ts b/src/shared/lib/utils/splitArray/splitArray.ts new file mode 100644 index 0000000..5d8bf71 --- /dev/null +++ b/src/shared/lib/utils/splitArray/splitArray.ts @@ -0,0 +1,8 @@ +export function splitArray(array: T[], callback: (item: T) => boolean) { + return array.reduce<[T[], T[]]>( + ([pass, fail], item) => ( + callback(item) ? pass.push(item) : fail.push(item), [pass, fail] + ), + [[], []], + ); +} From 5d23a2af5553bf0db07e6eee47a55efc698c013f Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Sun, 18 Jan 2026 14:38:58 +0300 Subject: [PATCH 30/48] feat(EntityStore): create a helper for creation of an Entity Store to store and operate over values that have ids --- .../createEntityStore.svelte.ts | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 src/shared/lib/helpers/createEntityStore/createEntityStore.svelte.ts diff --git a/src/shared/lib/helpers/createEntityStore/createEntityStore.svelte.ts b/src/shared/lib/helpers/createEntityStore/createEntityStore.svelte.ts new file mode 100644 index 0000000..28afcc9 --- /dev/null +++ b/src/shared/lib/helpers/createEntityStore/createEntityStore.svelte.ts @@ -0,0 +1,80 @@ +import { SvelteMap } from 'svelte/reactivity'; + +export interface Entity { + id: string; +} + +/** + * Svelte 5 Entity Store + * Uses SvelteMap for O(1) lookups and granular reactivity. + */ +export class EntityStore { + // SvelteMap is a reactive version of the native Map + #entities = new SvelteMap(); + + constructor(initialEntities: T[] = []) { + this.setAll(initialEntities); + } + + // --- Selectors (Equivalent to Selectors) --- + + /** Get all entities as an array */ + get all() { + return Array.from(this.#entities.values()); + } + + /** Select a single entity by ID */ + getById(id: string) { + return this.#entities.get(id); + } + + /** Select multiple entities by IDs */ + getByIds(ids: string[]) { + return ids.map(id => this.#entities.get(id)).filter((e): e is T => !!e); + } + + // --- Actions (CRUD) --- + + addOne(entity: T) { + this.#entities.set(entity.id, entity); + } + + addMany(entities: T[]) { + entities.forEach(e => this.addOne(e)); + } + + updateOne(id: string, changes: Partial) { + const entity = this.#entities.get(id); + if (entity) { + // In Svelte 5, updating the object property directly is reactive + // if the object itself was made reactive, but here we replace + // the reference to ensure top-level map triggers. + this.#entities.set(id, { ...entity, ...changes }); + } + } + + removeOne(id: string) { + this.#entities.delete(id); + } + + removeMany(ids: string[]) { + ids.forEach(id => this.#entities.delete(id)); + } + + setAll(entities: T[]) { + this.#entities.clear(); + this.addMany(entities); + } + + has(id: string) { + return this.#entities.has(id); + } + + clear() { + this.#entities.clear(); + } +} + +export function createEntityStore(initialEntities: T[] = []) { + return new EntityStore(initialEntities); +} From ef259c6fce293a1c9bd7eecc79ffb4414c553389 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Sun, 18 Jan 2026 14:39:38 +0300 Subject: [PATCH 31/48] chore: add import shortcuts --- src/shared/lib/helpers/index.ts | 6 ++++++ src/shared/lib/index.ts | 4 ++++ src/shared/lib/utils/index.ts | 1 + src/shared/ui/index.ts | 2 ++ 4 files changed, 13 insertions(+) diff --git a/src/shared/lib/helpers/index.ts b/src/shared/lib/helpers/index.ts index cd57e87..62db226 100644 --- a/src/shared/lib/helpers/index.ts +++ b/src/shared/lib/helpers/index.ts @@ -20,3 +20,9 @@ export { } from './createVirtualizer/createVirtualizer.svelte'; export { createDebouncedState } from './createDebouncedState/createDebouncedState.svelte'; + +export { + createEntityStore, + type Entity, + type EntityStore, +} from './createEntityStore/createEntityStore.svelte'; diff --git a/src/shared/lib/index.ts b/src/shared/lib/index.ts index cbbfad9..fea0978 100644 --- a/src/shared/lib/index.ts +++ b/src/shared/lib/index.ts @@ -2,9 +2,12 @@ export { type ControlDataModel, type ControlModel, createDebouncedState, + createEntityStore, createFilter, createTypographyControl, createVirtualizer, + type Entity, + type EntityStore, type Filter, type FilterModel, type Property, @@ -15,3 +18,4 @@ export { } from './helpers'; export { motion } from './accessibility/motion.svelte'; +export { splitArray } from './utils'; diff --git a/src/shared/lib/utils/index.ts b/src/shared/lib/utils/index.ts index d2efc54..08a788f 100644 --- a/src/shared/lib/utils/index.ts +++ b/src/shared/lib/utils/index.ts @@ -11,3 +11,4 @@ export { clampNumber } from './clampNumber/clampNumber'; export { debounce } from './debounce/debounce'; export { getDecimalPlaces } from './getDecimalPlaces/getDecimalPlaces'; export { roundToStepPrecision } from './roundToStepPrecision/roundToStepPrecision'; +export { splitArray } from './splitArray/splitArray'; diff --git a/src/shared/ui/index.ts b/src/shared/ui/index.ts index 69bdf9c..307dcc6 100644 --- a/src/shared/ui/index.ts +++ b/src/shared/ui/index.ts @@ -6,12 +6,14 @@ import CheckboxFilter from './CheckboxFilter/CheckboxFilter.svelte'; import ComboControl from './ComboControl/ComboControl.svelte'; +import ContentEditable from './ContentEditable/ContentEditable.svelte'; import SearchBar from './SearchBar/SearchBar.svelte'; import VirtualList from './VirtualList/VirtualList.svelte'; export { CheckboxFilter, ComboControl, + ContentEditable, SearchBar, VirtualList, }; From ad18a19c4b3ab85d61f176eafa1380778bf9c1e9 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Sun, 18 Jan 2026 14:47:31 +0300 Subject: [PATCH 32/48] chore(FontSampler): delete unused prop --- src/entities/Font/ui/FontApplicator/FontApplicator.svelte | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/entities/Font/ui/FontApplicator/FontApplicator.svelte b/src/entities/Font/ui/FontApplicator/FontApplicator.svelte index 61f4680..110e596 100644 --- a/src/entities/Font/ui/FontApplicator/FontApplicator.svelte +++ b/src/entities/Font/ui/FontApplicator/FontApplicator.svelte @@ -12,13 +12,12 @@ import type { Snippet } from 'svelte'; interface Props { name: string; - slug: string; id: string; className?: string; children?: Snippet; } -let { name, slug, id, className, children }: Props = $props(); +let { name, id, className, children }: Props = $props(); let element: Element; $effect(() => { From af2ef77c309f98c788d92c22543bbbbb20005a10 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Sun, 18 Jan 2026 14:48:36 +0300 Subject: [PATCH 33/48] feat(FontSampler): edit FontSampler to applt font-family through FontApplicator component --- .../ui/FontSampler/FontSampler.svelte | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/features/DisplayFont/ui/FontSampler/FontSampler.svelte b/src/features/DisplayFont/ui/FontSampler/FontSampler.svelte index 68705fb..274c704 100644 --- a/src/features/DisplayFont/ui/FontSampler/FontSampler.svelte +++ b/src/features/DisplayFont/ui/FontSampler/FontSampler.svelte @@ -3,26 +3,25 @@ Displays a sample text with a given font in a contenteditable element. -->
{ bg-white p-6 border border-slate-200 shadow-sm dark:border-slate-800 dark:bg-slate-950 " - style:font-family={fontId} > - + + +
From 37ab7f795e0662614710e19a67c52aa9bd0d4e11 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Sun, 18 Jan 2026 14:52:12 +0300 Subject: [PATCH 34/48] feat(selectedFontsStore): create selectedFontsStore to manage selected fonts collection --- .../store/selectedFontsStore/selectedFontsStore.svelte.ts | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 src/entities/Font/model/store/selectedFontsStore/selectedFontsStore.svelte.ts diff --git a/src/entities/Font/model/store/selectedFontsStore/selectedFontsStore.svelte.ts b/src/entities/Font/model/store/selectedFontsStore/selectedFontsStore.svelte.ts new file mode 100644 index 0000000..d90d22b --- /dev/null +++ b/src/entities/Font/model/store/selectedFontsStore/selectedFontsStore.svelte.ts @@ -0,0 +1,4 @@ +import { createEntityStore } from '$shared/lib'; +import type { UnifiedFont } from '../../types'; + +export const selectedFontsStore = createEntityStore([]); From e0e0d929bb83046de6f00b5706d751b6bcdaf72d Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Sun, 18 Jan 2026 14:53:14 +0300 Subject: [PATCH 35/48] chore: add import shortcuts --- src/entities/Font/index.ts | 8 +++++++- src/entities/Font/model/index.ts | 2 ++ src/entities/Font/model/store/index.ts | 4 ++++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/entities/Font/index.ts b/src/entities/Font/index.ts index 03f4781..2c92962 100644 --- a/src/entities/Font/index.ts +++ b/src/entities/Font/index.ts @@ -60,9 +60,11 @@ export type { } from './model'; export { + appliedFontsManager, createFontshareStore, fetchFontshareFontsQuery, fontshareStore, + selectedFontsStore, } from './model'; // Stores @@ -72,4 +74,8 @@ export { } from './model/services/fetchGoogleFonts.svelte'; // UI elements -export { FontList } from './ui'; +export { + FontApplicator, + FontListItem, + FontVirtualList, +} from './ui'; diff --git a/src/entities/Font/model/index.ts b/src/entities/Font/model/index.ts index fa164fb..b1ce8a1 100644 --- a/src/entities/Font/model/index.ts +++ b/src/entities/Font/model/index.ts @@ -37,7 +37,9 @@ export type { export { fetchFontshareFontsQuery } from './services'; export { + appliedFontsManager, createFontshareStore, type FontshareStore, fontshareStore, + selectedFontsStore, } from './store'; diff --git a/src/entities/Font/model/store/index.ts b/src/entities/Font/model/store/index.ts index f439eab..f019a3c 100644 --- a/src/entities/Font/model/store/index.ts +++ b/src/entities/Font/model/store/index.ts @@ -17,3 +17,7 @@ export { type FontshareStore, fontshareStore, } from './fontshareStore.svelte'; + +export { appliedFontsManager } from './appliedFontsStore/appliedFontsStore.svelte'; + +export { selectedFontsStore } from './selectedFontsStore/selectedFontsStore.svelte'; From f457e5116f90e9bc8e7d03454134818e0ae1f497 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Sun, 18 Jan 2026 14:55:00 +0300 Subject: [PATCH 36/48] feat(displayedFontsStore): create store to manage displayed fonts sample and its content --- src/features/DisplayFont/index.ts | 0 src/features/DisplayFont/model/index.ts | 1 + .../model/store/displayedFontsStore.svelte.ts | 23 +++++++++++++++++++ src/features/DisplayFont/model/store/index.ts | 1 + 4 files changed, 25 insertions(+) create mode 100644 src/features/DisplayFont/index.ts create mode 100644 src/features/DisplayFont/model/index.ts create mode 100644 src/features/DisplayFont/model/store/displayedFontsStore.svelte.ts create mode 100644 src/features/DisplayFont/model/store/index.ts diff --git a/src/features/DisplayFont/index.ts b/src/features/DisplayFont/index.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/features/DisplayFont/model/index.ts b/src/features/DisplayFont/model/index.ts new file mode 100644 index 0000000..5099f73 --- /dev/null +++ b/src/features/DisplayFont/model/index.ts @@ -0,0 +1 @@ +export { displayedFontsStore } from './store'; diff --git a/src/features/DisplayFont/model/store/displayedFontsStore.svelte.ts b/src/features/DisplayFont/model/store/displayedFontsStore.svelte.ts new file mode 100644 index 0000000..6877ae8 --- /dev/null +++ b/src/features/DisplayFont/model/store/displayedFontsStore.svelte.ts @@ -0,0 +1,23 @@ +import { selectedFontsStore } from '$entities/Font'; + +export class DisplayedFontsStore { + #sampleText = $state('The quick brown fox jumps over the lazy dog'); + + #displayedFonts = $derived.by(() => { + return selectedFontsStore.all; + }); + + get fonts() { + return this.#displayedFonts; + } + + get text() { + return this.#sampleText; + } + + set text(text: string) { + this.#sampleText = text; + } +} + +export const displayedFontsStore = new DisplayedFontsStore(); diff --git a/src/features/DisplayFont/model/store/index.ts b/src/features/DisplayFont/model/store/index.ts new file mode 100644 index 0000000..43bb021 --- /dev/null +++ b/src/features/DisplayFont/model/store/index.ts @@ -0,0 +1 @@ +export { displayedFontsStore } from './displayedFontsStore.svelte'; From 5ef8d609ab3ef482f330dfbda47dfca5597ecef5 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Sun, 18 Jan 2026 14:56:25 +0300 Subject: [PATCH 37/48] feat(SuggestedFonts): create a component for Suggested Virtualized Font List --- .../ui/SuggestedFonts/SuggestedFonts.svelte | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 src/features/GetFonts/ui/SuggestedFonts/SuggestedFonts.svelte diff --git a/src/features/GetFonts/ui/SuggestedFonts/SuggestedFonts.svelte b/src/features/GetFonts/ui/SuggestedFonts/SuggestedFonts.svelte new file mode 100644 index 0000000..9a2565d --- /dev/null +++ b/src/features/GetFonts/ui/SuggestedFonts/SuggestedFonts.svelte @@ -0,0 +1,13 @@ + + + + {#snippet children({ item: font })} + + {/snippet} + From 96b26fb0558d915b5669ef154a228f856c284066 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Sun, 18 Jan 2026 14:57:15 +0300 Subject: [PATCH 38/48] feat(FontDisplay): create a FontDisplay component to show selected font samples --- .../DisplayFont/ui/FontDisplay/FontDisplay.svelte | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 src/features/DisplayFont/ui/FontDisplay/FontDisplay.svelte diff --git a/src/features/DisplayFont/ui/FontDisplay/FontDisplay.svelte b/src/features/DisplayFont/ui/FontDisplay/FontDisplay.svelte new file mode 100644 index 0000000..915078f --- /dev/null +++ b/src/features/DisplayFont/ui/FontDisplay/FontDisplay.svelte @@ -0,0 +1,14 @@ + + + +
+ {#each displayedFontsStore.fonts as font (font.id)} + + {/each} +
From b7ce1004074ed40cbb856448e12adc67630a4b67 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Sun, 18 Jan 2026 14:58:05 +0300 Subject: [PATCH 39/48] fix(FontSearch): edit component to render suggested fonts --- src/features/GetFonts/ui/FontSearch/FontSearch.svelte | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/features/GetFonts/ui/FontSearch/FontSearch.svelte b/src/features/GetFonts/ui/FontSearch/FontSearch.svelte index 8e636a2..c792eed 100644 --- a/src/features/GetFonts/ui/FontSearch/FontSearch.svelte +++ b/src/features/GetFonts/ui/FontSearch/FontSearch.svelte @@ -4,14 +4,12 @@ Combines search input with font list display -->
@@ -49,7 +55,6 @@ const handleChange = (checked: boolean) => { {font.name} From ba883ef9a867b10086a3d5c8c4a3b8895566b578 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Sun, 18 Jan 2026 15:00:07 +0300 Subject: [PATCH 41/48] fix(motion): edit MotionPreference to avoid errors --- src/shared/lib/accessibility/motion.svelte.ts | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/src/shared/lib/accessibility/motion.svelte.ts b/src/shared/lib/accessibility/motion.svelte.ts index 939819a..c0a7c52 100644 --- a/src/shared/lib/accessibility/motion.svelte.ts +++ b/src/shared/lib/accessibility/motion.svelte.ts @@ -4,11 +4,6 @@ const isBrowser = typeof window !== 'undefined'; class MotionPreference { // Reactive state #reduced = $state(false); - #mediaQuery: MediaQueryList = new MediaQueryList(); - - private handleChange = (e: MediaQueryListEvent) => { - this.#reduced = e.matches; - }; constructor() { if (isBrowser) { @@ -17,9 +12,12 @@ class MotionPreference { // Set initial value immediately this.#reduced = mediaQuery.matches; - mediaQuery.addEventListener('change', this.handleChange); + // Simple listener that updates the reactive state + const handleChange = (e: MediaQueryListEvent) => { + this.#reduced = e.matches; + }; - this.#mediaQuery = mediaQuery; + mediaQuery.addEventListener('change', handleChange); } } @@ -27,10 +25,6 @@ class MotionPreference { get reduced() { return this.#reduced; } - - destroy() { - this.#mediaQuery.removeEventListener('change', this.handleChange); - } } // Export a single instance to be used everywhere From ee074036f6238a87f23bc5488b809a1bdca072cf Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Sun, 18 Jan 2026 15:00:26 +0300 Subject: [PATCH 42/48] chore: add import shortcuts --- src/entities/Font/ui/index.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/entities/Font/ui/index.ts b/src/entities/Font/ui/index.ts index 941392e..2ed85bc 100644 --- a/src/entities/Font/ui/index.ts +++ b/src/entities/Font/ui/index.ts @@ -1,3 +1,9 @@ -import FontList from './FontList/FontList.svelte'; +import FontApplicator from './FontApplicator/FontApplicator.svelte'; +import FontListItem from './FontListItem/FontListItem.svelte'; +import FontVirtualList from './FontVirtualList/FontVirtualList.svelte'; -export { FontList }; +export { + FontApplicator, + FontListItem, + FontVirtualList, +}; From c04518300be69af664dcbe6d5abe8622b07f720b Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Sun, 18 Jan 2026 15:00:54 +0300 Subject: [PATCH 43/48] chore: remove unused code --- src/features/ShowFont/ui/FontView.svelte | 40 ------------------------ 1 file changed, 40 deletions(-) delete mode 100644 src/features/ShowFont/ui/FontView.svelte diff --git a/src/features/ShowFont/ui/FontView.svelte b/src/features/ShowFont/ui/FontView.svelte deleted file mode 100644 index 3b387d3..0000000 --- a/src/features/ShowFont/ui/FontView.svelte +++ /dev/null @@ -1,40 +0,0 @@ - - - - - - - -
- {@render children?.()} -
From 20f6e193f2899558d6707b1119f699e0fda877dd Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Sun, 18 Jan 2026 15:01:19 +0300 Subject: [PATCH 44/48] chore: minor changes --- src/routes/Page.svelte | 6 +++++- .../helpers/createVirtualizer/createVirtualizer.svelte.ts | 7 ++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/routes/Page.svelte b/src/routes/Page.svelte index b2ae25e..8f077bd 100644 --- a/src/routes/Page.svelte +++ b/src/routes/Page.svelte @@ -1,8 +1,12 @@ -
+
+ +
diff --git a/src/shared/lib/helpers/createVirtualizer/createVirtualizer.svelte.ts b/src/shared/lib/helpers/createVirtualizer/createVirtualizer.svelte.ts index ea96535..af9e7ba 100644 --- a/src/shared/lib/helpers/createVirtualizer/createVirtualizer.svelte.ts +++ b/src/shared/lib/helpers/createVirtualizer/createVirtualizer.svelte.ts @@ -79,7 +79,11 @@ export interface VirtualizerOptions { *
* ``` */ -export function createVirtualizer(optionsGetter: () => VirtualizerOptions & { data: T[] }) { +export function createVirtualizer( + optionsGetter: () => VirtualizerOptions & { + data: T[]; + }, +) { let scrollOffset = $state(0); let containerHeight = $state(0); let measuredSizes = $state>({}); @@ -149,6 +153,7 @@ export function createVirtualizer(optionsGetter: () => VirtualizerOptions & { key: options.getItemKey?.(i) ?? i, }); } + return result; }); // Svelte Actions (The DOM Interface) From 7ca45c2e635ee917f46cb7e4fcc22b6978afa327 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Sun, 18 Jan 2026 15:53:16 +0300 Subject: [PATCH 45/48] chore: add import shortcuts --- src/features/DisplayFont/ui/index.ts | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 src/features/DisplayFont/ui/index.ts diff --git a/src/features/DisplayFont/ui/index.ts b/src/features/DisplayFont/ui/index.ts new file mode 100644 index 0000000..cf3cfc7 --- /dev/null +++ b/src/features/DisplayFont/ui/index.ts @@ -0,0 +1,3 @@ +import FontDisplay from './FontDisplay/FontDisplay.svelte'; + +export { FontDisplay }; From 8356e99382542d64e8e05673b51b910ec5629d0e Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Sun, 18 Jan 2026 15:53:44 +0300 Subject: [PATCH 46/48] chore: add import shortcuts --- src/features/DisplayFont/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/features/DisplayFont/index.ts b/src/features/DisplayFont/index.ts index e69de29..4fb9052 100644 --- a/src/features/DisplayFont/index.ts +++ b/src/features/DisplayFont/index.ts @@ -0,0 +1 @@ +export { FontDisplay } from './ui'; From 9cbf4fdc48adca608206474903192323bfac7edc Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Sun, 18 Jan 2026 15:55:07 +0300 Subject: [PATCH 47/48] doc: comments for codebase and updated documentation --- .../appliedFontsStore.svelte.ts | 7 ++++++ .../selectedFontsStore.svelte.ts | 3 +++ .../ui/FontApplicator/FontApplicator.svelte | 20 ++++++++++++---- .../Font/ui/FontListItem/FontListItem.svelte | 3 +++ .../ui/FontVirtualList/FontVirtualList.svelte | 7 +++++- .../model/store/displayedFontsStore.svelte.ts | 5 ++++ .../ui/FontSampler/FontSampler.svelte | 9 ++++++++ .../lib/filterManager/filterManager.svelte.ts | 5 ++++ .../GetFonts/lib/mapper/mapManagerToParams.ts | 6 +++++ .../GetFonts/ui/Filters/Filters.svelte | 18 ++++----------- .../ui/FiltersControl/FilterControls.svelte | 13 ++++------- .../ui/SuggestedFonts/SuggestedFonts.svelte | 4 ++++ .../controlManager/controlManager.svelte.ts | 6 +++++ .../SetupFont/ui/SetupFontMenu.svelte | 7 +++--- src/shared/lib/accessibility/motion.svelte.ts | 1 + .../createEntityStore.svelte.ts | 5 ++++ src/shared/lib/utils/splitArray/splitArray.ts | 6 +++++ .../ui/CheckboxFilter/CheckboxFilter.svelte | 23 ++++++++----------- .../ui/ComboControl/ComboControl.svelte | 7 ++++++ 19 files changed, 111 insertions(+), 44 deletions(-) diff --git a/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts b/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts index 6e8fa63..3394b6f 100644 --- a/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts +++ b/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts @@ -2,6 +2,13 @@ import { SvelteMap } from 'svelte/reactivity'; export type FontStatus = 'loading' | 'loaded' | 'error'; +/** + * Manager that handles loading of the fonts + * Adds tags to + * - Uses batch loading to reduce the number of requests + * - Uses a queue to prevent too many requests at once + * - Purges unused fonts after a certain time + */ class AppliedFontsManager { // Stores: slug -> timestamp of last visibility #usageTracker = new Map(); diff --git a/src/entities/Font/model/store/selectedFontsStore/selectedFontsStore.svelte.ts b/src/entities/Font/model/store/selectedFontsStore/selectedFontsStore.svelte.ts index d90d22b..de3c3f8 100644 --- a/src/entities/Font/model/store/selectedFontsStore/selectedFontsStore.svelte.ts +++ b/src/entities/Font/model/store/selectedFontsStore/selectedFontsStore.svelte.ts @@ -1,4 +1,7 @@ import { createEntityStore } from '$shared/lib'; import type { UnifiedFont } from '../../types'; +/** + * Store that handles collection of selected fonts + */ export const selectedFontsStore = createEntityStore([]); diff --git a/src/entities/Font/ui/FontApplicator/FontApplicator.svelte b/src/entities/Font/ui/FontApplicator/FontApplicator.svelte index 110e596..9bd3415 100644 --- a/src/entities/Font/ui/FontApplicator/FontApplicator.svelte +++ b/src/entities/Font/ui/FontApplicator/FontApplicator.svelte @@ -1,19 +1,31 @@ diff --git a/src/features/GetFonts/ui/FiltersControl/FilterControls.svelte b/src/features/GetFonts/ui/FiltersControl/FilterControls.svelte index 31ee53f..2b74696 100644 --- a/src/features/GetFonts/ui/FiltersControl/FilterControls.svelte +++ b/src/features/GetFonts/ui/FiltersControl/FilterControls.svelte @@ -1,14 +1,11 @@ +
diff --git a/src/features/GetFonts/ui/SuggestedFonts/SuggestedFonts.svelte b/src/features/GetFonts/ui/SuggestedFonts/SuggestedFonts.svelte index 9a2565d..3c24e8c 100644 --- a/src/features/GetFonts/ui/SuggestedFonts/SuggestedFonts.svelte +++ b/src/features/GetFonts/ui/SuggestedFonts/SuggestedFonts.svelte @@ -1,3 +1,7 @@ +