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
+ *
+ *
+ *
+ * Toggle View
+ *
+ * ```
+ */
+
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,