refactor(helpers): modernize reactive helpers and add tests

This commit is contained in:
Ilia Mashkov
2026-03-02 22:18:59 +03:00
parent 594af924c7
commit ac73fd5044
12 changed files with 1117 additions and 185 deletions

View File

@@ -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
* <script lang="ts">
* import { createCharacterComparison } from '$shared/lib/helpers';
*
* const comparison = createCharacterComparison(
* () => text,
* () => fontA,
* () => fontB,
* () => weight,
* () => size
* );
*
* $: lines = comparison.lines;
* </script>
*
* <canvas bind:this={measureCanvas} hidden></canvas>
* <div bind:this={container}>
* {#each lines as line}
* <span>{line.text}</span>
* {/each}
* </div>
* ```
*/
/**
* 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<LineData[]>([]);
let containerWidth = $state(0);
/**
* Type guard to check if a font is defined
*/
function fontDefined<T extends { name: string; id: string }>(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<typeof createCharacterComparison>;

View File

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

View File

@@ -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<User>([
* { 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<T extends Entity> {
// SvelteMap is a reactive version of the native Map
/** Reactive map of entities keyed by ID */
#entities = new SvelteMap<string, T>();
/**
* 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<T>) {
const entity = this.#entities.get(id);
if (entity) {
@@ -50,32 +104,61 @@ export class EntityStore<T extends Entity> {
}
}
/**
* 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<T extends Entity>(initialEntities: T[] = []) {
return new EntityStore<T>(initialEntities);

View File

@@ -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<TValue extends string> {
/**
* 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;
}
export interface FilterModel<TValue extends string> {
/**
* Properties
/**
* Initial state configuration for a filter
*
* @template TValue - The type of property values
*/
export interface FilterModel<TValue extends string> {
/** Array of filterable properties */
properties: Property<TValue>[];
}
/**
* 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<TValue extends string>(initialState: FilterModel<TValue>) {
// 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<TValue extends string>(initialState: FilterModel<TV
const findProp = (id: string) => 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<typeof createFilter>;

View File

@@ -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<Settings>('app-settings', {
* theme: 'light',
* fontSize: 16
* });
* ```
*/
export function createPersistentStore<T>(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<T>(key: string, defaultValue: T) {
let value = $state<T>(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<T>(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<T>(key: string, defaultValue: T) {
};
}
/**
* Type representing a persistent store instance
*/
export type PersistentStore<T> = ReturnType<typeof createPersistentStore<T>>;

View File

@@ -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
* <script lang="ts">
* import { createPerspectiveManager } from '$shared/lib/helpers';
*
* const perspective = createPerspectiveManager({
* depthStep: 100,
* scaleStep: 0.5,
* blurStep: 4
* });
* </script>
*
* <div
* style="transform: scale({perspective.isBack ? 0.5 : 1});
* filter: blur({perspective.isBack ? 4 : 0}px)"
* >
* <button on:click={perspective.toggle}>Toggle View</button>
* </div>
* ```
*/
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<PerspectiveConfig>;
/**
* 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);

View File

@@ -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
* <script lang="ts">
* import { responsiveManager } from '$shared/lib/helpers';
*
* // Singleton is auto-initialized
* </script>
*
* {#if responsiveManager.isMobile}
* <MobileNav />
* {:else}
* <DesktopNav />
* {/if}
*
* <p>Viewport: {responsiveManager.width}x{responsiveManager.height}</p>
* <p>Breakpoint: {responsiveManager.currentBreakpoint}</p>
* ```
*/
/**
* 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
* <script lang="ts">
* ```ts
* // Use defaults
* const responsive = createResponsiveManager();
* </script>
*
* {#if responsive.isMobile}
* <MobileNav />
* {:else if responsive.isTablet}
* <TabletNav />
* {:else}
* <DesktopNav />
* {/if}
* // Custom breakpoints
* const custom = createResponsiveManager({
* mobile: 480,
* desktop: 1024
* });
*
* <p>Width: {responsive.width}px</p>
* <p>Orientation: {responsive.orientation}</p>
* // In component
* $: isMobile = responsive.isMobile;
* $: cols = responsive.isDesktop ? 3 : 1;
* ```
*/
export function createResponsiveManager(customBreakpoints?: Partial<Breakpoints>) {
@@ -69,7 +99,7 @@ export function createResponsiveManager(customBreakpoints?: Partial<Breakpoints>
...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<Breakpoints>
const isMobileOrTablet = $derived(width < breakpoints.desktop);
const isTabletOrDesktop = $derived(width >= breakpoints.tabletPortrait);
// Orientation
// Orientation detection
const orientation = $derived<Orientation>(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<Breakpoints>
/**
* 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<Breakpoints>
}
/**
* 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<Breakpoints>
}
/**
* Get the current breakpoint name
* Current breakpoint name based on viewport width
*/
const currentBreakpoint = $derived<keyof Breakpoints | 'xs'>(
(() => {
@@ -158,16 +200,17 @@ export function createResponsiveManager(customBreakpoints?: Partial<Breakpoints>
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<Breakpoints>
};
}
/**
* 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') {

View File

@@ -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();
});
});
});

View File

@@ -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<T extends string = string> 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<T extends ControlDataModel>(
initialState: T,
) {
@@ -49,12 +101,17 @@ export function createTypographyControl<T extends ControlDataModel>(
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<T extends ControlDataModel>(
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<T extends ControlDataModel>(
};
}
/**
* Type representing a typography control instance
*/
export type TypographyControl = ReturnType<typeof createTypographyControl>;

View File

@@ -291,7 +291,7 @@ export function createVirtualizer<T>(
},
};
} else {
containerHeight = node.offsetHeight;
containerHeight = node.clientHeight;
const handleScroll = () => {
scrollOffset = node.scrollTop;

View File

@@ -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,

View File

@@ -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,