diff --git a/src/shared/lib/helpers/createCharacterComparison/createCharacterComparison.svelte.ts b/src/shared/lib/helpers/createCharacterComparison/createCharacterComparison.svelte.ts index d3d426d..99b5430 100644 --- a/src/shared/lib/helpers/createCharacterComparison/createCharacterComparison.svelte.ts +++ b/src/shared/lib/helpers/createCharacterComparison/createCharacterComparison.svelte.ts @@ -1,25 +1,83 @@ /** - * Interface representing a line of text with its measured width. + * Character-by-character font comparison helper + * + * Creates utilities for comparing two fonts character by character. + * Used by the ComparisonView widget to render morphing text effects + * where characters transition between font A and font B based on + * slider position. + * + * Features: + * - Responsive text measurement using canvas + * - Binary search for optimal line breaking + * - Character proximity calculation for morphing effects + * - Handles CSS transforms correctly (uses offsetWidth) + * + * @example + * ```svelte + * + * + * + *
+ * {#each lines as line} + * {line.text} + * {/each} + *
+ * ``` + */ + +/** + * Represents a single line of text with its measured width */ export interface LineData { - /** - * Line's text - */ + /** The text content of the line */ text: string; - /** - * It's width - */ + /** Maximum width between both fonts in pixels */ width: number; } /** - * Creates a helper for splitting text into lines and calculating character proximity. - * This is used by the ComparisonSlider (TestTen) to render morphing text. + * Creates a character comparison helper for morphing text effects * - * @param text - The text to split and measure - * @param fontA - The first font definition - * @param fontB - The second font definition - * @returns Object with reactive state (lines, containerWidth) and methods (breakIntoLines, getCharState) + * Measures text in both fonts to determine line breaks and calculates + * character-level proximity for morphing animations. + * + * @param text - Getter for the text to compare + * @param fontA - Getter for the first font (left/top side) + * @param fontB - Getter for the second font (right/bottom side) + * @param weight - Getter for the current font weight + * @param size - Getter for the controlled font size + * @returns Character comparison instance with lines and proximity calculations + * + * @example + * ```ts + * const comparison = createCharacterComparison( + * () => $sampleText, + * () => $selectedFontA, + * () => $selectedFontB, + * () => $fontWeight, + * () => $fontSize + * ); + * + * // Call when DOM is ready + * comparison.breakIntoLines(container, canvas); + * + * // Get character state for morphing + * const state = comparison.getCharState(5, sliderPosition, lineEl, container); + * // state.proximity: 0-1 value for opacity/interpolation + * // state.isPast: true if slider is past this character + * ``` */ export function createCharacterComparison< T extends { name: string; id: string } | undefined = undefined, @@ -33,17 +91,22 @@ export function createCharacterComparison< let lines = $state([]); let containerWidth = $state(0); + /** + * Type guard to check if a font is defined + */ function fontDefined(font: T | undefined): font is T { return font !== undefined; } /** - * Measures text width using a canvas context. + * Measures text width using canvas 2D context + * * @param ctx - Canvas rendering context * @param text - Text string to measure - * @param fontFamily - Font family name * @param fontSize - Font size in pixels - * @param fontWeight - Font weight + * @param fontWeight - Font weight (100-900) + * @param fontFamily - Font family name (optional, returns 0 if missing) + * @returns Width of text in pixels */ function measureText( ctx: CanvasRenderingContext2D, @@ -58,8 +121,13 @@ export function createCharacterComparison< } /** - * Determines the appropriate font size based on window width. - * Matches the Tailwind breakpoints used in the component. + * Gets responsive font size based on viewport width + * + * Matches Tailwind breakpoints used in the component: + * - < 640px: 64px + * - 640-767px: 80px + * - 768-1023px: 96px + * - >= 1024px: 112px */ function getFontSize() { if (typeof window === 'undefined') { @@ -75,13 +143,14 @@ export function createCharacterComparison< } /** - * Breaks the text into lines based on the container width and measure canvas. - * Populates the `lines` state. + * Breaks text into lines based on container width * - * @param container - The container element to measure width from - * @param measureCanvas - The canvas element used for text measurement + * Measures text in BOTH fonts and uses the wider width to prevent + * layout shifts. Uses binary search for efficient word breaking. + * + * @param container - Container element to measure width from + * @param measureCanvas - Hidden canvas element for text measurement */ - function breakIntoLines( container: HTMLElement | undefined, measureCanvas: HTMLCanvasElement | undefined, @@ -90,13 +159,11 @@ export function createCharacterComparison< return; } - // Use offsetWidth instead of getBoundingClientRect() to avoid CSS transform scaling issues - // getBoundingClientRect() returns transformed dimensions, which causes incorrect line breaking - // when PerspectivePlan applies scale() transforms (e.g., scale(0.5) in settings mode) + // Use offsetWidth to avoid CSS transform scaling issues + // getBoundingClientRect() includes transform scale which breaks calculations const width = container.offsetWidth; containerWidth = width; - // Padding considerations - matches the container padding const padding = window.innerWidth < 640 ? 48 : 96; const availableWidth = width - padding; const ctx = measureCanvas.getContext('2d'); @@ -106,17 +173,19 @@ export function createCharacterComparison< const controlledFontSize = size(); const fontSize = getFontSize(); - const currentWeight = weight(); // Get current weight + const currentWeight = weight(); const words = text().split(' '); const newLines: LineData[] = []; let currentLineWords: string[] = []; + /** + * Adds a line to the output using the wider font's width + */ function pushLine(words: string[]) { if (words.length === 0 || !fontDefined(fontA()) || !fontDefined(fontB())) { return; } const lineText = words.join(' '); - // Measure both fonts at the CURRENT weight const widthA = measureText( ctx!, lineText, @@ -139,7 +208,7 @@ export function createCharacterComparison< const testLine = currentLineWords.length > 0 ? currentLineWords.join(' ') + ' ' + word : word; - // Measure with both fonts and use the wider one to prevent layout shifts + // Measure with both fonts - use wider to prevent shifts const widthA = measureText( ctx, testLine, @@ -163,6 +232,7 @@ export function createCharacterComparison< currentLineWords = []; } + // Check if word alone fits const wordWidthA = measureText( ctx, word, @@ -180,16 +250,16 @@ export function createCharacterComparison< const wordAloneWidth = Math.max(wordWidthA, wordWidthB); if (wordAloneWidth <= availableWidth) { - // If word fits start new line with it currentLineWords = [word]; } else { + // Word doesn't fit - binary search to find break point let remainingWord = word; while (remainingWord.length > 0) { let low = 1; let high = remainingWord.length; let bestBreak = 1; - // Binary Search to find the maximum characters that fit + // Binary search for maximum characters that fit while (low <= high) { const mid = Math.floor((low + high) / 2); const testFragment = remainingWord.slice(0, mid); @@ -236,13 +306,16 @@ export function createCharacterComparison< } /** - * precise calculation of character state based on global slider position. + * Calculates character proximity to slider position * - * @param charIndex - Index of the character in the line - * @param sliderPos - Current slider position (0-100) - * @param lineElement - The line element - * @param container - The container element - * @returns Object containing proximity (0-1) and isPast (boolean) + * Used for morphing effects - returns how close a character is to + * the slider and whether it's on the "past" side. + * + * @param charIndex - Index of character within its line + * @param sliderPos - Slider position (0-100, percent across container) + * @param lineElement - The line element containing the character + * @param container - The container element for position calculations + * @returns Proximity (0-1, 1 = at slider) and isPast (true = right of slider) */ function getCharState( charIndex: number, @@ -262,14 +335,15 @@ export function createCharacterComparison< return { proximity: 0, isPast: false }; } - // Get the actual bounding box of the character + // Get character bounding box relative to container const charRect = charElement.getBoundingClientRect(); const containerRect = container.getBoundingClientRect(); - // Calculate character center relative to container + // Calculate character center as percentage of container width const charCenter = charRect.left + (charRect.width / 2) - containerRect.left; const charGlobalPercent = (charCenter / containerWidth) * 100; + // Calculate proximity (1.0 = at slider, 0.0 = 5% away) const distance = Math.abs(sliderPos - charGlobalPercent); const range = 5; const proximity = Math.max(0, 1 - distance / range); @@ -279,15 +353,22 @@ export function createCharacterComparison< } return { + /** Reactive array of broken lines */ get lines() { return lines; }, + /** Container width in pixels */ get containerWidth() { return containerWidth; }, + /** Break text into lines based on current container and fonts */ breakIntoLines, + /** Get character state for morphing calculations */ getCharState, }; } +/** + * Type representing a character comparison instance + */ export type CharacterComparison = ReturnType; diff --git a/src/shared/lib/helpers/createCharacterComparison/createCharacterComparison.test.ts b/src/shared/lib/helpers/createCharacterComparison/createCharacterComparison.test.ts new file mode 100644 index 0000000..04348a1 --- /dev/null +++ b/src/shared/lib/helpers/createCharacterComparison/createCharacterComparison.test.ts @@ -0,0 +1,312 @@ +import { + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; +import { createCharacterComparison } from './createCharacterComparison.svelte'; + +type Font = { name: string; id: string }; + +const fontA: Font = { name: 'Roboto', id: 'roboto' }; +const fontB: Font = { name: 'Open Sans', id: 'open-sans' }; + +function createMockCanvas(charWidth = 10): HTMLCanvasElement { + return { + getContext: () => ({ + font: '', + measureText: (text: string) => ({ width: text.length * charWidth }), + }), + } as unknown as HTMLCanvasElement; +} + +function createMockContainer(offsetWidth = 500): HTMLElement { + return { + offsetWidth, + getBoundingClientRect: () => ({ + left: 0, + width: offsetWidth, + top: 0, + right: offsetWidth, + bottom: 0, + height: 0, + }), + } as unknown as HTMLElement; +} + +describe('createCharacterComparison', () => { + beforeEach(() => { + // Mock window.innerWidth for getFontSize and padding calculations + Object.defineProperty(globalThis, 'window', { + value: { innerWidth: 1024 }, + writable: true, + configurable: true, + }); + }); + + describe('Initial State', () => { + it('should initialize with empty lines and zero container width', () => { + const comparison = createCharacterComparison( + () => 'test', + () => fontA, + () => fontB, + () => 400, + () => 48, + ); + + expect(comparison.lines).toEqual([]); + expect(comparison.containerWidth).toBe(0); + }); + }); + + describe('breakIntoLines', () => { + it('should not break lines when container or canvas is undefined', () => { + const comparison = createCharacterComparison( + () => 'Hello world', + () => fontA, + () => fontB, + () => 400, + () => 48, + ); + + comparison.breakIntoLines(undefined, undefined); + expect(comparison.lines).toEqual([]); + + comparison.breakIntoLines(createMockContainer(), undefined); + expect(comparison.lines).toEqual([]); + }); + + it('should not break lines when fonts are undefined', () => { + const comparison = createCharacterComparison( + () => 'Hello world', + () => undefined, + () => undefined, + () => 400, + () => 48, + ); + + comparison.breakIntoLines(createMockContainer(), createMockCanvas()); + expect(comparison.lines).toEqual([]); + }); + + it('should produce a single line when text fits within container', () => { + // charWidth=10, padding=96 (innerWidth>=640), availableWidth=500-96=404 + // "Hello" = 5 chars * 10 = 50px, fits easily + const comparison = createCharacterComparison( + () => 'Hello', + () => fontA, + () => fontB, + () => 400, + () => 48, + ); + + comparison.breakIntoLines(createMockContainer(500), createMockCanvas(10)); + + expect(comparison.lines).toHaveLength(1); + expect(comparison.lines[0].text).toBe('Hello'); + }); + + it('should break text into multiple lines when it overflows', () => { + // charWidth=10, container=200, padding=96, availableWidth=104 + // "Hello world test" => "Hello" (50px), "Hello world" (110px > 104) + // So "Hello" goes on line 1, "world" (50px) fits, "world test" (100px) fits + const comparison = createCharacterComparison( + () => 'Hello world test', + () => fontA, + () => fontB, + () => 400, + () => 48, + ); + + comparison.breakIntoLines(createMockContainer(200), createMockCanvas(10)); + + expect(comparison.lines.length).toBeGreaterThan(1); + // All original text should be preserved across lines + const reconstructed = comparison.lines.map(l => l.text).join(' '); + expect(reconstructed).toBe('Hello world test'); + }); + + it('should update containerWidth after breaking lines', () => { + const comparison = createCharacterComparison( + () => 'Hi', + () => fontA, + () => fontB, + () => 400, + () => 48, + ); + + comparison.breakIntoLines(createMockContainer(750), createMockCanvas(10)); + + expect(comparison.containerWidth).toBe(750); + }); + + it('should use smaller padding on narrow viewports', () => { + Object.defineProperty(globalThis, 'window', { + value: { innerWidth: 500 }, + writable: true, + configurable: true, + }); + + // container=150, padding=48 (innerWidth<640), availableWidth=102 + // "ABCDEFGHIJ" = 10 chars * 10 = 100px, fits in 102 + const comparison = createCharacterComparison( + () => 'ABCDEFGHIJ', + () => fontA, + () => fontB, + () => 400, + () => 48, + ); + + comparison.breakIntoLines(createMockContainer(150), createMockCanvas(10)); + + expect(comparison.lines).toHaveLength(1); + expect(comparison.lines[0].text).toBe('ABCDEFGHIJ'); + }); + + it('should break a single long word using binary search', () => { + // container=150, padding=96, availableWidth=54 + // "ABCDEFGHIJ" = 10 chars * 10 = 100px, doesn't fit as single word + // Binary search should split it + const comparison = createCharacterComparison( + () => 'ABCDEFGHIJ', + () => fontA, + () => fontB, + () => 400, + () => 48, + ); + + comparison.breakIntoLines(createMockContainer(150), createMockCanvas(10)); + + expect(comparison.lines.length).toBeGreaterThan(1); + const reconstructed = comparison.lines.map(l => l.text).join(''); + expect(reconstructed).toBe('ABCDEFGHIJ'); + }); + + it('should store max width between both fonts for each line', () => { + // Use a canvas where measureText returns text.length * charWidth + // Both fonts measure the same, so width = text.length * charWidth + const comparison = createCharacterComparison( + () => 'Hi', + () => fontA, + () => fontB, + () => 400, + () => 48, + ); + + comparison.breakIntoLines(createMockContainer(500), createMockCanvas(10)); + + expect(comparison.lines[0].width).toBe(20); // "Hi" = 2 chars * 10 + }); + }); + + describe('getCharState', () => { + it('should return zero proximity and isPast=false when containerWidth is 0', () => { + const comparison = createCharacterComparison( + () => 'test', + () => fontA, + () => fontB, + () => 400, + () => 48, + ); + + const state = comparison.getCharState(0, 50, undefined, undefined); + + expect(state.proximity).toBe(0); + expect(state.isPast).toBe(false); + }); + + it('should return zero proximity when charElement is not found', () => { + const comparison = createCharacterComparison( + () => 'test', + () => fontA, + () => fontB, + () => 400, + () => 48, + ); + + // First break lines to set containerWidth + comparison.breakIntoLines(createMockContainer(500), createMockCanvas(10)); + + const lineEl = { children: [] } as unknown as HTMLElement; + const container = createMockContainer(500); + const state = comparison.getCharState(0, 50, lineEl, container); + + expect(state.proximity).toBe(0); + expect(state.isPast).toBe(false); + }); + + it('should calculate proximity based on distance from slider', () => { + const comparison = createCharacterComparison( + () => 'test', + () => fontA, + () => fontB, + () => 400, + () => 48, + ); + + comparison.breakIntoLines(createMockContainer(500), createMockCanvas(10)); + + // Character centered at 250px in a 500px container = 50% + const charEl = { + getBoundingClientRect: () => ({ left: 240, width: 20 }), + }; + const lineEl = { children: [charEl] } as unknown as HTMLElement; + const container = createMockContainer(500); + + // Slider at 50% => charCenter at 250px => charGlobalPercent = 50% + // distance = |50 - 50| = 0, proximity = max(0, 1 - 0/5) = 1 + const state = comparison.getCharState(0, 50, lineEl, container); + + expect(state.proximity).toBe(1); + expect(state.isPast).toBe(false); + }); + + it('should return isPast=true when slider is past the character', () => { + const comparison = createCharacterComparison( + () => 'test', + () => fontA, + () => fontB, + () => 400, + () => 48, + ); + + comparison.breakIntoLines(createMockContainer(500), createMockCanvas(10)); + + // Character centered at 100px => 20% of 500px + const charEl = { + getBoundingClientRect: () => ({ left: 90, width: 20 }), + }; + const lineEl = { children: [charEl] } as unknown as HTMLElement; + const container = createMockContainer(500); + + // Slider at 80% => past the character at 20% + const state = comparison.getCharState(0, 80, lineEl, container); + + expect(state.isPast).toBe(true); + }); + + it('should return zero proximity when character is far from slider', () => { + const comparison = createCharacterComparison( + () => 'test', + () => fontA, + () => fontB, + () => 400, + () => 48, + ); + + comparison.breakIntoLines(createMockContainer(500), createMockCanvas(10)); + + // Character at 10% of container, slider at 90% => distance = 80%, range = 5% + const charEl = { + getBoundingClientRect: () => ({ left: 45, width: 10 }), + }; + const lineEl = { children: [charEl] } as unknown as HTMLElement; + const container = createMockContainer(500); + + const state = comparison.getCharState(0, 90, lineEl, container); + + expect(state.proximity).toBe(0); + }); + }); +}); diff --git a/src/shared/lib/helpers/createEntityStore/createEntityStore.svelte.ts b/src/shared/lib/helpers/createEntityStore/createEntityStore.svelte.ts index 46c6f00..c304ab2 100644 --- a/src/shared/lib/helpers/createEntityStore/createEntityStore.svelte.ts +++ b/src/shared/lib/helpers/createEntityStore/createEntityStore.svelte.ts @@ -1,48 +1,102 @@ +/** + * Generic entity store using Svelte 5's reactive SvelteMap + * + * Provides O(1) lookups by ID and granular reactivity for entity collections. + * Ideal for managing collections of objects with unique identifiers. + * + * @example + * ```ts + * interface User extends Entity { + * id: string; + * name: string; + * } + * + * const store = createEntityStore([ + * { id: '1', name: 'Alice' }, + * { id: '2', name: 'Bob' } + * ]); + * + * // Access is reactive in Svelte components + * const allUsers = store.all; + * const alice = store.getById('1'); + * ``` + */ + import { SvelteMap } from 'svelte/reactivity'; +/** + * Base entity interface requiring an ID field + */ export interface Entity { + /** Unique identifier for the entity */ id: string; } /** - * Svelte 5 Entity Store - * Uses SvelteMap for O(1) lookups and granular reactivity. + * Reactive entity store with O(1) lookups + * + * Uses SvelteMap internally for reactive state that automatically + * triggers updates when entities are added, removed, or modified. */ export class EntityStore { - // SvelteMap is a reactive version of the native Map + /** Reactive map of entities keyed by ID */ #entities = new SvelteMap(); + /** + * Creates a new entity store with optional initial data + * @param initialEntities - Initial entities to populate the store + */ constructor(initialEntities: T[] = []) { this.setAll(initialEntities); } - // --- Selectors (Equivalent to Selectors) --- - - /** Get all entities as an array */ + /** + * Get all entities as an array + * @returns Array of all entities in the store + */ get all() { return Array.from(this.#entities.values()); } - /** Select a single entity by ID */ + /** + * Get a single entity by ID + * @param id - Entity ID to look up + * @returns The entity if found, undefined otherwise + */ getById(id: string) { return this.#entities.get(id); } - /** Select multiple entities by IDs */ + /** + * Get multiple entities by their IDs + * @param ids - Array of entity IDs to look up + * @returns Array of found entities (undefined IDs are filtered out) + */ getByIds(ids: string[]) { return ids.map(id => this.#entities.get(id)).filter((e): e is T => !!e); } - // --- Actions (CRUD) --- - + /** + * Add a single entity to the store + * @param entity - Entity to add (updates if ID already exists) + */ addOne(entity: T) { this.#entities.set(entity.id, entity); } + /** + * Add multiple entities to the store + * @param entities - Array of entities to add + */ addMany(entities: T[]) { entities.forEach(e => this.addOne(e)); } + /** + * Update an existing entity by merging changes + * @param id - ID of entity to update + * @param changes - Partial changes to merge into existing entity + */ updateOne(id: string, changes: Partial) { const entity = this.#entities.get(id); if (entity) { @@ -50,32 +104,61 @@ export class EntityStore { } } + /** + * Remove a single entity by ID + * @param id - ID of entity to remove + */ removeOne(id: string) { this.#entities.delete(id); } + /** + * Remove multiple entities by their IDs + * @param ids - Array of entity IDs to remove + */ removeMany(ids: string[]) { ids.forEach(id => this.#entities.delete(id)); } + /** + * Replace all entities in the store + * Clears existing entities and adds new ones + * @param entities - New entities to populate the store with + */ setAll(entities: T[]) { this.#entities.clear(); this.addMany(entities); } + /** + * Check if an entity exists in the store + * @param id - Entity ID to check + * @returns true if entity exists, false otherwise + */ has(id: string) { return this.#entities.has(id); } + /** + * Remove all entities from the store + */ clear() { this.#entities.clear(); } } /** - * Creates a new EntityStore instance with the given initial entities. - * @param initialEntities The initial entities to populate the store with. - * @returns - A new EntityStore instance. + * Creates a new entity store instance + * @param initialEntities - Initial entities to populate the store with + * @returns A new EntityStore instance + * + * @example + * ```ts + * const store = createEntityStore([ + * { id: '1', name: 'Item 1' }, + * { id: '2', name: 'Item 2' } + * ]); + * ``` */ export function createEntityStore(initialEntities: T[] = []) { return new EntityStore(initialEntities); diff --git a/src/shared/lib/helpers/createFilter/createFilter.svelte.ts b/src/shared/lib/helpers/createFilter/createFilter.svelte.ts index 5789d0c..2787482 100644 --- a/src/shared/lib/helpers/createFilter/createFilter.svelte.ts +++ b/src/shared/lib/helpers/createFilter/createFilter.svelte.ts @@ -1,35 +1,80 @@ +/** + * Filter state management for multi-select property filtering + * + * Creates reactive state for managing filterable properties with selection state. + * Commonly used for category filters, tag selection, and other multi-select UIs. + * + * @example + * ```ts + * const filter = createFilter({ + * properties: [ + * { id: 'sans', name: 'Sans Serif', value: 'sans-serif', selected: false }, + * { id: 'serif', name: 'Serif', value: 'serif', selected: false } + * ] + * }); + * + * // Access state + * filter.selectedProperties; // Currently selected items + * filter.selectedCount; // Number of selected items + * + * // Modify state + * filter.toggleProperty('sans'); + * filter.selectAll(); + * ``` + */ + +/** + * A filterable property with selection state + * + * @template TValue - The type of the property value (typically string) + */ export interface Property { - /** - * Property identifier - */ + /** Unique identifier for the property */ id: string; - /** - * Property name - */ + /** Human-readable display name */ name: string; - /** - * Property value - */ + /** Underlying value for filtering logic */ value: TValue; - /** - * Property selected state - */ + /** Whether the property is currently selected */ selected?: boolean; } +/** + * Initial state configuration for a filter + * + * @template TValue - The type of property values + */ export interface FilterModel { - /** - * Properties - */ + /** Array of filterable properties */ properties: Property[]; } /** - * Create a filter store. - * @param initialState - Initial state of filter store + * Creates a reactive filter store for managing multi-select state + * + * Provides methods for toggling, selecting, and deselecting properties + * along with derived state for selected items and counts. + * + * @param initialState - Initial configuration of properties and their selection state + * @returns Filter instance with reactive properties and methods + * + * @example + * ```ts + * // Create category filter + * const categoryFilter = createFilter({ + * properties: [ + * { id: 'sans', name: 'Sans Serif', value: 'sans-serif' }, + * { id: 'serif', name: 'Serif', value: 'serif' }, + * { id: 'display', name: 'Display', value: 'display' } + * ] + * }); + * + * // In a Svelte component + * $: selected = categoryFilter.selectedProperties; + * ``` */ export function createFilter(initialState: FilterModel) { - // We map the initial properties into a reactive state array + // Map initial properties to reactive state with defaulted selection const properties = $state( initialState.properties.map(p => ({ ...p, @@ -41,41 +86,77 @@ export function createFilter(initialState: FilterModel properties.find(p => p.id === id); return { + /** + * All properties with their current selection state + */ get properties() { return properties; }, + + /** + * Only properties that are currently selected + */ get selectedProperties() { return properties.filter(p => p.selected); }, + + /** + * Count of currently selected properties + */ get selectedCount() { return properties.filter(p => p.selected)?.length; }, + /** + * Toggle the selection state of a property + * @param id - Property ID to toggle + */ toggleProperty(id: string) { const property = findProp(id); if (property) { property.selected = !property.selected; } }, + + /** + * Select a property (idempotent - safe if already selected) + * @param id - Property ID to select + */ selectProperty(id: string) { const property = findProp(id); if (property) { property.selected = true; } }, + + /** + * Deselect a property (idempotent - safe if already deselected) + * @param id - Property ID to deselect + */ deselectProperty(id: string) { const property = findProp(id); if (property) { property.selected = false; } }, + + /** + * Select all properties + */ selectAll() { properties.forEach(property => property.selected = true); }, + + /** + * Deselect all properties + */ deselectAll() { properties.forEach(property => property.selected = false); }, }; } +/** + * Type representing a filter instance + */ export type Filter = ReturnType; diff --git a/src/shared/lib/helpers/createPersistentStore/createPersistentStore.svelte.ts b/src/shared/lib/helpers/createPersistentStore/createPersistentStore.svelte.ts index 761998b..028770c 100644 --- a/src/shared/lib/helpers/createPersistentStore/createPersistentStore.svelte.ts +++ b/src/shared/lib/helpers/createPersistentStore/createPersistentStore.svelte.ts @@ -1,10 +1,66 @@ /** - * Reusable persistent storage utility using Svelte 5 runes + * Persistent localStorage-backed reactive state * - * Automatically syncs state with localStorage. + * Creates reactive state that automatically syncs with localStorage. + * Values persist across browser sessions and are restored on page load. + * + * Handles edge cases: + * - SSR safety (no localStorage on server) + * - JSON parse errors (falls back to default) + * - Storage quota errors (logs warning, doesn't crash) + * + * @example + * ```ts + * // Store user preferences + * const preferences = createPersistentStore('user-prefs', { + * theme: 'dark', + * fontSize: 16, + * sidebarOpen: true + * }); + * + * // Access reactive state + * $: currentTheme = preferences.value.theme; + * + * // Update (auto-saves to localStorage) + * preferences.value.theme = 'light'; + * + * // Clear stored value + * preferences.clear(); + * ``` + */ + +/** + * Creates a reactive store backed by localStorage + * + * The value is loaded from localStorage on initialization and automatically + * saved whenever it changes. Uses Svelte 5's $effect for reactive sync. + * + * @param key - localStorage key for storing the value + * @param defaultValue - Default value if no stored value exists + * @returns Persistent store with getter/setter and clear method + * + * @example + * ```ts + * // Simple value + * const counter = createPersistentStore('counter', 0); + * counter.value++; + * + * // Complex object + * interface Settings { + * theme: 'light' | 'dark'; + * fontSize: number; + * } + * const settings = createPersistentStore('app-settings', { + * theme: 'light', + * fontSize: 16 + * }); + * ``` */ export function createPersistentStore(key: string, defaultValue: T) { - // Initialize from storage or default + /** + * Load value from localStorage or return default + * Safely handles missing keys, parse errors, and SSR + */ const loadFromStorage = (): T => { if (typeof window === 'undefined') { return defaultValue; @@ -21,6 +77,7 @@ export function createPersistentStore(key: string, defaultValue: T) { let value = $state(loadFromStorage()); // Sync to storage whenever value changes + // Wrapped in $effect.root to prevent memory leaks $effect.root(() => { $effect(() => { if (typeof window === 'undefined') { @@ -29,18 +86,27 @@ export function createPersistentStore(key: string, defaultValue: T) { try { localStorage.setItem(key, JSON.stringify(value)); } catch (error) { + // Quota exceeded or privacy mode - log but don't crash console.warn(`[createPersistentStore] Error saving ${key}:`, error); } }); }); return { + /** + * Current value (getter/setter) + * Changes automatically persist to localStorage + */ get value() { return value; }, set value(v: T) { value = v; }, + + /** + * Remove value from localStorage and reset to default + */ clear() { if (typeof window !== 'undefined') { localStorage.removeItem(key); @@ -50,4 +116,7 @@ export function createPersistentStore(key: string, defaultValue: T) { }; } +/** + * Type representing a persistent store instance + */ export type PersistentStore = ReturnType>; diff --git a/src/shared/lib/helpers/createPerspectiveManager/createPerspectiveManager.svelte.ts b/src/shared/lib/helpers/createPerspectiveManager/createPerspectiveManager.svelte.ts index 4a98ca8..4ed4416 100644 --- a/src/shared/lib/helpers/createPerspectiveManager/createPerspectiveManager.svelte.ts +++ b/src/shared/lib/helpers/createPerspectiveManager/createPerspectiveManager.svelte.ts @@ -1,61 +1,83 @@ +/** + * 3D perspective animation state manager + * + * Manages smooth transitions between "front" (interactive) and "back" (background) + * visual states using Svelte springs. Used for creating depth-based UI effects + * like settings panels, modal transitions, and spatial navigation. + * + * @example + * ```svelte + * + * + *
+ * + *
+ * ``` + */ + import { Spring } from 'svelte/motion'; +/** + * Configuration options for perspective effects + */ export interface PerspectiveConfig { - /** - * How many px to move back per level - */ + /** Z-axis translation per level in pixels */ depthStep?: number; - /** - * Scale reduction per level - */ + /** Scale reduction per level (0-1) */ scaleStep?: number; - /** - * Blur amount per level - */ + /** Blur amount per level in pixels */ blurStep?: number; - /** - * Opacity reduction per level - */ + /** Opacity reduction per level (0-1) */ opacityStep?: number; - /** - * Parallax intensity per level - */ + /** Parallax movement intensity per level */ parallaxIntensity?: number; - /** - * Horizontal offset for each plan (x-axis positioning) - * Positive = right, Negative = left - */ + /** Horizontal offset - positive for right, negative for left */ horizontalOffset?: number; - /** - * Layout mode: 'center' (default) or 'split' for Swiss-style side-by-side - */ + /** Layout mode: 'center' for centered, 'split' for side-by-side */ layoutMode?: 'center' | 'split'; } /** - * Manages perspective state with a simple boolean flag. + * Manages perspective state with spring-based transitions * - * Drastically simplified from the complex camera/index system. - * Just manages whether content is in "back" or "front" state. + * Simplified from a complex camera system to just track back/front state. + * The spring value animates between 0 (front) and 1 (back) for smooth + * visual transitions of scale, blur, opacity, and position. * * @example - * ```typescript + * ```ts * const perspective = createPerspectiveManager({ * depthStep: 100, * scaleStep: 0.5, - * blurStep: 4, + * blurStep: 4 * }); * - * // Toggle back/front - * perspective.toggle(); + * // Check state (reactive) + * console.log(perspective.isBack); // false * - * // Check state - * const isBack = perspective.isBack; // reactive boolean + * // Toggle with animation + * perspective.toggle(); // Smoothly animates to back position + * + * // Direct control + * perspective.setBack(); // Go to back + * perspective.setFront(); // Go to front * ``` */ export class PerspectiveManager { /** - * Spring for smooth back/front transitions + * Spring animation state + * Animates between 0 (front) and 1 (back) with configurable physics */ spring = new Spring(0, { stiffness: 0.2, @@ -63,20 +85,30 @@ export class PerspectiveManager { }); /** - * Reactive boolean: true when in back position (blurred, scaled down) + * Reactive state: true when in back position + * + * Content should appear blurred, scaled down, and less interactive + * when this is true. Derived from spring value > 0.5. */ isBack = $derived(this.spring.current > 0.5); /** - * Reactive boolean: true when in front position (fully visible, interactive) + * Reactive state: true when in front position + * + * Content should be fully visible, sharp, and interactive + * when this is true. Derived from spring value < 0.5. */ isFront = $derived(this.spring.current < 0.5); /** - * Configuration values for style computation + * Internal configuration with defaults applied */ private config: Required; + /** + * Creates a new perspective manager + * @param config - Configuration for visual effects + */ constructor(config: PerspectiveConfig = {}) { this.config = { depthStep: config.depthStep ?? 100, @@ -90,8 +122,10 @@ export class PerspectiveManager { } /** - * Toggle between front (0) and back (1) positions. - * Smooth spring animation handles the transition. + * Toggle between front and back positions + * + * Uses spring animation for smooth transition. Toggles based on + * current state - if spring < 0.5 goes to 1, otherwise goes to 0. */ toggle = () => { const target = this.spring.current < 0.5 ? 1 : 0; @@ -99,31 +133,40 @@ export class PerspectiveManager { }; /** - * Force to back position + * Force to back position (blurred, scaled down) */ setBack = () => { this.spring.target = 1; }; /** - * Force to front position + * Force to front position (fully visible, interactive) */ setFront = () => { this.spring.target = 0; }; /** - * Get configuration for style computation - * @internal + * Get current configuration + * @internal Used by components to compute styles */ getConfig = () => this.config; } /** - * Factory function to create a PerspectiveManager instance. + * Factory function to create a perspective manager * - * @param config - Configuration options + * @param config - Configuration options for visual effects * @returns Configured PerspectiveManager instance + * + * @example + * ```ts + * const perspective = createPerspectiveManager({ + * scaleStep: 0.6, + * blurStep: 8, + * layoutMode: 'split' + * }); + * ``` */ export function createPerspectiveManager(config: PerspectiveConfig = {}) { return new PerspectiveManager(config); diff --git a/src/shared/lib/helpers/createResponsiveManager/createResponsiveManager.svelte.ts b/src/shared/lib/helpers/createResponsiveManager/createResponsiveManager.svelte.ts index 6556732..413b848 100644 --- a/src/shared/lib/helpers/createResponsiveManager/createResponsiveManager.svelte.ts +++ b/src/shared/lib/helpers/createResponsiveManager/createResponsiveManager.svelte.ts @@ -1,66 +1,96 @@ -// $shared/lib/createResponsiveManager.svelte.ts +/** + * Responsive breakpoint tracking using Svelte 5 runes + * + * Provides reactive viewport dimensions and breakpoint detection that + * automatically updates on window resize. Includes touch device detection + * and orientation tracking. + * + * Default breakpoints match Tailwind CSS: + * - xs: < 640px (mobile) + * - sm: 640px (mobile) + * - md: 768px (tablet portrait) + * - lg: 1024px (tablet) + * - xl: 1280px (desktop) + * - 2xl: 1536px (desktop large) + * + * @example + * ```svelte + * + * + * {#if responsiveManager.isMobile} + * + * {:else} + * + * {/if} + * + *

Viewport: {responsiveManager.width}x{responsiveManager.height}

+ *

Breakpoint: {responsiveManager.currentBreakpoint}

+ * ``` + */ /** - * Breakpoint definitions following common device sizes - * Customize these values to match your design system + * Breakpoint definitions for responsive design + * + * Values represent the minimum width (in pixels) for each breakpoint. + * Customize to match your design system's breakpoints. */ export interface Breakpoints { - /** Mobile devices (portrait phones) */ + /** Mobile devices - default 640px */ mobile: number; - /** Tablet portrait */ + /** Tablet portrait - default 768px */ tabletPortrait: number; - /** Tablet landscape */ + /** Tablet landscape - default 1024px */ tablet: number; - /** Desktop */ + /** Desktop - default 1280px */ desktop: number; - /** Large desktop */ + /** Large desktop - default 1536px */ desktopLarge: number; } /** - * Default breakpoints (matches common Tailwind-like breakpoints) + * Default breakpoint values (Tailwind CSS compatible) */ const DEFAULT_BREAKPOINTS: Breakpoints = { - mobile: 640, // sm - tabletPortrait: 768, // md - tablet: 1024, // lg - desktop: 1280, // xl - desktopLarge: 1536, // 2xl + mobile: 640, + tabletPortrait: 768, + tablet: 1024, + desktop: 1280, + desktopLarge: 1536, }; /** - * Orientation type + * Device orientation type */ export type Orientation = 'portrait' | 'landscape'; /** - * Creates a reactive responsive manager that tracks viewport size and breakpoints. + * Creates a responsive manager for tracking viewport state * - * Provides reactive getters for: - * - Current breakpoint detection (isMobile, isTablet, etc.) - * - Viewport dimensions (width, height) - * - Device orientation (portrait/landscape) - * - Custom breakpoint matching + * Tracks viewport dimensions, calculates breakpoint states, and detects + * device capabilities (touch, orientation). Uses ResizeObserver for + * accurate tracking and falls back to window resize events. * * @param customBreakpoints - Optional custom breakpoint values * @returns Responsive manager instance with reactive properties * * @example - * ```svelte - * + * ```ts + * // Use defaults + * const responsive = createResponsiveManager(); * - * {#if responsive.isMobile} - * - * {:else if responsive.isTablet} - * - * {:else} - * - * {/if} + * // Custom breakpoints + * const custom = createResponsiveManager({ + * mobile: 480, + * desktop: 1024 + * }); * - *

Width: {responsive.width}px

- *

Orientation: {responsive.orientation}

+ * // In component + * $: isMobile = responsive.isMobile; + * $: cols = responsive.isDesktop ? 3 : 1; * ``` */ export function createResponsiveManager(customBreakpoints?: Partial) { @@ -69,7 +99,7 @@ export function createResponsiveManager(customBreakpoints?: Partial ...customBreakpoints, }; - // Reactive state + // Reactive viewport dimensions let width = $state(typeof window !== 'undefined' ? window.innerWidth : 0); let height = $state(typeof window !== 'undefined' ? window.innerHeight : 0); @@ -90,12 +120,12 @@ export function createResponsiveManager(customBreakpoints?: Partial const isMobileOrTablet = $derived(width < breakpoints.desktop); const isTabletOrDesktop = $derived(width >= breakpoints.tabletPortrait); - // Orientation + // Orientation detection const orientation = $derived(height > width ? 'portrait' : 'landscape'); const isPortrait = $derived(orientation === 'portrait'); const isLandscape = $derived(orientation === 'landscape'); - // Touch device detection (best effort) + // Touch device detection (best effort heuristic) const isTouchDevice = $derived( typeof window !== 'undefined' && ('ontouchstart' in window || navigator.maxTouchPoints > 0), @@ -103,7 +133,11 @@ export function createResponsiveManager(customBreakpoints?: Partial /** * Initialize responsive tracking - * Call this in an $effect or component mount + * + * Sets up ResizeObserver on document.documentElement and falls back + * to window resize event listener. Returns cleanup function. + * + * @returns Cleanup function to remove listeners */ function init() { if (typeof window === 'undefined') return; @@ -130,9 +164,17 @@ export function createResponsiveManager(customBreakpoints?: Partial } /** - * Check if current width matches a custom breakpoint + * Check if current viewport matches a custom breakpoint range + * * @param min - Minimum width (inclusive) - * @param max - Maximum width (exclusive) + * @param max - Optional maximum width (exclusive) + * @returns true if viewport width matches the range + * + * @example + * ```ts + * responsive.matches(768, 1024); // true for tablet only + * responsive.matches(1280); // true for desktop and larger + * ``` */ function matches(min: number, max?: number): boolean { if (max !== undefined) { @@ -142,7 +184,7 @@ export function createResponsiveManager(customBreakpoints?: Partial } /** - * Get the current breakpoint name + * Current breakpoint name based on viewport width */ const currentBreakpoint = $derived( (() => { @@ -158,16 +200,17 @@ export function createResponsiveManager(customBreakpoints?: Partial case isDesktopLarge: return 'desktopLarge'; default: - return 'xs'; // Fallback for very small screens + return 'xs'; } })(), ); return { - // Dimensions + /** Viewport width in pixels */ get width() { return width; }, + /** Viewport height in pixels */ get height() { return height; }, @@ -227,6 +270,12 @@ export function createResponsiveManager(customBreakpoints?: Partial }; } +/** + * Singleton responsive manager instance + * + * Auto-initializes on the client side. Use this throughout the app + * rather than creating multiple instances. + */ export const responsiveManager = createResponsiveManager(); if (typeof window !== 'undefined') { diff --git a/src/shared/lib/helpers/createResponsiveManager/createResponsiveManager.test.ts b/src/shared/lib/helpers/createResponsiveManager/createResponsiveManager.test.ts new file mode 100644 index 0000000..c964914 --- /dev/null +++ b/src/shared/lib/helpers/createResponsiveManager/createResponsiveManager.test.ts @@ -0,0 +1,107 @@ +/** + * Tests for createResponsiveManager + */ + +import { + describe, + expect, + it, +} from 'vitest'; +import { createResponsiveManager } from './createResponsiveManager.svelte'; + +describe('createResponsiveManager', () => { + describe('initialization', () => { + it('should create with default breakpoints', () => { + const manager = createResponsiveManager(); + + expect(manager.breakpoints).toEqual({ + mobile: 640, + tabletPortrait: 768, + tablet: 1024, + desktop: 1280, + desktopLarge: 1536, + }); + }); + + it('should merge custom breakpoints with defaults', () => { + const manager = createResponsiveManager({ mobile: 480, desktop: 1200 }); + + expect(manager.breakpoints.mobile).toBe(480); + expect(manager.breakpoints.desktop).toBe(1200); + expect(manager.breakpoints.tablet).toBe(1024); // default preserved + }); + + it('should have initial width and height from window', () => { + const manager = createResponsiveManager(); + + // In test environment, window dimensions come from jsdom/mocks + expect(typeof manager.width).toBe('number'); + expect(typeof manager.height).toBe('number'); + }); + }); + + describe('matches', () => { + it('should return true when width is above min', () => { + const manager = createResponsiveManager(); + + // width is 0 in node env (no window), so matches(0) should be true + expect(manager.matches(0)).toBe(true); + }); + + it('should return false when width is below min', () => { + const manager = createResponsiveManager(); + + // width is 0, so matches(100) should be false + expect(manager.matches(100)).toBe(false); + }); + + it('should handle range with max', () => { + const manager = createResponsiveManager(); + + // width is 0, so matches(0, 100) should be true (0 >= 0 && 0 < 100) + expect(manager.matches(0, 100)).toBe(true); + // matches(1, 100) should be false (0 >= 1 is false) + expect(manager.matches(1, 100)).toBe(false); + }); + }); + + describe('breakpoint states at width 0', () => { + it('should report isMobile when width is 0', () => { + const manager = createResponsiveManager(); + + expect(manager.isMobile).toBe(true); + expect(manager.isTabletPortrait).toBe(false); + expect(manager.isTablet).toBe(false); + expect(manager.isDesktop).toBe(false); + expect(manager.isDesktopLarge).toBe(false); + }); + + it('should report correct convenience groupings', () => { + const manager = createResponsiveManager(); + + expect(manager.isMobileOrTablet).toBe(true); + expect(manager.isTabletOrDesktop).toBe(false); + }); + }); + + describe('orientation', () => { + it('should detect portrait when height > width', () => { + // Default: width=0, height=0 => not portrait (0 > 0 is false) + const manager = createResponsiveManager(); + + expect(manager.orientation).toBe('landscape'); + expect(manager.isLandscape).toBe(true); + expect(manager.isPortrait).toBe(false); + }); + }); + + describe('init', () => { + it('should return undefined in non-browser environment', () => { + const manager = createResponsiveManager(); + const cleanup = manager.init(); + + // In node test env, window is undefined so init returns early + expect(cleanup).toBeUndefined(); + }); + }); +}); diff --git a/src/shared/lib/helpers/createTypographyControl/createTypographyControl.svelte.ts b/src/shared/lib/helpers/createTypographyControl/createTypographyControl.svelte.ts index 7b1a390..10df8a7 100644 --- a/src/shared/lib/helpers/createTypographyControl/createTypographyControl.svelte.ts +++ b/src/shared/lib/helpers/createTypographyControl/createTypographyControl.svelte.ts @@ -1,46 +1,98 @@ +/** + * Numeric control with bounded values and step precision + * + * Creates a reactive control for numeric values that enforces min/max bounds + * and rounds to a specific step increment. Commonly used for typography controls + * like font size, line height, and letter spacing. + * + * @example + * ```ts + * const fontSize = createTypographyControl({ + * value: 16, + * min: 12, + * max: 72, + * step: 1 + * }); + * + * // Access current value + * fontSize.value; // 16 + * fontSize.isAtMin; // false + * + * // Modify value (automatically clamped and rounded) + * fontSize.increase(); + * fontSize.value = 100; // Will be clamped to max (72) + * ``` + */ + import { clampNumber, roundToStepPrecision, } from '$shared/lib/utils'; +/** + * Core numeric control configuration + * Defines the bounds and stepping behavior for a control + */ export interface ControlDataModel { - /** - * Control value - */ + /** Current numeric value */ value: number; - /** - * Minimal possible value - */ + /** Minimum allowed value (inclusive) */ min: number; - /** - * Maximal possible value - */ + /** Maximum allowed value (inclusive) */ max: number; - /** - * Step size for increase/decrease - */ + /** Step size for increment/decrement operations */ step: number; } +/** + * Full control model including accessibility labels + * + * @template T - Type for the control identifier + */ export interface ControlModel extends ControlDataModel { - /** - * Control identifier - */ + /** Unique identifier for the control */ id: T; - /** - * Area label for increase button - */ + /** ARIA label for the increase button */ increaseLabel?: string; - /** - * Area label for decrease button - */ + /** ARIA label for the decrease button */ decreaseLabel?: string; - /** - * Control area label - */ + /** ARIA label for the control area */ controlLabel?: string; } +/** + * Creates a reactive numeric control with bounds and stepping + * + * The control automatically: + * - Clamps values to the min/max range + * - Rounds values to the step precision + * - Tracks whether at min/max bounds + * + * @param initialState - Initial value, bounds, and step configuration + * @returns Typography control instance with reactive state and methods + * + * @example + * ```ts + * // Font size control: 12-72px in 1px increments + * const fontSize = createTypographyControl({ + * value: 16, + * min: 12, + * max: 72, + * step: 1 + * }); + * + * // Line height control: 1.0-2.0 in 0.1 increments + * const lineHeight = createTypographyControl({ + * value: 1.5, + * min: 1.0, + * max: 2.0, + * step: 0.1 + * }); + * + * // Direct assignment (auto-clamped) + * fontSize.value = 100; // Becomes 72 (max) + * ``` + */ export function createTypographyControl( initialState: T, ) { @@ -49,12 +101,17 @@ export function createTypographyControl( let min = $state(initialState.min); let step = $state(initialState.step); + // Derived state for boundary detection const { isAtMax, isAtMin } = $derived({ isAtMax: value >= max, isAtMin: value <= min, }); return { + /** + * Current control value (getter/setter) + * Setting automatically clamps to bounds and rounds to step precision + */ get value() { return value; }, @@ -64,27 +121,45 @@ export function createTypographyControl( value = rounded; } }, + + /** Maximum allowed value */ get max() { return max; }, + + /** Minimum allowed value */ get min() { return min; }, + + /** Step increment size */ get step() { return step; }, + + /** Whether the value is at or exceeds the maximum */ get isAtMax() { return isAtMax; }, + + /** Whether the value is at or below the minimum */ get isAtMin() { return isAtMin; }, + + /** + * Increase value by one step (clamped to max) + */ increase() { value = roundToStepPrecision( clampNumber(value + step, min, max), step, ); }, + + /** + * Decrease value by one step (clamped to min) + */ decrease() { value = roundToStepPrecision( clampNumber(value - step, min, max), @@ -94,4 +169,7 @@ export function createTypographyControl( }; } +/** + * Type representing a typography control instance + */ export type TypographyControl = ReturnType; diff --git a/src/shared/lib/helpers/createVirtualizer/createVirtualizer.svelte.ts b/src/shared/lib/helpers/createVirtualizer/createVirtualizer.svelte.ts index 9b8ea6a..7f5c5fa 100644 --- a/src/shared/lib/helpers/createVirtualizer/createVirtualizer.svelte.ts +++ b/src/shared/lib/helpers/createVirtualizer/createVirtualizer.svelte.ts @@ -291,7 +291,7 @@ export function createVirtualizer( }, }; } else { - containerHeight = node.offsetHeight; + containerHeight = node.clientHeight; const handleScroll = () => { scrollOffset = node.scrollTop; diff --git a/src/shared/lib/helpers/createVirtualizer/createVirtualizer.test.ts b/src/shared/lib/helpers/createVirtualizer/createVirtualizer.test.ts index c2fdaa5..d78b551 100644 --- a/src/shared/lib/helpers/createVirtualizer/createVirtualizer.test.ts +++ b/src/shared/lib/helpers/createVirtualizer/createVirtualizer.test.ts @@ -56,6 +56,11 @@ function createMockContainer(height = 500, scrollTop = 0): any { configurable: true, writable: true, }); + Object.defineProperty(container, 'clientHeight', { + value: height, + configurable: true, + writable: true, + }); Object.defineProperty(container, 'scrollTop', { value: scrollTop, writable: true, diff --git a/src/shared/lib/helpers/index.ts b/src/shared/lib/helpers/index.ts index d37eb5b..fc63b39 100644 --- a/src/shared/lib/helpers/index.ts +++ b/src/shared/lib/helpers/index.ts @@ -1,3 +1,27 @@ +/** + * Reactive helper factories using Svelte 5 runes + * + * Provides composable state management patterns for common UI needs: + * - Filter management with multi-selection + * - Typography controls with bounds and stepping + * - Virtual scrolling for large lists + * - Debounced state for search inputs + * - Entity stores with O(1) lookups + * - Character-by-character font comparison + * - Persistent localStorage-backed state + * - Responsive breakpoint tracking + * - 3D perspective animations + * + * @example + * ```ts + * import { createFilter, createVirtualizer, createTypographyControl } from '$shared/lib/helpers'; + * + * const filter = createFilter({ properties: [...] }); + * const virtualizer = createVirtualizer(() => ({ count: 1000, estimateSize: () => 50 })); + * const control = createTypographyControl({ value: 16, min: 12, max: 72, step: 1 }); + * ``` + */ + export { createFilter, type Filter,