diff --git a/.gitea/workflows/workflow.yml b/.gitea/workflows/workflow.yml index a9b8fb8..442810f 100644 --- a/.gitea/workflows/workflow.yml +++ b/.gitea/workflows/workflow.yml @@ -41,7 +41,14 @@ jobs: run: yarn lint - name: Type Check - run: yarn check:shadcn-excluded + run: yarn check + + - name: Run Unit Tests + run: yarn test:unit + + - name: Run Component Tests + timeout-minutes: 5 + run: yarn test:component --reporter=verbose --logHeapUsage publish: needs: build # Only runs if tests/lint pass diff --git a/.storybook/Decorator.svelte b/.storybook/Decorator.svelte index e2abefd..38af10e 100644 --- a/.storybook/Decorator.svelte +++ b/.storybook/Decorator.svelte @@ -4,12 +4,11 @@ This provides: - ResponsiveManager context for breakpoint tracking - - TooltipProvider for shadcn Tooltip components + - TooltipProvider for tooltip components --> - - {@render children()} - +{@render children()} diff --git a/.storybook/StoryStage.svelte b/.storybook/StoryStage.svelte index 5a9a9c7..5a8bb31 100644 --- a/.storybook/StoryStage.svelte +++ b/.storybook/StoryStage.svelte @@ -1,14 +1,18 @@
-
+
{@render children()}
diff --git a/.storybook/preview.ts b/.storybook/preview.ts index 4d8f256..9ae1335 100644 --- a/.storybook/preview.ts +++ b/.storybook/preview.ts @@ -5,68 +5,6 @@ import ThemeDecorator from './ThemeDecorator.svelte'; import '../src/app/styles/app.css'; const preview: Preview = { - globalTypes: { - viewport: { - description: 'Viewport size for responsive design', - defaultValue: 'widgetWide', - toolbar: { - icon: 'view', - items: [ - { - value: 'reset', - icon: 'refresh', - title: 'Reset viewport', - }, - { - value: 'mobile1', - icon: 'mobile', - title: 'iPhone 5/SE', - }, - { - value: 'mobile2', - icon: 'mobile', - title: 'iPhone 14 Pro Max', - }, - { - value: 'tablet', - icon: 'tablet', - title: 'iPad (Portrait)', - }, - { - value: 'desktop', - icon: 'desktop', - title: 'Desktop (Small)', - }, - { - value: 'widgetMedium', - icon: 'view', - title: 'Widget Medium', - }, - { - value: 'widgetWide', - icon: 'view', - title: 'Widget Wide', - }, - { - value: 'widgetExtraWide', - icon: 'view', - title: 'Widget Extra Wide', - }, - { - value: 'fullWidth', - icon: 'view', - title: 'Full Width', - }, - { - value: 'fullScreen', - icon: 'expand', - title: 'Full Screen', - }, - ], - dynamicTitle: true, - }, - }, - }, parameters: { layout: 'padded', controls: { @@ -195,10 +133,11 @@ const preview: Preview = { }, }), // Wrap with StoryStage for presentation styling - story => ({ + (story, context) => ({ Component: StoryStage, props: { children: story(), + maxWidth: context.parameters.storyStage?.maxWidth, }, }), ], diff --git a/README.md b/README.md index 7673a60..f0f01d0 100644 --- a/README.md +++ b/README.md @@ -8,14 +8,14 @@ A modern font exploration and comparison tool for browsing fonts from Google Fon - **Side-by-Side Comparison**: Compare up to 4 fonts simultaneously with customizable text, size, and typography settings - **Advanced Filtering**: Filter by category, provider, character subsets, and weight - **Virtual Scrolling**: Fast, smooth browsing of thousands of fonts -- **Responsive UI**: Beautiful interface built with shadcn components and Tailwind CSS +- **Responsive UI**: Beautiful interface built with Tailwind CSS - **Type-Safe**: Full TypeScript coverage with strict mode enabled ## Tech Stack - **Framework**: Svelte 5 with reactive primitives (runes) - **Styling**: Tailwind CSS v4 -- **Components**: shadcn-svelte (via bits-ui) +- **Components**: Bits UI primitives - **State Management**: TanStack Query for async data - **Architecture**: Feature-Sliced Design (FSD) - **Quality**: oxlint (linting), dprint (formatting), lefthook (git hooks) diff --git a/components.json b/components.json deleted file mode 100644 index ffab50d..0000000 --- a/components.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "$schema": "https://shadcn-svelte.com/schema.json", - "tailwind": { - "css": "src/app.css", - "baseColor": "zinc" - }, - "aliases": { - "components": "$shared/shadcn/ui", - "utils": "$shared/shadcn/utils/shadcn-utils", - "ui": "$shared/shadcn/ui", - "hooks": "$shared/shadcn/hooks", - "lib": "$shared" - }, - "typescript": true, - "registry": "https://shadcn-svelte.com/registry" -} diff --git a/dprint.json b/dprint.json index 776bdf3..c90d305 100644 --- a/dprint.json +++ b/dprint.json @@ -31,7 +31,17 @@ "importDeclaration.forceMultiLine": "whenMultiple", "importDeclaration.forceSingleLine": false, "exportDeclaration.forceMultiLine": "whenMultiple", - "exportDeclaration.forceSingleLine": false + "exportDeclaration.forceSingleLine": false, + "ifStatement.useBraces": "always", + "ifStatement.singleBodyPosition": "nextLine", + "whileStatement.useBraces": "always", + "whileStatement.singleBodyPosition": "nextLine", + "forStatement.useBraces": "always", + "forStatement.singleBodyPosition": "nextLine", + "forInStatement.useBraces": "always", + "forInStatement.singleBodyPosition": "nextLine", + "forOfStatement.useBraces": "always", + "forOfStatement.singleBodyPosition": "nextLine" }, "json": { "indentWidth": 2, diff --git a/lefthook.yml b/lefthook.yml index d0cf91e..db427ed 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -13,11 +13,15 @@ pre-commit: pre-push: parallel: true commands: + test-unit: + run: yarn test:unit + test-component: + run: yarn test:component type-check: run: yarn tsc --noEmit svelte-check: - run: yarn check:shadcn-excluded --threshold warning + run: yarn check --threshold warning format-check: glob: "*.{ts,js,svelte,json,md}" diff --git a/package.json b/package.json index 113ce24..673d68d 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,6 @@ "prepare": "svelte-check --tsconfig ./tsconfig.json || echo ''", "check": "svelte-check", "check:watch": "svelte-check --tsconfig ./tsconfig.json --watch", - "check:shadcn-excluded": "svelte-check --no-tsconfig --ignore \"src/shared/shadcn\"", "lint": "oxlint", "format": "dprint fmt", "format:check": "dprint check", diff --git a/src/app/styles/app.css b/src/app/styles/app.css index 60e21a4..b00d02a 100644 --- a/src/app/styles/app.css +++ b/src/app/styles/app.css @@ -7,7 +7,7 @@ /* Base font size */ --font-size: 16px; - /* GLYPHDIFF Swiss Design System */ + /* GLYPHDIFF Design System */ /* Primary Colors */ --swiss-beige: #f3f0e9; --swiss-red: #ff3b30; @@ -91,7 +91,6 @@ --space-4xl: 4rem; /* Typography Scale */ - --text-2xs: 0.625rem; --text-xs: 0.75rem; --text-sm: 0.875rem; --text-base: 1rem; @@ -205,6 +204,14 @@ --font-mono: 'Space Mono', monospace; --font-primary: 'Space Grotesk', system-ui, -apple-system, 'Segoe UI', Inter, Roboto, Arial, sans-serif; --font-secondary: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, Arial, sans-serif; + + /* Micro typography scale — extends Tailwind's text-xs (0.75rem) downward */ + --text-5xs: 0.4375rem; + --text-4xs: 0.5rem; + --text-3xs: 0.5625rem; + --text-2xs: 0.625rem; + /* Monospace label tracking — used in Loader and Footnote */ + --tracking-wider-mono: 0.2em; } @layer base { @@ -212,6 +219,11 @@ @apply border-border outline-ring/50; } + ::selection { + background-color: var(--color-brand); + color: var(--swiss-white); + } + body { @apply bg-background text-foreground; font-family: "Karla", system-ui, -apple-system, "Segoe UI", Inter, Roboto, Arial, sans-serif; @@ -265,6 +277,21 @@ } } +@layer utilities { + /* 21× border-black/5 dark:border-white/10 → single token */ + .border-subtle { + @apply border-black/5 dark:border-white/10; + } + /* Secondary text pair */ + .text-secondary { + @apply text-neutral-500 dark:text-neutral-400; + } + /* Standard focus ring */ + .focus-ring { + @apply focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2; + } +} + /* Global utility - useful across your app */ @media (prefers-reduced-motion: reduce) { * { diff --git a/src/app/ui/Layout.svelte b/src/app/ui/Layout.svelte index c430a3f..651482f 100644 --- a/src/app/ui/Layout.svelte +++ b/src/app/ui/Layout.svelte @@ -3,23 +3,12 @@ Application shell with providers and page wrapper --> - + themeManager.destroy()); /> GlyphDiff | Typography & Typefaces +
-
- -
+ {#if fontsReady} + {@render children?.()} + {/if} - - - - {#if fontsReady} - {@render children?.()} - {/if} - - - -
+
diff --git a/src/entities/Breadcrumb/model/store/scrollBreadcrumbsStore.svelte.ts b/src/entities/Breadcrumb/model/store/scrollBreadcrumbsStore.svelte.ts index d67e6d0..b72e3bd 100644 --- a/src/entities/Breadcrumb/model/store/scrollBreadcrumbsStore.svelte.ts +++ b/src/entities/Breadcrumb/model/store/scrollBreadcrumbsStore.svelte.ts @@ -34,11 +34,17 @@ * A breadcrumb item representing a tracked section */ export interface BreadcrumbItem { - /** Unique index for ordering */ + /** + * Unique index for ordering + */ index: number; - /** Display title for the breadcrumb */ + /** + * Display title for the breadcrumb + */ title: string; - /** DOM element to track */ + /** + * DOM element to track + */ element: HTMLElement; } @@ -50,21 +56,37 @@ export interface BreadcrumbItem { * past while moving down the page. */ class ScrollBreadcrumbsStore { - /** All tracked breadcrumb items */ + /** + * All tracked breadcrumb items + */ #items = $state([]); - /** Set of indices that have scrolled past (exited viewport while scrolling down) */ + /** + * Set of indices that have scrolled past (exited viewport while scrolling down) + */ #scrolledPast = $state>(new Set()); - /** Intersection Observer instance */ + /** + * Intersection Observer instance + */ #observer: IntersectionObserver | null = null; - /** Offset for smooth scrolling (sticky header height) */ + /** + * Offset for smooth scrolling (sticky header height) + */ #scrollOffset = 0; - /** Current scroll direction */ + /** + * Current scroll direction + */ #isScrollingDown = $state(false); - /** Previous scroll Y position to determine direction */ + /** + * Previous scroll Y position to determine direction + */ #prevScrollY = 0; - /** Throttled scroll handler */ + /** + * Throttled scroll handler + */ #handleScroll: (() => void) | null = null; - /** Listener count for cleanup */ + /** + * Listener count for cleanup + */ #listenerCount = 0; /** @@ -83,13 +105,17 @@ class ScrollBreadcrumbsStore { * (fires as soon as any part of element crosses viewport edge). */ #initObserver(): void { - if (this.#observer) return; + if (this.#observer) { + return; + } this.#observer = new IntersectionObserver( entries => { for (const entry of entries) { const item = this.#items.find(i => i.element === entry.target); - if (!item) continue; + if (!item) { + continue; + } if (!entry.isIntersecting && this.#isScrollingDown) { // Element exited viewport while scrolling DOWN - add to breadcrumbs @@ -141,17 +167,23 @@ class ScrollBreadcrumbsStore { this.#detachScrollListener(); } - /** All tracked items sorted by index */ + /** + * All tracked items sorted by index + */ get items(): BreadcrumbItem[] { return this.#items.slice().sort((a, b) => a.index - b.index); } - /** Items that have scrolled past viewport top (visible in breadcrumbs) */ + /** + * Items that have scrolled past viewport top (visible in breadcrumbs) + */ get scrolledPastItems(): BreadcrumbItem[] { return this.items.filter(item => this.#scrolledPast.has(item.index)); } - /** Index of the most recently scrolled item (active section) */ + /** + * Index of the most recently scrolled item (active section) + */ get activeIndex(): number | null { const past = this.scrolledPastItems; return past.length > 0 ? past[past.length - 1].index : null; @@ -171,7 +203,9 @@ class ScrollBreadcrumbsStore { * @param offset - Scroll offset in pixels (for sticky headers) */ add(item: BreadcrumbItem, offset = 0): void { - if (this.#items.find(i => i.index === item.index)) return; + if (this.#items.find(i => i.index === item.index)) { + return; + } this.#scrollOffset = offset; this.#items.push(item); @@ -188,7 +222,9 @@ class ScrollBreadcrumbsStore { */ remove(index: number): void { const item = this.#items.find(i => i.index === index); - if (!item) return; + if (!item) { + return; + } this.#observer?.unobserve(item.element); this.#items = this.#items.filter(i => i.index !== index); @@ -209,7 +245,9 @@ class ScrollBreadcrumbsStore { */ scrollTo(index: number, container: HTMLElement | Window = window): void { const item = this.#items.find(i => i.index === index); - if (!item) return; + if (!item) { + return; + } const rect = item.element.getBoundingClientRect(); const scrollTop = container === window ? window.scrollY : (container as HTMLElement).scrollTop; diff --git a/src/entities/Breadcrumb/model/store/scrollBreadcrumbsStore.test.ts b/src/entities/Breadcrumb/model/store/scrollBreadcrumbsStore.test.ts index 7f6744b..bdf0cb5 100644 --- a/src/entities/Breadcrumb/model/store/scrollBreadcrumbsStore.test.ts +++ b/src/entities/Breadcrumb/model/store/scrollBreadcrumbsStore.test.ts @@ -1,4 +1,6 @@ -/** @vitest-environment jsdom */ +/** + * @vitest-environment jsdom + */ import { afterEach, beforeEach, @@ -24,7 +26,9 @@ class MockIntersectionObserver implements IntersectionObserver { constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) { this.callbacks.push(callback); - if (options?.rootMargin) this.rootMargin = options.rootMargin; + if (options?.rootMargin) { + this.rootMargin = options.rootMargin; + } if (options?.threshold) { this.thresholds = Array.isArray(options.threshold) ? options.threshold : [options.threshold]; } @@ -118,7 +122,9 @@ describe('ScrollBreadcrumbsStore', () => { (event: string, listener: EventListenerOrEventListenerObject) => { if (event === 'scroll') { const index = scrollListeners.indexOf(listener as () => void); - if (index > -1) scrollListeners.splice(index, 1); + if (index > -1) { + scrollListeners.splice(index, 1); + } } return undefined; }, diff --git a/src/entities/Breadcrumb/ui/BreadcrumbHeader/BreadcrumbHeader.stories.svelte b/src/entities/Breadcrumb/ui/BreadcrumbHeader/BreadcrumbHeader.stories.svelte new file mode 100644 index 0000000..bdec72a --- /dev/null +++ b/src/entities/Breadcrumb/ui/BreadcrumbHeader/BreadcrumbHeader.stories.svelte @@ -0,0 +1,65 @@ + + + + + + {#snippet template()} + + + + {/snippet} + + + + {#snippet template()} + +
+ BreadcrumbHeader renders nothing when scrolledPastItems is empty. +
+ +
+ {/snippet} +
diff --git a/src/entities/Breadcrumb/ui/BreadcrumbHeader/BreadcrumbHeader.svelte b/src/entities/Breadcrumb/ui/BreadcrumbHeader/BreadcrumbHeader.svelte index 6eed8c5..f419995 100644 --- a/src/entities/Breadcrumb/ui/BreadcrumbHeader/BreadcrumbHeader.svelte +++ b/src/entities/Breadcrumb/ui/BreadcrumbHeader/BreadcrumbHeader.svelte @@ -44,7 +44,7 @@ function createButtonText(item: BreadcrumbItem) { flex items-center justify-between z-40 bg-surface/90 dark:bg-dark-bg/90 backdrop-blur-md - border-b border-black/5 dark:border-white/10 + border-b border-subtle " >
diff --git a/src/entities/Breadcrumb/ui/BreadcrumbHeader/BreadcrumbHeader.svelte.test.ts b/src/entities/Breadcrumb/ui/BreadcrumbHeader/BreadcrumbHeader.svelte.test.ts new file mode 100644 index 0000000..4642337 --- /dev/null +++ b/src/entities/Breadcrumb/ui/BreadcrumbHeader/BreadcrumbHeader.svelte.test.ts @@ -0,0 +1,11 @@ +import { render } from '@testing-library/svelte'; +import BreadcrumbHeader from './BreadcrumbHeader.svelte'; + +const context = new Map([['responsive', { isMobile: false, isMobileOrTablet: false }]]); + +describe('BreadcrumbHeader', () => { + it('renders nothing when no sections have been scrolled past', () => { + const { container } = render(BreadcrumbHeader, { context }); + expect(container.firstElementChild).toBeNull(); + }); +}); diff --git a/src/entities/Breadcrumb/ui/BreadcrumbHeader/BreadcrumbHeaderSeeded.svelte b/src/entities/Breadcrumb/ui/BreadcrumbHeader/BreadcrumbHeaderSeeded.svelte new file mode 100644 index 0000000..792d86c --- /dev/null +++ b/src/entities/Breadcrumb/ui/BreadcrumbHeader/BreadcrumbHeaderSeeded.svelte @@ -0,0 +1,49 @@ + + +
+ {#each sections as section} +
+ {section.title} — scroll up to see the breadcrumb header +
+ {/each} +
+ + diff --git a/src/entities/Breadcrumb/ui/NavigationWrapper/NavigationWrapper.stories.svelte b/src/entities/Breadcrumb/ui/NavigationWrapper/NavigationWrapper.stories.svelte new file mode 100644 index 0000000..d3adb47 --- /dev/null +++ b/src/entities/Breadcrumb/ui/NavigationWrapper/NavigationWrapper.stories.svelte @@ -0,0 +1,109 @@ + + + + + + {#snippet template(args: ComponentProps)} + + {#snippet content(register)} +
+

+ Section registered as {args.title} at index {args.index}. Scroll past this + section to see it appear in the breadcrumb header. +

+
+ {/snippet} +
+ {/snippet} +
+ + + {#snippet template()} +
+ + {#snippet content(register)} +
+

Introduction

+

+ Registered as section 0. Scroll down to build the breadcrumb trail. +

+
+ {/snippet} +
+ + + {#snippet content(register)} +
+

Typography

+

Registered as section 1.

+
+ {/snippet} +
+ + + {#snippet content(register)} +
+

Spacing

+

Registered as section 2.

+
+ {/snippet} +
+
+ {/snippet} +
diff --git a/src/entities/Font/api/proxy/proxyFonts.ts b/src/entities/Font/api/proxy/proxyFonts.ts index f7699d5..3fdccbe 100644 --- a/src/entities/Font/api/proxy/proxyFonts.ts +++ b/src/entities/Font/api/proxy/proxyFonts.ts @@ -97,16 +97,24 @@ export interface ProxyFontsParams extends QueryParams { * Includes pagination metadata alongside font data */ export interface ProxyFontsResponse { - /** Array of unified font objects */ + /** + * List of font objects returned by the proxy + */ fonts: UnifiedFont[]; - /** Total number of fonts matching the query */ + /** + * Total number of matching fonts (ignoring limit/offset) + */ total: number; - /** Limit used for this request */ + /** + * Page size used for the request + */ limit: number; - /** Offset used for this request */ + /** + * Start index for the result set + */ offset: number; } @@ -189,7 +197,9 @@ export async function fetchProxyFontById( * @returns Promise resolving to an array of fonts */ export async function fetchFontsByIds(ids: string[]): Promise { - if (ids.length === 0) return []; + if (ids.length === 0) { + return []; + } const queryString = ids.join(','); const url = `${PROXY_API_URL}/batch?ids=${queryString}`; diff --git a/src/entities/Font/lib/getFontUrl/getFontUrl.ts b/src/entities/Font/lib/getFontUrl/getFontUrl.ts index 13f697e..da4433d 100644 --- a/src/entities/Font/lib/getFontUrl/getFontUrl.ts +++ b/src/entities/Font/lib/getFontUrl/getFontUrl.ts @@ -3,7 +3,9 @@ import type { UnifiedFont, } from '../../model'; -/** Valid font weight values (100-900 in increments of 100) */ +/** + * Valid font weight values (100-900 in increments of 100) + */ const SIZES = [100, 200, 300, 400, 500, 600, 700, 800, 900]; /** diff --git a/src/entities/Font/lib/mocks/filters.mock.ts b/src/entities/Font/lib/mocks/filters.mock.ts index 1b32531..d4e6432 100644 --- a/src/entities/Font/lib/mocks/filters.mock.ts +++ b/src/entities/Font/lib/mocks/filters.mock.ts @@ -1,31 +1,3 @@ -/** - * Mock font filter data - * - * Factory functions and preset mock data for font-related filters. - * Used in Storybook stories for font filtering components. - * - * ## Usage - * - * ```ts - * import { - * createMockFilter, - * MOCK_FILTERS, - * } from '$entities/Font/lib/mocks'; - * - * // Create a custom filter - * const customFilter = createMockFilter({ - * properties: [ - * { id: 'option1', name: 'Option 1', value: 'option1' }, - * { id: 'option2', name: 'Option 2', value: 'option2', selected: true }, - * ], - * }); - * - * // Use preset filters - * const categoriesFilter = MOCK_FILTERS.categories; - * const subsetsFilter = MOCK_FILTERS.subsets; - * ``` - */ - import type { FontCategory, FontProvider, @@ -34,13 +6,13 @@ import type { import type { Property } from '$shared/lib'; import { createFilter } from '$shared/lib'; -// TYPE DEFINITIONS - /** * Options for creating a mock filter */ export interface MockFilterOptions { - /** Filter properties */ + /** + * Initial set of properties for the mock filter + */ properties: Property[]; } @@ -48,16 +20,20 @@ export interface MockFilterOptions { * Preset mock filters for font filtering */ export interface MockFilters { - /** Provider filter (Google, Fontshare) */ + /** + * Provider filter (Google, Fontshare) + */ providers: ReturnType>; - /** Category filter (sans-serif, serif, display, etc.) */ + /** + * Category filter (sans-serif, serif, display, etc.) + */ categories: ReturnType>; - /** Subset filter (latin, latin-ext, cyrillic, etc.) */ + /** + * Subset filter (latin, latin-ext, cyrillic, etc.) + */ subsets: ReturnType>; } -// FONT CATEGORIES - /** * Unified categories (combines both providers) */ @@ -71,8 +47,6 @@ export const UNIFIED_CATEGORIES: Property[] = [ { id: 'script', name: 'Script', value: 'script' }, ]; -// FONT SUBSETS - /** * Common font subsets */ @@ -85,8 +59,6 @@ export const FONT_SUBSETS: Property[] = [ { id: 'devanagari', name: 'Devanagari', value: 'devanagari' }, ]; -// FONT PROVIDERS - /** * Font providers */ @@ -95,8 +67,6 @@ export const FONT_PROVIDERS: Property[] = [ { id: 'fontshare', name: 'Fontshare', value: 'fontshare' }, ]; -// FILTER FACTORIES - /** * Create a mock filter from properties */ @@ -139,8 +109,6 @@ export function createProvidersFilter(options?: { selected?: FontProvider[] }) { return createFilter({ properties }); } -// PRESET FILTERS - /** * Preset mock filters - use these directly in stories */ @@ -216,8 +184,6 @@ export const MOCK_FILTERS_ALL_SELECTED: MockFilters = { }), }; -// GENERIC FILTER MOCKS - /** * Create a mock filter with generic string properties * Useful for testing generic filter components @@ -239,7 +205,9 @@ export function createGenericFilter( * Preset generic filters for testing */ export const GENERIC_FILTERS = { - /** Small filter with 3 items */ + /** + * Small filter with 3 items + */ small: createFilter({ properties: [ { id: 'option-1', name: 'Option 1', value: 'option-1' }, @@ -247,7 +215,9 @@ export const GENERIC_FILTERS = { { id: 'option-3', name: 'Option 3', value: 'option-3' }, ], }), - /** Medium filter with 6 items */ + /** + * Medium filter with 6 items + */ medium: createFilter({ properties: [ { id: 'alpha', name: 'Alpha', value: 'alpha' }, @@ -258,7 +228,9 @@ export const GENERIC_FILTERS = { { id: 'zeta', name: 'Zeta', value: 'zeta' }, ], }), - /** Large filter with 12 items */ + /** + * Large filter with 12 items + */ large: createFilter({ properties: [ { id: 'jan', name: 'January', value: 'jan' }, @@ -275,7 +247,9 @@ export const GENERIC_FILTERS = { { id: 'dec', name: 'December', value: 'dec' }, ], }), - /** Filter with some pre-selected items */ + /** + * Filter with some pre-selected items + */ partial: createFilter({ properties: [ { id: 'red', name: 'Red', value: 'red', selected: true }, @@ -284,7 +258,9 @@ export const GENERIC_FILTERS = { { id: 'yellow', name: 'Yellow', value: 'yellow', selected: false }, ], }), - /** Filter with all items selected */ + /** + * Filter with all items selected + */ allSelected: createFilter({ properties: [ { id: 'cat', name: 'Cat', value: 'cat', selected: true }, @@ -292,7 +268,9 @@ export const GENERIC_FILTERS = { { id: 'bird', name: 'Bird', value: 'bird', selected: true }, ], }), - /** Empty filter (no items) */ + /** + * Empty filter (no items) + */ empty: createFilter({ properties: [], }), diff --git a/src/entities/Font/lib/mocks/fonts.mock.ts b/src/entities/Font/lib/mocks/fonts.mock.ts index 309701a..a691dc7 100644 --- a/src/entities/Font/lib/mocks/fonts.mock.ts +++ b/src/entities/Font/lib/mocks/fonts.mock.ts @@ -51,23 +51,41 @@ import type { * Options for creating a mock UnifiedFont */ export interface MockUnifiedFontOptions { - /** Unique identifier (default: derived from name) */ + /** + * Unique identifier (default: derived from name) + */ id?: string; - /** Font display name (default: 'Mock Font') */ + /** + * Font display name (default: 'Mock Font') + */ name?: string; - /** Font provider (default: 'google') */ + /** + * Font provider (default: 'google') + */ provider?: FontProvider; - /** Font category (default: 'sans-serif') */ + /** + * Font category (default: 'sans-serif') + */ category?: FontCategory; - /** Font subsets (default: ['latin']) */ + /** + * Font subsets (default: ['latin']) + */ subsets?: FontSubset[]; - /** Font variants (default: ['regular', '700', 'italic', '700italic']) */ + /** + * Font variants (default: ['regular', '700', 'italic', '700italic']) + */ variants?: FontVariant[]; - /** Style URLs (if not provided, mock URLs are generated) */ + /** + * Style URLs (if not provided, mock URLs are generated) + */ styles?: FontStyleUrls; - /** Metadata overrides */ + /** + * Metadata overrides + */ metadata?: Partial; - /** Features overrides */ + /** + * Features overrides + */ features?: Partial; } diff --git a/src/entities/Font/lib/mocks/stores.mock.ts b/src/entities/Font/lib/mocks/stores.mock.ts index 53f3dcb..fd54060 100644 --- a/src/entities/Font/lib/mocks/stores.mock.ts +++ b/src/entities/Font/lib/mocks/stores.mock.ts @@ -1,8 +1,4 @@ /** - * ============================================================================ - * MOCK FONT STORE HELPERS - * ============================================================================ - * * Factory functions and preset mock data for TanStack Query stores and state management. * Used in Storybook stories for components that use reactive stores. * @@ -35,27 +31,73 @@ import { generateMockFonts, } from './fonts.mock'; -// TANSTACK QUERY MOCK TYPES - /** * Mock TanStack Query state */ export interface MockQueryState { + /** + * Primary query status (pending, success, error) + */ status: QueryStatus; + /** + * Payload data (present on success) + */ data?: TData; + /** + * Caught error object (present on error) + */ error?: TError; + /** + * True if initial load is in progress + */ isLoading?: boolean; + /** + * True if background fetch is in progress + */ isFetching?: boolean; + /** + * True if query resolved successfully + */ isSuccess?: boolean; + /** + * True if query failed + */ isError?: boolean; + /** + * True if query is waiting to be executed + */ isPending?: boolean; + /** + * Timestamp of last successful data retrieval + */ dataUpdatedAt?: number; + /** + * Timestamp of last recorded error + */ errorUpdatedAt?: number; + /** + * Total number of consecutive failures + */ failureCount?: number; + /** + * Detailed reason for the last failure + */ failureReason?: TError; + /** + * Number of times an error has been caught + */ errorUpdateCount?: number; + /** + * True if currently refetching in background + */ isRefetching?: boolean; + /** + * True if refetch attempt failed + */ isRefetchError?: boolean; + /** + * True if query is paused (e.g. offline) + */ isPaused?: boolean; } @@ -63,26 +105,72 @@ export interface MockQueryState { * Mock TanStack Query observer result */ export interface MockQueryObserverResult { + /** + * Current observer status + */ status?: QueryStatus; + /** + * Cached or active data payload + */ data?: TData; + /** + * Caught error from the observer + */ error?: TError; + /** + * Loading flag for the observer + */ isLoading?: boolean; + /** + * Fetching flag for the observer + */ isFetching?: boolean; + /** + * Success flag for the observer + */ isSuccess?: boolean; + /** + * Error flag for the observer + */ isError?: boolean; + /** + * Pending flag for the observer + */ isPending?: boolean; + /** + * Last update time for data + */ dataUpdatedAt?: number; + /** + * Last update time for error + */ errorUpdatedAt?: number; + /** + * Consecutive failure count + */ failureCount?: number; + /** + * Failure reason object + */ failureReason?: TError; + /** + * Error count for the observer + */ errorUpdateCount?: number; + /** + * Refetching flag + */ isRefetching?: boolean; + /** + * Refetch error flag + */ isRefetchError?: boolean; + /** + * Paused flag + */ isPaused?: boolean; } -// TANSTACK QUERY MOCK FACTORIES - /** * Create a mock query state for TanStack Query */ @@ -138,33 +226,53 @@ export function createSuccessState(data: TData): MockQueryObserverResult< return createMockQueryState({ status: 'success', data, error: undefined }); } -// FONT STORE MOCKS - /** * Mock UnifiedFontStore state */ export interface MockFontStoreState { - /** All cached fonts */ + /** + * Map of mock fonts indexed by ID + */ fonts: Record; - /** Current page */ + /** + * Currently active page number + */ page: number; - /** Total pages available */ + /** + * Total number of pages calculated from limit + */ totalPages: number; - /** Items per page */ + /** + * Number of items per page + */ limit: number; - /** Total font count */ + /** + * Total number of available fonts + */ total: number; - /** Loading state */ + /** + * Store-level loading status + */ isLoading: boolean; - /** Error state */ + /** + * Caught error object + */ error: Error | null; - /** Search query */ + /** + * Mock search filter string + */ searchQuery: string; - /** Selected provider */ + /** + * Mock provider filter selection + */ provider: 'google' | 'fontshare' | 'all'; - /** Selected category */ + /** + * Mock category filter selection + */ category: string | null; - /** Selected subset */ + /** + * Mock subset filter selection + */ subset: string | null; } @@ -210,10 +318,12 @@ export function createMockFontStoreState( } /** - * Preset font store states + * Preset font store states for UI testing */ export const MOCK_FONT_STORE_STATES = { - /** Initial loading state */ + /** + * Initial loading state with no data + */ loading: createMockFontStoreState({ isLoading: true, fonts: {}, @@ -221,7 +331,9 @@ export const MOCK_FONT_STORE_STATES = { page: 1, }), - /** Empty state (no fonts found) */ + /** + * State with no fonts matching filters + */ empty: createMockFontStoreState({ fonts: {}, total: 0, @@ -229,7 +341,9 @@ export const MOCK_FONT_STORE_STATES = { isLoading: false, }), - /** First page with fonts */ + /** + * First page of results (10 items) + */ firstPage: createMockFontStoreState({ fonts: Object.fromEntries( Object.values(UNIFIED_FONTS).slice(0, 10).map(font => [font.id, font]), @@ -241,7 +355,9 @@ export const MOCK_FONT_STORE_STATES = { isLoading: false, }), - /** Second page with fonts */ + /** + * Second page of results (10 items) + */ secondPage: createMockFontStoreState({ fonts: Object.fromEntries( Object.values(UNIFIED_FONTS).slice(10, 20).map(font => [font.id, font]), @@ -253,7 +369,9 @@ export const MOCK_FONT_STORE_STATES = { isLoading: false, }), - /** Last page with fonts */ + /** + * Final page of results (5 items) + */ lastPage: createMockFontStoreState({ fonts: Object.fromEntries( Object.values(UNIFIED_FONTS).slice(0, 5).map(font => [font.id, font]), @@ -265,7 +383,9 @@ export const MOCK_FONT_STORE_STATES = { isLoading: false, }), - /** Error state */ + /** + * Terminal failure state + */ error: createMockFontStoreState({ fonts: {}, error: new Error('Failed to load fonts'), @@ -274,7 +394,9 @@ export const MOCK_FONT_STORE_STATES = { isLoading: false, }), - /** With search query */ + /** + * State with active search query + */ withSearch: createMockFontStoreState({ fonts: Object.fromEntries( Object.values(UNIFIED_FONTS).slice(0, 3).map(font => [font.id, font]), @@ -285,7 +407,9 @@ export const MOCK_FONT_STORE_STATES = { searchQuery: 'Roboto', }), - /** Filtered by category */ + /** + * State with active category filter + */ filteredByCategory: createMockFontStoreState({ fonts: Object.fromEntries( Object.values(UNIFIED_FONTS) @@ -299,7 +423,9 @@ export const MOCK_FONT_STORE_STATES = { category: 'serif', }), - /** Filtered by provider */ + /** + * State with active provider filter + */ filteredByProvider: createMockFontStoreState({ fonts: Object.fromEntries( Object.values(UNIFIED_FONTS) @@ -313,7 +439,9 @@ export const MOCK_FONT_STORE_STATES = { provider: 'google', }), - /** Large dataset */ + /** + * Large collection for performance testing (50 items) + */ largeDataset: createMockFontStoreState({ fonts: Object.fromEntries( generateMockFonts(50).map(font => [font.id, font]), @@ -326,17 +454,30 @@ export const MOCK_FONT_STORE_STATES = { }), }; -// MOCK STORE OBJECT - /** * Create a mock store object that mimics TanStack Query behavior * Useful for components that subscribe to store properties */ export function createMockStore(config: { + /** + * Reactive data payload + */ data?: T; + /** + * Loading status flag + */ isLoading?: boolean; + /** + * Error status flag + */ isError?: boolean; + /** + * Catch-all error object + */ error?: Error; + /** + * Background fetching flag + */ isFetching?: boolean; }) { const { @@ -348,50 +489,81 @@ export function createMockStore(config: { } = config; return { + /** + * Returns the active data payload + */ get data() { return data; }, + /** + * True if initially loading + */ get isLoading() { return isLoading; }, + /** + * True if last request failed + */ get isError() { return isError; }, + /** + * Returns the caught error object + */ get error() { return error; }, + /** + * True if fetching in background + */ get isFetching() { return isFetching; }, + /** + * True if query is stable and has data + */ get isSuccess() { return !isLoading && !isError && data !== undefined; }, + /** + * Returns semantic status string + */ get status() { - if (isLoading) return 'pending'; - if (isError) return 'error'; + if (isLoading) { + return 'pending'; + } + if (isError) { + return 'error'; + } return 'success'; }, }; } /** - * Preset mock stores + * Preset mock stores for common UI states */ export const MOCK_STORES = { - /** Font store in loading state */ + /** + * Initial loading state + */ loadingFontStore: createMockStore({ isLoading: true, data: undefined, }), - /** Font store with fonts loaded */ + /** + * Successful data load state + */ successFontStore: createMockStore({ data: Object.values(UNIFIED_FONTS), isLoading: false, isError: false, }), - /** Font store with error */ + /** + * API error state + */ errorFontStore: createMockStore({ data: undefined, isLoading: false, @@ -399,7 +571,9 @@ export const MOCK_STORES = { error: new Error('Failed to load fonts'), }), - /** Font store with empty results */ + /** + * Empty result set state + */ emptyFontStore: createMockStore({ data: [], isLoading: false, @@ -414,36 +588,69 @@ export const MOCK_STORES = { const mockState = createMockFontStoreState(state); return { // State properties + /** + * Collection of mock fonts + */ get fonts() { return mockState.fonts; }, + /** + * Current mock page + */ get page() { return mockState.page; }, + /** + * Total mock pages + */ get totalPages() { return mockState.totalPages; }, + /** + * Mock items per page + */ get limit() { return mockState.limit; }, + /** + * Total mock items + */ get total() { return mockState.total; }, + /** + * Mock loading status + */ get isLoading() { return mockState.isLoading; }, + /** + * Mock error status + */ get error() { return mockState.error; }, + /** + * Mock search string + */ get searchQuery() { return mockState.searchQuery; }, + /** + * Mock provider filter + */ get provider() { return mockState.provider; }, + /** + * Mock category filter + */ get category() { return mockState.category; }, + /** + * Mock subset filter + */ get subset() { return mockState.subset; }, @@ -464,15 +671,45 @@ export const MOCK_STORES = { * Matches FontStore's public API for Storybook use */ fontStore: (config: { + /** + * Preset font list + */ fonts?: UnifiedFont[]; + /** + * Total item count + */ total?: number; + /** + * Items per page + */ limit?: number; + /** + * Pagination offset + */ offset?: number; + /** + * Loading flag + */ isLoading?: boolean; + /** + * Fetching flag + */ isFetching?: boolean; + /** + * Error flag + */ isError?: boolean; + /** + * Catch-all error object + */ error?: Error | null; + /** + * Has more pages flag + */ hasMore?: boolean; + /** + * Current page number + */ page?: number; } = {}) => { const { @@ -495,27 +732,51 @@ export const MOCK_STORES = { return { // State getters + /** + * Current mock parameters + */ get params() { return state.params; }, + /** + * Mock font list + */ get fonts() { return mockFonts; }, + /** + * Mock loading state + */ get isLoading() { return isLoading; }, + /** + * Mock fetching state + */ get isFetching() { return isFetching; }, + /** + * Mock error state + */ get isError() { return isError; }, + /** + * Mock error object + */ get error() { return error; }, + /** + * Mock empty state check + */ get isEmpty() { return !isLoading && !isFetching && mockFonts.length === 0; }, + /** + * Mock pagination metadata + */ get pagination() { return { total: mockTotal, @@ -527,18 +788,33 @@ export const MOCK_STORES = { }; }, // Category getters + /** + * Derived sans-serif filter + */ get sansSerifFonts() { return mockFonts.filter(f => f.category === 'sans-serif'); }, + /** + * Derived serif filter + */ get serifFonts() { return mockFonts.filter(f => f.category === 'serif'); }, + /** + * Derived display filter + */ get displayFonts() { return mockFonts.filter(f => f.category === 'display'); }, + /** + * Derived handwriting filter + */ get handwritingFonts() { return mockFonts.filter(f => f.category === 'handwriting'); }, + /** + * Derived monospace filter + */ get monospaceFonts() { return mockFonts.filter(f => f.category === 'monospace'); }, diff --git a/src/entities/Font/lib/sizeResolver/createFontRowSizeResolver.ts b/src/entities/Font/lib/sizeResolver/createFontRowSizeResolver.ts index fe053c3..29afcf5 100644 --- a/src/entities/Font/lib/sizeResolver/createFontRowSizeResolver.ts +++ b/src/entities/Font/lib/sizeResolver/createFontRowSizeResolver.ts @@ -13,15 +13,25 @@ import type { * (e.g. `SvelteMap.get()`) is automatically tracked as a dependency. */ export interface FontRowSizeResolverOptions { - /** Returns the current fonts array. Index `i` corresponds to row `i`. */ + /** + * Returns the current fonts array. Index `i` corresponds to row `i`. + */ getFonts: () => UnifiedFont[]; - /** Returns the active font weight (e.g. 400). */ + /** + * Returns the active font weight (e.g. 400). + */ getWeight: () => number; - /** Returns the preview text string. */ + /** + * Returns the preview text string. + */ getPreviewText: () => string; - /** Returns the scroll container's inner width in pixels. Returns 0 before mount. */ + /** + * Returns the scroll container's inner width in pixels. Returns 0 before mount. + */ getContainerWidth: () => number; - /** Returns the font size in pixels (e.g. `controlManager.renderedSize`). */ + /** + * Returns the font size in pixels (e.g. `controlManager.renderedSize`). + */ getFontSizePx: () => number; /** * Returns the computed line height in pixels. @@ -44,9 +54,13 @@ export interface FontRowSizeResolverOptions { * the content width is never over-estimated, keeping the height estimate safe. */ contentHorizontalPadding: number; - /** Fixed height in pixels of chrome that is not text content (header bar, etc.). */ + /** + * Fixed height in pixels of chrome that is not text content (header bar, etc.). + */ chromeHeight: number; - /** Height in pixels to return when the font is not loaded or container width is 0. */ + /** + * Height in pixels to return when the font is not loaded or container width is 0. + */ fallbackHeight: number; } @@ -79,12 +93,16 @@ export function createFontRowSizeResolver(options: FontRowSizeResolverOptions): return function resolveRowHeight(rowIndex: number): number { const fonts = options.getFonts(); const font = fonts[rowIndex]; - if (!font) return options.fallbackHeight; + if (!font) { + return options.fallbackHeight; + } const containerWidth = options.getContainerWidth(); const previewText = options.getPreviewText(); - if (containerWidth <= 0 || !previewText) return options.fallbackHeight; + if (containerWidth <= 0 || !previewText) { + return options.fallbackHeight; + } const weight = options.getWeight(); // generateFontKey: '{id}@{weight}' for static fonts, '{id}@vf' for variable fonts. @@ -93,7 +111,9 @@ export function createFontRowSizeResolver(options: FontRowSizeResolverOptions): // Reading via getStatus() allows the caller to pass appliedFontsManager.statuses.get(), // which creates a Svelte 5 reactive dependency when called inside $derived.by. const status = options.getStatus(fontKey); - if (status !== 'loaded') return options.fallbackHeight; + if (status !== 'loaded') { + return options.fallbackHeight; + } const fontSizePx = options.getFontSizePx(); const lineHeightPx = options.getLineHeightPx(); @@ -102,7 +122,9 @@ export function createFontRowSizeResolver(options: FontRowSizeResolverOptions): const cacheKey = `${fontCssString}|${previewText}|${contentWidth}|${lineHeightPx}`; const cached = cache.get(cacheKey); - if (cached !== undefined) return cached; + if (cached !== undefined) { + return cached; + } const { totalHeight } = engine.layout(previewText, fontCssString, contentWidth, lineHeightPx); const result = totalHeight + options.chromeHeight; diff --git a/src/features/SetupFont/model/const/const.ts b/src/entities/Font/model/const/const.ts similarity index 90% rename from src/features/SetupFont/model/const/const.ts rename to src/entities/Font/model/const/const.ts index 0bebbcd..971dca4 100644 --- a/src/features/SetupFont/model/const/const.ts +++ b/src/entities/Font/model/const/const.ts @@ -1,5 +1,5 @@ import type { ControlModel } from '$shared/lib'; -import type { ControlId } from '..'; +import type { ControlId } from '../types/typography'; /** * Font size constants @@ -86,3 +86,9 @@ export const DEFAULT_TYPOGRAPHY_CONTROLS_DATA: ControlModel[] = [ export const MULTIPLIER_S = 0.5; export const MULTIPLIER_M = 0.75; export const MULTIPLIER_L = 1; + +/** + * Index value for items not yet loaded in a virtualized list. + * Treated as being at the very bottom of the infinite scroll. + */ +export const VIRTUAL_INDEX_NOT_LOADED = Infinity; diff --git a/src/entities/Font/model/index.ts b/src/entities/Font/model/index.ts index e353b14..8749b29 100644 --- a/src/entities/Font/model/index.ts +++ b/src/entities/Font/model/index.ts @@ -1,2 +1,3 @@ +export * from './const/const'; export * from './store'; export * from './types'; diff --git a/src/entities/Font/model/store/appliedFontsStore/appliedFontStore.test.ts b/src/entities/Font/model/store/appliedFontsStore/appliedFontStore.test.ts index bac5059..b1183ba 100644 --- a/src/entities/Font/model/store/appliedFontsStore/appliedFontStore.test.ts +++ b/src/entities/Font/model/store/appliedFontsStore/appliedFontStore.test.ts @@ -1,10 +1,10 @@ -/** @vitest-environment jsdom */ +/** + * @vitest-environment jsdom + */ import { AppliedFontsManager } from './appliedFontsStore.svelte'; import { FontFetchError } from './errors'; import { FontEvictionPolicy } from './utils/fontEvictionPolicy/FontEvictionPolicy'; -// ── Fake collaborators ──────────────────────────────────────────────────────── - class FakeBufferCache { async get(_url: string): Promise { return new ArrayBuffer(8); @@ -13,7 +13,9 @@ class FakeBufferCache { clear(): void {} } -/** Throws {@link FontFetchError} on every `get()` — simulates network/HTTP failure. */ +/** + * Throws {@link FontFetchError} on every `get()` — simulates network/HTTP failure. + */ class FailingBufferCache { async get(url: string): Promise { throw new FontFetchError(url, new Error('network error'), 500); @@ -22,8 +24,6 @@ class FailingBufferCache { clear(): void {} } -// ── Helpers ─────────────────────────────────────────────────────────────────── - const makeConfig = (id: string, overrides: Partial<{ weight: number; isVariable: boolean }> = {}) => ({ id, name: id, @@ -32,8 +32,6 @@ const makeConfig = (id: string, overrides: Partial<{ weight: number; isVariable: ...overrides, }); -// ── Suite ───────────────────────────────────────────────────────────────────── - describe('AppliedFontsManager', () => { let manager: AppliedFontsManager; let eviction: FontEvictionPolicy; @@ -66,8 +64,6 @@ describe('AppliedFontsManager', () => { vi.unstubAllGlobals(); }); - // ── touch() ─────────────────────────────────────────────────────────────── - describe('touch()', () => { it('queues and loads a new font', async () => { manager.touch([makeConfig('roboto')]); @@ -131,8 +127,6 @@ describe('AppliedFontsManager', () => { }); }); - // ── queue processing ────────────────────────────────────────────────────── - describe('queue processing', () => { it('filters non-critical weights in data-saver mode', async () => { (navigator as any).connection = { saveData: true }; @@ -163,8 +157,6 @@ describe('AppliedFontsManager', () => { }); }); - // ── Phase 1: fetch ──────────────────────────────────────────────────────── - describe('Phase 1 — fetch', () => { it('sets status to error on fetch failure', async () => { const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); @@ -209,8 +201,6 @@ describe('AppliedFontsManager', () => { }); }); - // ── Phase 2: parse ──────────────────────────────────────────────────────── - describe('Phase 2 — parse', () => { it('sets status to error on parse failure', async () => { const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); @@ -241,8 +231,6 @@ describe('AppliedFontsManager', () => { }); }); - // ── #purgeUnused ────────────────────────────────────────────────────────── - describe('#purgeUnused', () => { it('evicts fonts after TTL expires', async () => { manager.touch([makeConfig('ephemeral')]); @@ -300,8 +288,6 @@ describe('AppliedFontsManager', () => { }); }); - // ── destroy() ───────────────────────────────────────────────────────────── - describe('destroy()', () => { it('clears all statuses', async () => { manager.touch([makeConfig('roboto')]); diff --git a/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts b/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts index f0ff3cd..48bf55c 100644 --- a/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts +++ b/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts @@ -156,7 +156,9 @@ export class AppliedFontsManager { } } - /** Returns true if data-saver mode is enabled (defers non-critical weights). */ + /** + * Returns true if data-saver mode is enabled (defers non-critical weights). + */ #shouldDeferNonCritical(): boolean { return (navigator as any).connection?.saveData === true; } @@ -188,13 +190,11 @@ export class AppliedFontsManager { const concurrency = getEffectiveConcurrency(); const buffers = new Map(); - // ==================== PHASE 1: Concurrent Fetching ==================== // Fetch multiple font files in parallel since network I/O is non-blocking for (let i = 0; i < entries.length; i += concurrency) { await this.#fetchChunk(entries.slice(i, i + concurrency), buffers); } - // ==================== PHASE 2: Sequential Parsing ==================== // Parse buffers one at a time with periodic yields to avoid blocking UI const hasInputPending = !!(navigator as any).scheduling?.isInputPending; let lastYield = performance.now(); @@ -246,12 +246,16 @@ export class AppliedFontsManager { ); for (const result of results) { - if (result.ok) continue; + if (result.ok) { + continue; + } const { key, config, reason } = result; const isAbort = reason instanceof FontFetchError && reason.cause instanceof Error && reason.cause.name === 'AbortError'; - if (isAbort) continue; + if (isAbort) { + continue; + } if (reason instanceof FontFetchError) { console.error(`Font fetch failed: ${config.name}`, reason); } @@ -279,7 +283,9 @@ export class AppliedFontsManager { } } - /** Removes fonts unused within TTL (LRU-style cleanup). Runs every PURGE_INTERVAL. Pinned fonts are never evicted. */ + /** + * Removes fonts unused within TTL (LRU-style cleanup). Runs every PURGE_INTERVAL. Pinned fonts are never evicted. + */ #purgeUnused() { const now = Date.now(); // Iterate through all tracked font keys @@ -291,7 +297,9 @@ export class AppliedFontsManager { // Remove FontFace from document to free memory const font = this.#loadedFonts.get(key); - if (font) document.fonts.delete(font); + if (font) { + document.fonts.delete(font); + } // Evict from cache and cleanup URL mapping const url = this.#urlByKey.get(key); @@ -307,7 +315,9 @@ export class AppliedFontsManager { } } - /** Returns current loading status for a font, or undefined if never requested. */ + /** + * Returns current loading status for a font, or undefined if never requested. + */ getFontStatus(id: string, weight: number, isVariable = false) { try { return this.statuses.get(generateFontKey({ id, weight, isVariable })); @@ -316,17 +326,23 @@ export class AppliedFontsManager { } } - /** Pins a font so it is never evicted by #purgeUnused(), regardless of TTL. */ + /** + * Pins a font so it is never evicted by #purgeUnused(), regardless of TTL. + */ pin(id: string, weight: number, isVariable = false): void { this.#eviction.pin(generateFontKey({ id, weight, isVariable })); } - /** Unpins a font, allowing it to be evicted by #purgeUnused() once its TTL expires. */ + /** + * Unpins a font, allowing it to be evicted by #purgeUnused() once its TTL expires. + */ unpin(id: string, weight: number, isVariable = false): void { this.#eviction.unpin(generateFontKey({ id, weight, isVariable })); } - /** Waits for all fonts to finish loading using document.fonts.ready. */ + /** + * Waits for all fonts to finish loading using document.fonts.ready. + */ async ready(): Promise { if (typeof document === 'undefined') { return; @@ -336,7 +352,9 @@ export class AppliedFontsManager { } catch { /* document unloaded */ } } - /** Aborts all operations, removes fonts from document, and clears state. Manager cannot be reused after. */ + /** + * Aborts all operations, removes fonts from document, and clears state. Manager cannot be reused after. + */ destroy() { // Abort all in-flight network requests this.#abortController.abort(); @@ -375,5 +393,7 @@ export class AppliedFontsManager { } } -/** Singleton instance — use throughout the application for unified font loading state. */ +/** + * Singleton instance — use throughout the application for unified font loading state. + */ export const appliedFontsManager = new AppliedFontsManager(); diff --git a/src/entities/Font/model/store/appliedFontsStore/utils/fontBufferCache/FontBufferCache.test.ts b/src/entities/Font/model/store/appliedFontsStore/utils/fontBufferCache/FontBufferCache.test.ts index 3ae0884..c8b52f0 100644 --- a/src/entities/Font/model/store/appliedFontsStore/utils/fontBufferCache/FontBufferCache.test.ts +++ b/src/entities/Font/model/store/appliedFontsStore/utils/fontBufferCache/FontBufferCache.test.ts @@ -1,4 +1,6 @@ -/** @vitest-environment jsdom */ +/** + * @vitest-environment jsdom + */ import { FontFetchError } from '../../errors'; import { FontBufferCache } from './FontBufferCache'; diff --git a/src/entities/Font/model/store/appliedFontsStore/utils/fontBufferCache/FontBufferCache.ts b/src/entities/Font/model/store/appliedFontsStore/utils/fontBufferCache/FontBufferCache.ts index a2e6ace..6f5eb6a 100644 --- a/src/entities/Font/model/store/appliedFontsStore/utils/fontBufferCache/FontBufferCache.ts +++ b/src/entities/Font/model/store/appliedFontsStore/utils/fontBufferCache/FontBufferCache.ts @@ -3,9 +3,13 @@ import { FontFetchError } from '../../errors'; type Fetcher = (url: string, init?: RequestInit) => Promise; interface FontBufferCacheOptions { - /** Custom fetch implementation. Defaults to `globalThis.fetch`. Inject in tests for isolation. */ + /** + * Custom fetch implementation. Defaults to `globalThis.fetch`. Inject in tests for isolation. + */ fetcher?: Fetcher; - /** Cache API cache name. Defaults to `'font-cache-v1'`. */ + /** + * Cache API cache name. Defaults to `'font-cache-v1'`. + */ cacheName?: string; } @@ -85,12 +89,16 @@ export class FontBufferCache { return buffer; } - /** Removes a URL from the in-memory cache. Next call to `get()` will re-fetch. */ + /** + * Removes a URL from the in-memory cache. Next call to `get()` will re-fetch. + */ evict(url: string): void { this.#buffersByUrl.delete(url); } - /** Clears all in-memory cached buffers. */ + /** + * Clears all in-memory cached buffers. + */ clear(): void { this.#buffersByUrl.clear(); } diff --git a/src/entities/Font/model/store/appliedFontsStore/utils/fontEvictionPolicy/FontEvictionPolicy.ts b/src/entities/Font/model/store/appliedFontsStore/utils/fontEvictionPolicy/FontEvictionPolicy.ts index e99abde..2a64cc6 100644 --- a/src/entities/Font/model/store/appliedFontsStore/utils/fontEvictionPolicy/FontEvictionPolicy.ts +++ b/src/entities/Font/model/store/appliedFontsStore/utils/fontEvictionPolicy/FontEvictionPolicy.ts @@ -1,5 +1,7 @@ interface FontEvictionPolicyOptions { - /** TTL in milliseconds. Defaults to 5 minutes. */ + /** + * TTL in milliseconds. Defaults to 5 minutes. + */ ttl?: number; } @@ -28,12 +30,16 @@ export class FontEvictionPolicy { this.#usageTracker.set(key, now); } - /** Pins a font key so it is never evicted regardless of TTL. */ + /** + * Pins a font key so it is never evicted regardless of TTL. + */ pin(key: string): void { this.#pinnedFonts.add(key); } - /** Unpins a font key, allowing it to be evicted once its TTL expires. */ + /** + * Unpins a font key, allowing it to be evicted once its TTL expires. + */ unpin(key: string): void { this.#pinnedFonts.delete(key); } @@ -57,18 +63,24 @@ export class FontEvictionPolicy { return now - lastUsed >= this.#TTL; } - /** Returns an iterator over all tracked font keys. */ + /** + * Returns an iterator over all tracked font keys. + */ keys(): IterableIterator { return this.#usageTracker.keys(); } - /** Removes a font key from tracking. Called by the orchestrator after eviction. */ + /** + * Removes a font key from tracking. Called by the orchestrator after eviction. + */ remove(key: string): void { this.#usageTracker.delete(key); this.#pinnedFonts.delete(key); } - /** Clears all usage timestamps and pinned keys. */ + /** + * Clears all usage timestamps and pinned keys. + */ clear(): void { this.#usageTracker.clear(); this.#pinnedFonts.clear(); diff --git a/src/entities/Font/model/store/appliedFontsStore/utils/fontLoadQueue/FontLoadQueue.ts b/src/entities/Font/model/store/appliedFontsStore/utils/fontLoadQueue/FontLoadQueue.ts index 5e6de9f..e921eb9 100644 --- a/src/entities/Font/model/store/appliedFontsStore/utils/fontLoadQueue/FontLoadQueue.ts +++ b/src/entities/Font/model/store/appliedFontsStore/utils/fontLoadQueue/FontLoadQueue.ts @@ -34,22 +34,30 @@ export class FontLoadQueue { return entries; } - /** Returns `true` if the key is currently in the queue. */ + /** + * Returns `true` if the key is currently in the queue. + */ has(key: string): boolean { return this.#queue.has(key); } - /** Increments the retry count for a font key. */ + /** + * Increments the retry count for a font key. + */ incrementRetry(key: string): void { this.#retryCounts.set(key, (this.#retryCounts.get(key) ?? 0) + 1); } - /** Returns `true` if the font has reached or exceeded the maximum retry limit. */ + /** + * Returns `true` if the font has reached or exceeded the maximum retry limit. + */ isMaxRetriesReached(key: string): boolean { return (this.#retryCounts.get(key) ?? 0) >= this.#MAX_RETRIES; } - /** Clears all queued fonts and resets all retry counts. */ + /** + * Clears all queued fonts and resets all retry counts. + */ clear(): void { this.#queue.clear(); this.#retryCounts.clear(); diff --git a/src/entities/Font/model/store/appliedFontsStore/utils/loadFont/loadFont.test.ts b/src/entities/Font/model/store/appliedFontsStore/utils/loadFont/loadFont.test.ts index 4b7a667..f44b289 100644 --- a/src/entities/Font/model/store/appliedFontsStore/utils/loadFont/loadFont.test.ts +++ b/src/entities/Font/model/store/appliedFontsStore/utils/loadFont/loadFont.test.ts @@ -1,4 +1,6 @@ -/** @vitest-environment jsdom */ +/** + * @vitest-environment jsdom + */ import { FontParseError } from '../../errors'; import { loadFont } from './loadFont'; diff --git a/src/entities/Font/model/store/batchFontStore.svelte.ts b/src/entities/Font/model/store/batchFontStore.svelte.ts index 0ae30e4..79a4846 100644 --- a/src/entities/Font/model/store/batchFontStore.svelte.ts +++ b/src/entities/Font/model/store/batchFontStore.svelte.ts @@ -15,7 +15,9 @@ import type { UnifiedFont } from '../../model/types'; * Standalone function to avoid 'this' issues during construction. */ async function fetchAndSeed(ids: string[]): Promise { - if (ids.length === 0) return []; + if (ids.length === 0) { + return []; + } let response: UnifiedFont[]; try { diff --git a/src/entities/Font/model/store/fontStore/fontStore.svelte.spec.ts b/src/entities/Font/model/store/fontStore/fontStore.svelte.spec.ts index 2b6f181..d2e058c 100644 --- a/src/entities/Font/model/store/fontStore/fontStore.svelte.spec.ts +++ b/src/entities/Font/model/store/fontStore/fontStore.svelte.spec.ts @@ -61,7 +61,6 @@ describe('FontStore', () => { vi.resetAllMocks(); }); - // ----------------------------------------------------------------------- describe('construction', () => { it('stores initial params', () => { const store = makeStore({ limit: 20 }); @@ -90,7 +89,6 @@ describe('FontStore', () => { }); }); - // ----------------------------------------------------------------------- describe('state after fetch', () => { it('exposes loaded fonts', async () => { const store = await fetchedStore({}, generateMockFonts(7)); @@ -129,7 +127,6 @@ describe('FontStore', () => { }); }); - // ----------------------------------------------------------------------- describe('error states', () => { it('isError is false before any fetch', () => { const store = makeStore(); @@ -178,7 +175,6 @@ describe('FontStore', () => { }); }); - // ----------------------------------------------------------------------- describe('font accumulation', () => { it('replaces fonts when refetching the first page', async () => { const store = makeStore(); @@ -212,7 +208,6 @@ describe('FontStore', () => { }); }); - // ----------------------------------------------------------------------- describe('pagination state', () => { it('returns zero-value defaults before any fetch', () => { const store = makeStore(); @@ -248,7 +243,6 @@ describe('FontStore', () => { }); }); - // ----------------------------------------------------------------------- describe('setParams', () => { it('merges updates into existing params', () => { const store = makeStore({ limit: 10 }); @@ -266,7 +260,6 @@ describe('FontStore', () => { }); }); - // ----------------------------------------------------------------------- describe('filter change resets', () => { it('clears accumulated fonts when a filter changes', async () => { const store = await fetchedStore({}, generateMockFonts(5)); @@ -302,7 +295,6 @@ describe('FontStore', () => { }); }); - // ----------------------------------------------------------------------- describe('staleTime in buildOptions', () => { it('is 5 minutes with no active filters', () => { const store = makeStore(); @@ -331,7 +323,6 @@ describe('FontStore', () => { }); }); - // ----------------------------------------------------------------------- describe('buildQueryKey', () => { it('omits empty-string params', () => { const store = makeStore(); @@ -366,7 +357,6 @@ describe('FontStore', () => { }); }); - // ----------------------------------------------------------------------- describe('destroy', () => { it('does not throw', () => { const store = makeStore(); @@ -380,7 +370,6 @@ describe('FontStore', () => { }); }); - // ----------------------------------------------------------------------- describe('refetch', () => { it('triggers a fetch', async () => { const store = makeStore(); @@ -400,7 +389,6 @@ describe('FontStore', () => { }); }); - // ----------------------------------------------------------------------- describe('nextPage', () => { let store: FontStore; @@ -437,7 +425,6 @@ describe('FontStore', () => { }); }); - // ----------------------------------------------------------------------- describe('prevPage and goToPage', () => { it('prevPage is a no-op — infinite scroll does not support backward navigation', async () => { const store = await fetchedStore({}, generateMockFonts(5)); @@ -454,7 +441,6 @@ describe('FontStore', () => { }); }); - // ----------------------------------------------------------------------- describe('prefetch', () => { it('triggers a fetch for the provided params', async () => { const store = makeStore(); @@ -465,7 +451,6 @@ describe('FontStore', () => { }); }); - // ----------------------------------------------------------------------- describe('getCachedData / setQueryData', () => { it('getCachedData returns undefined before any fetch', () => { queryClient.clear(); @@ -497,7 +482,6 @@ describe('FontStore', () => { }); }); - // ----------------------------------------------------------------------- describe('invalidate', () => { it('calls invalidateQueries', async () => { const store = await fetchedStore(); @@ -508,7 +492,6 @@ describe('FontStore', () => { }); }); - // ----------------------------------------------------------------------- describe('setLimit', () => { it('updates the limit param', () => { const store = makeStore({ limit: 10 }); @@ -518,7 +501,6 @@ describe('FontStore', () => { }); }); - // ----------------------------------------------------------------------- describe('filter shortcut methods', () => { let store: FontStore; @@ -561,7 +543,6 @@ describe('FontStore', () => { }); }); - // ----------------------------------------------------------------------- describe('category getters', () => { it('each getter returns only fonts of that category', async () => { const fonts = generateMixedCategoryFonts(2); // 2 of each category = 10 total @@ -580,4 +561,67 @@ describe('FontStore', () => { store.destroy(); }); }); + + describe('fetchAllPagesTo', () => { + beforeEach(() => { + fetch.mockReset(); + queryClient.clear(); + }); + + it('fetches all missing pages in parallel up to targetIndex', async () => { + // First page already loaded (offset 0, limit 10, total 50) + const firstFonts = generateMockFonts(10); + fetch.mockResolvedValueOnce(makeResponse(firstFonts, { total: 50, limit: 10, offset: 0 })); + const store = makeStore(); + await store.refetch(); + flushSync(); + + expect(store.fonts).toHaveLength(10); + + // Mock remaining pages + for (let offset = 10; offset < 50; offset += 10) { + fetch.mockResolvedValueOnce( + makeResponse(generateMockFonts(10), { total: 50, limit: 10, offset }), + ); + } + + await store.fetchAllPagesTo(40); + flushSync(); + + expect(store.fonts).toHaveLength(50); + }); + + it('skips pages that fail and still merges successful ones', async () => { + const firstFonts = generateMockFonts(10); + fetch.mockResolvedValueOnce(makeResponse(firstFonts, { total: 30, limit: 10, offset: 0 })); + const store = makeStore(); + await store.refetch(); + flushSync(); + + // offset=10 fails, offset=20 succeeds + fetch.mockRejectedValueOnce(new Error('network error')); + fetch.mockResolvedValueOnce( + makeResponse(generateMockFonts(10), { total: 30, limit: 10, offset: 20 }), + ); + + await store.fetchAllPagesTo(25); + flushSync(); + + // Page at offset=20 merged, page at offset=10 missing — 20 total + expect(store.fonts).toHaveLength(20); + }); + + it('is a no-op when target is within already-loaded data', async () => { + const firstFonts = generateMockFonts(10); + fetch.mockResolvedValueOnce(makeResponse(firstFonts, { total: 50, limit: 10, offset: 0 })); + const store = makeStore(); + await store.refetch(); + flushSync(); + + const callsBefore = fetch.mock.calls.length; + await store.fetchAllPagesTo(5); + + expect(fetch.mock.calls.length).toBe(callsBefore); + }); + }); }); diff --git a/src/entities/Font/model/store/fontStore/fontStore.svelte.ts b/src/entities/Font/model/store/fontStore/fontStore.svelte.ts index 95edee0..aeeb9cc 100644 --- a/src/entities/Font/model/store/fontStore/fontStore.svelte.ts +++ b/src/entities/Font/model/store/fontStore/fontStore.svelte.ts @@ -18,7 +18,9 @@ import type { UnifiedFont } from '../../types'; type PageParam = { offset: number }; -/** Filter params + limit — offset is managed by TQ as a page param, not a user param. */ +/** + * Filter params + limit — offset is managed by TQ as a page param, not a user param. + */ type FontStoreParams = Omit; type FontStoreResult = InfiniteQueryObserverResult, Error>; @@ -44,34 +46,53 @@ export class FontStore { }); } - // -- Public state -- - + /** + * Current filter and limit configuration + */ get params(): FontStoreParams { return this.#params; } + /** + * Flattened list of all fonts loaded across all pages (reactive) + */ get fonts(): UnifiedFont[] { return this.#result.data?.pages.flatMap((p: ProxyFontsResponse) => p.fonts) ?? []; } + /** + * True if the first page is currently being fetched + */ get isLoading(): boolean { return this.#result.isLoading; } + /** + * True if any background fetch is in progress (initial or pagination) + */ get isFetching(): boolean { return this.#result.isFetching; } + /** + * True if the last fetch attempt resulted in an error + */ get isError(): boolean { return this.#result.isError; } + /** + * Last caught error from the query observer + */ get error(): Error | null { return this.#result.error ?? null; } - // isEmpty is false during loading/fetching so the UI never flashes "no results" - // while a fetch is in progress. The !isFetching guard is specifically for the filter-change - // transition: fonts clear synchronously → isFetching becomes true → isEmpty stays false. + /** + * True if no fonts were found for the current filter criteria + */ get isEmpty(): boolean { return !this.isLoading && !this.isFetching && this.fonts.length === 0; } + /** + * Pagination metadata derived from the last loaded page + */ get pagination() { const pages = this.#result.data?.pages; const last = pages?.at(-1); @@ -95,45 +116,65 @@ export class FontStore { }; } - // -- Lifecycle -- - + /** + * Cleans up subscriptions and destroys the observer + */ destroy() { this.#unsubscribe(); this.#observer.destroy(); } - // -- Param management -- - + /** + * Merge new parameters into existing state and trigger a refetch + */ setParams(updates: Partial) { this.#params = { ...this.#params, ...updates }; this.#observer.setOptions(this.buildOptions()); } + /** + * Forcefully invalidate and refetch the current query from the network + */ invalidate() { this.#qc.invalidateQueries({ queryKey: this.buildQueryKey(this.#params) }); } - // -- Async operations -- - + /** + * Manually trigger a query refetch + */ async refetch() { await this.#observer.refetch(); } + /** + * Prime the cache with data for a specific parameter set + */ async prefetch(params: FontStoreParams) { await this.#qc.prefetchInfiniteQuery(this.buildOptions(params)); } + /** + * Abort any active network requests for this store + */ cancel() { this.#qc.cancelQueries({ queryKey: this.buildQueryKey(this.#params) }); } + /** + * Retrieve current font list from cache without triggering a fetch + */ getCachedData(): UnifiedFont[] | undefined { const data = this.#qc.getQueryData>( this.buildQueryKey(this.#params), ); - if (!data) return undefined; + if (!data) { + return undefined; + } return data.pages.flatMap(p => p.fonts); } + /** + * Manually update the cached font data (useful for optimistic updates) + */ setQueryData(updater: (old: UnifiedFont[] | undefined) => UnifiedFont[]) { const key = this.buildQueryKey(this.#params); this.#qc.setQueryData>( @@ -164,55 +205,191 @@ export class FontStore { ); } - // -- Filter shortcuts -- - + /** + * Shortcut to update provider filters + */ setProviders(v: ProxyFontsParams['providers']) { this.setParams({ providers: v }); } + /** + * Shortcut to update category filters + */ setCategories(v: ProxyFontsParams['categories']) { this.setParams({ categories: v }); } + /** + * Shortcut to update subset filters + */ setSubsets(v: ProxyFontsParams['subsets']) { this.setParams({ subsets: v }); } + /** + * Shortcut to update search query + */ setSearch(v: string) { this.setParams({ q: v || undefined }); } + /** + * Shortcut to update sort order + */ setSort(v: ProxyFontsParams['sort']) { this.setParams({ sort: v }); } - // -- Pagination navigation -- - + /** + * Fetch the next page of results if available + */ async nextPage(): Promise { await this.#observer.fetchNextPage(); } - prevPage(): void {} // no-op: infinite scroll accumulates forward only; method kept for API compatibility - goToPage(_page: number): void {} // no-op + #isCatchingUp = false; + #inFlightOffsets = new Set(); + + /** + * Fetch all pages between the current loaded count and targetIndex in parallel. + * Pages are merged into the cache as they arrive (sorted by offset). + * Failed pages are silently skipped — normal scroll will re-fetch them on demand. + */ + async fetchAllPagesTo(targetIndex: number): Promise { + if (this.#isCatchingUp) { + return; + } + + const pageSize = typeof this.#params.limit === 'number' ? this.#params.limit : 50; + const key = this.buildQueryKey(this.#params); + const existing = this.#qc.getQueryData>(key); + + if (!existing) { + return; + } + + const loadedOffsets = new Set(existing.pageParams.map(p => p.offset)); + + // Collect offsets for all missing and not-in-flight pages + const missingOffsets: number[] = []; + for (let offset = 0; offset <= targetIndex; offset += pageSize) { + if (!loadedOffsets.has(offset) && !this.#inFlightOffsets.has(offset)) { + missingOffsets.push(offset); + } + } + + if (missingOffsets.length === 0) { + return; + } + + this.#isCatchingUp = true; + + // Sorted merge buffer — flush in offset order as pages arrive + const buffer = new Map(); + const failed = new Set(); + let nextFlushOffset = (existing.pageParams.at(-1)?.offset ?? -pageSize) + pageSize; + + const flush = () => { + while (buffer.has(nextFlushOffset) || failed.has(nextFlushOffset)) { + if (buffer.has(nextFlushOffset)) { + this.#appendPageToCache(buffer.get(nextFlushOffset)!); + buffer.delete(nextFlushOffset); + } + failed.delete(nextFlushOffset); + nextFlushOffset += pageSize; + } + }; + + try { + await Promise.allSettled( + missingOffsets.map(async offset => { + this.#inFlightOffsets.add(offset); + try { + const page = await this.fetchPage({ ...this.#params, offset }); + buffer.set(offset, page); + } catch { + failed.add(offset); + } finally { + this.#inFlightOffsets.delete(offset); + } + flush(); + }), + ); + } finally { + this.#isCatchingUp = false; + } + } + + /** + * Backward pagination (no-op: infinite scroll accumulates forward only) + */ + prevPage(): void {} + /** + * Jump to specific page (no-op for infinite scroll) + */ + goToPage(_page: number): void {} + + /** + * Update the number of items fetched per page + */ setLimit(limit: number) { this.setParams({ limit }); } - // -- Category views -- - + /** + * Derived list of sans-serif fonts in the current set + */ get sansSerifFonts() { return this.fonts.filter(f => f.category === 'sans-serif'); } + /** + * Derived list of serif fonts in the current set + */ get serifFonts() { return this.fonts.filter(f => f.category === 'serif'); } + /** + * Derived list of display fonts in the current set + */ get displayFonts() { return this.fonts.filter(f => f.category === 'display'); } + /** + * Derived list of handwriting fonts in the current set + */ get handwritingFonts() { return this.fonts.filter(f => f.category === 'handwriting'); } + /** + * Derived list of monospace fonts in the current set + */ get monospaceFonts() { return this.fonts.filter(f => f.category === 'monospace'); } - // -- Private helpers (TypeScript-private so tests can spy via `as any`) -- + /** + * Merge a single page into the InfiniteQuery cache in offset order. + * Called by fetchAllPagesTo as each parallel fetch resolves. + */ + #appendPageToCache(page: ProxyFontsResponse): void { + const key = this.buildQueryKey(this.#params); + const existing = this.#qc.getQueryData>(key); + if (!existing) { + return; + } + + // Guard against duplicates + const loadedOffsets = new Set(existing.pageParams.map(p => p.offset)); + if (loadedOffsets.has(page.offset)) { + return; + } + + const allPages = [...existing.pages, page].sort((a, b) => a.offset - b.offset); + const allParams = [...existing.pageParams, { offset: page.offset }].sort( + (a, b) => a.offset - b.offset, + ); + + this.#qc.setQueryData>(key, { + pages: allPages, + pageParams: allParams, + }); + } private buildQueryKey(params: FontStoreParams): readonly unknown[] { const filtered: Record = {}; @@ -263,9 +440,15 @@ export class FontStore { throw new FontNetworkError(cause); } - if (!response) throw new FontResponseError('response', response); - if (!response.fonts) throw new FontResponseError('response.fonts', response.fonts); - if (!Array.isArray(response.fonts)) throw new FontResponseError('response.fonts', response.fonts); + if (!response) { + throw new FontResponseError('response', response); + } + if (!response.fonts) { + throw new FontResponseError('response.fonts', response.fonts); + } + if (!Array.isArray(response.fonts)) { + throw new FontResponseError('response.fonts', response.fonts); + } return { fonts: response.fonts, diff --git a/src/entities/Font/model/store/index.ts b/src/entities/Font/model/store/index.ts index 9091e93..3ef8061 100644 --- a/src/entities/Font/model/store/index.ts +++ b/src/entities/Font/model/store/index.ts @@ -1,5 +1,5 @@ // Applied fonts manager -export { appliedFontsManager } from './appliedFontsStore/appliedFontsStore.svelte'; +export * from './appliedFontsStore/appliedFontsStore.svelte'; // Batch font store export { BatchFontStore } from './batchFontStore.svelte'; diff --git a/src/entities/Font/model/types/font.ts b/src/entities/Font/model/types/font.ts index b35d213..9cf368f 100644 --- a/src/entities/Font/model/types/font.ts +++ b/src/entities/Font/model/types/font.ts @@ -31,18 +31,28 @@ export type FontSubset = 'latin' | 'latin-ext' | 'cyrillic' | 'greek' | 'arabic' * Combined filter state for font queries */ export interface FontFilters { - /** Selected font providers */ + /** + * Active font providers to fetch from + */ providers: FontProvider[]; - /** Selected font categories */ + /** + * Visual classifications (sans, serif, etc.) + */ categories: FontCategory[]; - /** Selected character subsets */ + /** + * Character sets required for the sample text + */ subsets: FontSubset[]; } -/** Filter group identifier */ +/** + * Filter group identifier + */ export type FilterGroup = 'providers' | 'categories' | 'subsets'; -/** Filter type including search query */ +/** + * Filter type including search query + */ export type FilterType = FilterGroup | 'searchQuery'; /** @@ -80,15 +90,25 @@ export type UnifiedFontVariant = FontVariant; * Font style URLs */ export interface FontStyleUrls { - /** Regular weight URL */ + /** + * URL for the regular (400) weight + */ regular?: string; - /** Italic URL */ + /** + * URL for the italic (400) style + */ italic?: string; - /** Bold weight URL */ + /** + * URL for the bold (700) weight + */ bold?: string; - /** Bold italic URL */ + /** + * URL for the bold-italic (700) style + */ boldItalic?: string; - /** Additional variant mapping */ + /** + * Mapping for all other numeric/custom variants + */ variants?: Partial>; } @@ -96,19 +116,24 @@ export interface FontStyleUrls { * Font metadata */ export interface FontMetadata { - /** Timestamp when font was cached */ + /** + * Epoch timestamp of last successful fetch + */ cachedAt: number; - /** Font version from provider */ + /** + * Semantic version string from upstream + */ version?: string; - /** Last modified date from provider */ + /** + * ISO date string of last remote update + */ lastModified?: string; - /** Popularity rank (if available from provider) */ + /** + * Raw ranking integer from provider + */ popularity?: number; /** - * Normalized popularity score (0-100) - * - * Normalized across all fonts for consistent ranking - * Higher values indicate more popular fonts + * Normalized score (0-100) used for global sorting */ popularityScore?: number; } @@ -117,17 +142,38 @@ export interface FontMetadata { * Font features (variable fonts, axes, tags) */ export interface FontFeatures { - /** Whether this is a variable font */ + /** + * Whether the font supports fluid weight/width axes + */ isVariable?: boolean; - /** Variable font axes (for Fontshare) */ + /** + * Definable axes for variable font interpolation + */ axes?: Array<{ + /** + * Human-readable axis name (e.g., 'Weight') + */ name: string; + /** + * CSS property name (e.g., 'wght') + */ property: string; + /** + * Default numeric value for the axis + */ default: number; + /** + * Minimum inclusive bound + */ min: number; + /** + * Maximum inclusive bound + */ max: number; }>; - /** Usage tags (for Fontshare) */ + /** + * Descriptive keywords for search indexing + */ tags?: string[]; } @@ -138,29 +184,44 @@ export interface FontFeatures { * for consistent font handling across the application. */ export interface UnifiedFont { - /** Unique identifier (Google: family name, Fontshare: slug) */ + /** + * Unique ID (family name for Google, slug for Fontshare) + */ id: string; - /** Font display name */ + /** + * Canonical family name for CSS font-family + */ name: string; - /** Font provider (google | fontshare) */ + /** + * Upstream data source + */ provider: FontProvider; /** - * Provider badge display name - * - * Human-readable provider name for UI display - * e.g., "Google Fonts" or "Fontshare" + * Display label for provider badges */ providerBadge?: string; - /** Font category classification */ + /** + * Primary typographic category + */ category: FontCategory; - /** Supported character subsets */ + /** + * All supported character sets + */ subsets: FontSubset[]; - /** Available font variants (weights, styles) */ + /** + * List of available weights and styles + */ variants: UnifiedFontVariant[]; - /** URL mapping for font file downloads */ + /** + * Remote assets for font loading + */ styles: FontStyleUrls; - /** Additional metadata */ + /** + * Technical metadata and rankings + */ metadata: FontMetadata; - /** Advanced font features */ + /** + * Variable font details and tags + */ features: FontFeatures; } diff --git a/src/entities/Font/model/types/index.ts b/src/entities/Font/model/types/index.ts index 930dd25..f4edb26 100644 --- a/src/entities/Font/model/types/index.ts +++ b/src/entities/Font/model/types/index.ts @@ -1,12 +1,3 @@ -/** - * ============================================================================ - * SINGLE EXPORT POINT - * ============================================================================ - * - * This is the single export point for all Font types. - * All imports should use: `import { X } from '$entities/Font/model/types'` - */ - // Font domain and model types export type { FilterGroup, @@ -33,3 +24,4 @@ export type { } from './store'; export * from './store/appliedFonts'; +export * from './typography'; diff --git a/src/entities/Font/model/types/store.ts b/src/entities/Font/model/types/store.ts index 894adcc..dde1165 100644 --- a/src/entities/Font/model/types/store.ts +++ b/src/entities/Font/model/types/store.ts @@ -1,9 +1,3 @@ -/** - * ============================================================================ - * STORE TYPES - * ============================================================================ - */ - import type { FontCategory, FontProvider, @@ -12,37 +6,55 @@ import type { } from './font'; /** - * Font collection state + * Global state for the local font collection */ export interface FontCollectionState { - /** All cached fonts */ + /** + * Map of cached fonts indexed by their unique family ID + */ fonts: Record; - /** Active filters */ + /** + * Set of active user-defined filters + */ filters: FontCollectionFilters; - /** Sort configuration */ + /** + * Current sorting parameters for the display list + */ sort: FontCollectionSort; } /** - * Font collection filters + * Filter configuration for narrow collections */ export interface FontCollectionFilters { - /** Search query */ + /** + * Partial family name to match against + */ searchQuery: string; - /** Filter by providers */ + /** + * Data sources (Google, Fontshare) to include + */ providers?: FontProvider[]; - /** Filter by categories */ + /** + * Typographic categories (Serif, Sans, etc.) to include + */ categories?: FontCategory[]; - /** Filter by subsets */ + /** + * Character sets (Latin, Cyrillic, etc.) to include + */ subsets?: FontSubset[]; } /** - * Font collection sort configuration + * Ordering configuration for the font list */ export interface FontCollectionSort { - /** Sort field */ + /** + * The font property to order by + */ field: 'name' | 'popularity' | 'category'; - /** Sort direction */ + /** + * The sort order (Ascending or Descending) + */ direction: 'asc' | 'desc'; } diff --git a/src/entities/Font/model/types/typography.ts b/src/entities/Font/model/types/typography.ts new file mode 100644 index 0000000..19104a2 --- /dev/null +++ b/src/entities/Font/model/types/typography.ts @@ -0,0 +1 @@ +export type ControlId = 'font_size' | 'font_weight' | 'line_height' | 'letter_spacing'; diff --git a/src/entities/Font/ui/FontApplicator/FontApplicator.stories.svelte b/src/entities/Font/ui/FontApplicator/FontApplicator.stories.svelte new file mode 100644 index 0000000..ff14ba7 --- /dev/null +++ b/src/entities/Font/ui/FontApplicator/FontApplicator.stories.svelte @@ -0,0 +1,91 @@ + + + + + + {#snippet template(args: ComponentProps)} + +

The quick brown fox jumps over the lazy dog

+
+ {/snippet} +
+ + + {#snippet template(args: ComponentProps)} + +

The quick brown fox jumps over the lazy dog

+
+ {/snippet} +
+ + + {#snippet template(args: ComponentProps)} + +

The quick brown fox jumps over the lazy dog

+
+ {/snippet} +
diff --git a/src/entities/Font/ui/FontApplicator/FontApplicator.svelte b/src/entities/Font/ui/FontApplicator/FontApplicator.svelte index 8482e49..fe12255 100644 --- a/src/entities/Font/ui/FontApplicator/FontApplicator.svelte +++ b/src/entities/Font/ui/FontApplicator/FontApplicator.svelte @@ -1,15 +1,13 @@ -
- {@render children?.()} -
+{#if !shouldReveal && skeleton} + {@render skeleton()} +{:else} +
+ {@render children?.()} +
+{/if} diff --git a/src/entities/Font/ui/FontVirtualList/FontVirtualList.stories.svelte b/src/entities/Font/ui/FontVirtualList/FontVirtualList.stories.svelte new file mode 100644 index 0000000..caf0107 --- /dev/null +++ b/src/entities/Font/ui/FontVirtualList/FontVirtualList.stories.svelte @@ -0,0 +1,114 @@ + + + + + + {#snippet template(args: ComponentProps)} +
+ + {#snippet skeleton()} +
+ {#each Array(6) as _} +
+ {/each} +
+ {/snippet} + {#snippet children({ item })} +
{item.name}
+ {/snippet} +
+
+ {/snippet} +
+ + + {#snippet template(args: ComponentProps)} +
+ + {#snippet children({ item })} +
{item.name}
+ {/snippet} +
+
+ {/snippet} +
+ + + {#snippet template(args: ComponentProps)} +
+ + {#snippet skeleton()} +
+ {#each Array(6) as _} +
+ {/each} +
+ {/snippet} + {#snippet children({ item })} +
+ {item.name} + {item.category} +
+ {/snippet} +
+
+ {/snippet} +
diff --git a/src/entities/Font/ui/FontVirtualList/FontVirtualList.svelte b/src/entities/Font/ui/FontVirtualList/FontVirtualList.svelte index 71c530f..7d4cc84 100644 --- a/src/entities/Font/ui/FontVirtualList/FontVirtualList.svelte +++ b/src/entities/Font/ui/FontVirtualList/FontVirtualList.svelte @@ -4,6 +4,7 @@ - Handles font registration with the manager -->
- {#if skeleton && isLoading && fontStore.fonts.length === 0} + {#if showInitialSkeleton && skeleton} -
+
{@render skeleton()}
{:else} @@ -117,14 +162,20 @@ function handleNearBottom(_lastVisibleIndex: number) { {#snippet children(scope)} {@render children(scope)} {/snippet} + {#if showCatchupSkeleton && skeleton} +
+ {@render skeleton()} +
+ {/if} {/if}
diff --git a/src/features/ChangeAppTheme/model/store/ThemeManager/ThemeManager.svelte.ts b/src/features/ChangeAppTheme/model/store/ThemeManager/ThemeManager.svelte.ts index 5a999d4..540f0e2 100644 --- a/src/features/ChangeAppTheme/model/store/ThemeManager/ThemeManager.svelte.ts +++ b/src/features/ChangeAppTheme/model/store/ThemeManager/ThemeManager.svelte.ts @@ -41,15 +41,25 @@ type ThemeSource = 'system' | 'user'; */ class ThemeManager { // Private reactive state - /** Current theme value ('light' or 'dark') */ + /** + * Current theme value ('light' or 'dark') + */ #theme = $state('light'); - /** Whether theme is controlled by user or follows system */ + /** + * Whether theme is controlled by user or follows system + */ #source = $state('system'); - /** MediaQueryList for detecting system theme changes */ + /** + * MediaQueryList for detecting system theme changes + */ #mediaQuery: MediaQueryList | null = null; - /** Persistent storage for user's theme preference */ + /** + * Persistent storage for user's theme preference + */ #store = createPersistentStore('glyphdiff:theme', null); - /** Bound handler for system theme change events */ + /** + * Bound handler for system theme change events + */ #systemChangeHandler = this.#onSystemChange.bind(this); constructor() { @@ -64,22 +74,30 @@ class ThemeManager { } } - /** Current theme value */ + /** + * Current theme value + */ get value(): Theme { return this.#theme; } - /** Source of current theme ('system' or 'user') */ + /** + * Source of current theme ('system' or 'user') + */ get source(): ThemeSource { return this.#source; } - /** Whether dark theme is active */ + /** + * Whether dark theme is active + */ get isDark(): boolean { return this.#theme === 'dark'; } - /** Whether theme is controlled by user (not following system) */ + /** + * Whether theme is controlled by user (not following system) + */ get isUserControlled(): boolean { return this.#source === 'user'; } diff --git a/src/features/ChangeAppTheme/model/store/ThemeManager/ThemeManager.test.ts b/src/features/ChangeAppTheme/model/store/ThemeManager/ThemeManager.test.ts index 702883a..2018e0f 100644 --- a/src/features/ChangeAppTheme/model/store/ThemeManager/ThemeManager.test.ts +++ b/src/features/ChangeAppTheme/model/store/ThemeManager/ThemeManager.test.ts @@ -1,9 +1,9 @@ -/** @vitest-environment jsdom */ +/** + * @vitest-environment jsdom + */ -// ============================================================ // Mock MediaQueryListEvent for system theme change simulations // Note: Other mocks (ResizeObserver, localStorage, matchMedia) are set up in vitest.setup.unit.ts -// ============================================================ class MockMediaQueryListEvent extends Event { matches: boolean; @@ -16,9 +16,7 @@ class MockMediaQueryListEvent extends Event { } } -// ============================================================ // NOW IT'S SAFE TO IMPORT -// ============================================================ import { afterEach, diff --git a/src/features/ChangeAppTheme/ui/ThemeSwitch/ThemeSwitch.svelte.test.ts b/src/features/ChangeAppTheme/ui/ThemeSwitch/ThemeSwitch.svelte.test.ts new file mode 100644 index 0000000..393bf55 --- /dev/null +++ b/src/features/ChangeAppTheme/ui/ThemeSwitch/ThemeSwitch.svelte.test.ts @@ -0,0 +1,56 @@ +import { + fireEvent, + render, + screen, +} from '@testing-library/svelte'; +import { themeManager } from '../../model'; +import ThemeSwitch from './ThemeSwitch.svelte'; + +const context = new Map([['responsive', { isMobile: false }]]); + +describe('ThemeSwitch', () => { + beforeEach(() => { + themeManager.setTheme('light'); + }); + + describe('Rendering', () => { + it('renders an icon button', () => { + render(ThemeSwitch, { context }); + expect(screen.getByRole('button')).toBeInTheDocument(); + }); + + it('has "Toggle theme" title', () => { + render(ThemeSwitch, { context }); + expect(screen.getByTitle('Toggle theme')).toBeInTheDocument(); + }); + + it('renders an SVG icon', () => { + const { container } = render(ThemeSwitch, { context }); + expect(container.querySelector('svg')).toBeInTheDocument(); + }); + }); + + describe('Interaction', () => { + it('toggles theme from light to dark on click', async () => { + render(ThemeSwitch, { context }); + expect(themeManager.value).toBe('light'); + await fireEvent.click(screen.getByRole('button')); + expect(themeManager.value).toBe('dark'); + }); + + it('toggles theme from dark to light on click', async () => { + themeManager.setTheme('dark'); + render(ThemeSwitch, { context }); + await fireEvent.click(screen.getByRole('button')); + expect(themeManager.value).toBe('light'); + }); + + it('double click returns to original theme', async () => { + render(ThemeSwitch, { context }); + const btn = screen.getByRole('button'); + await fireEvent.click(btn); + await fireEvent.click(btn); + expect(themeManager.value).toBe('light'); + }); + }); +}); diff --git a/src/features/DisplayFont/ui/FontSampler/FontSampler.stories.svelte b/src/features/DisplayFont/ui/FontSampler/FontSampler.stories.svelte index 0238a83..ffc2a3f 100644 --- a/src/features/DisplayFont/ui/FontSampler/FontSampler.stories.svelte +++ b/src/features/DisplayFont/ui/FontSampler/FontSampler.stories.svelte @@ -35,7 +35,7 @@ const { Story } = defineMeta({ @@ -65,7 +59,7 @@ const stats = $derived([ group relative w-full h-full bg-paper dark:bg-dark-card - border border-black/5 dark:border-white/10 + border border-subtle hover:border-brand dark:hover:border-brand hover:shadow-brand/10 hover:shadow-[5px_5px_0px_0px] @@ -75,20 +69,20 @@ const stats = $derived([ min-h-60 rounded-none " - style:font-weight={fontWeight} + style:font-weight={typographySettingsStore.weight} >
- + {String(index + 1).padStart(2, '0')} @@ -100,14 +94,14 @@ const stats = $derived([ {#if fontType} - + {fontType} {/if} {#if providerBadge} - + {providerBadge} {/if} @@ -140,20 +134,20 @@ const stats = $derived([
- +
-
+
{#each stats as stat, i} - + {stat.label}:{stat.value} {#if i < stats.length - 1} diff --git a/src/features/GetFonts/api/filters/filters.ts b/src/features/GetFonts/api/filters/filters.ts index a576a1b..822c968 100644 --- a/src/features/GetFonts/api/filters/filters.ts +++ b/src/features/GetFonts/api/filters/filters.ts @@ -15,19 +15,29 @@ const PROXY_API_URL = 'https://api.glyphdiff.com/api/v1/filters' as const; * Filter metadata type from backend */ export interface FilterMetadata { - /** Filter ID (e.g., "providers", "categories", "subsets") */ + /** + * Filter ID (e.g., "providers", "categories", "subsets") + */ id: string; - /** Display name (e.g., "Font Providers", "Categories", "Character Subsets") */ + /** + * Display name (e.g., "Font Providers", "Categories", "Character Subsets") + */ name: string; - /** Filter description */ + /** + * Filter description + */ description: string; - /** Filter type */ + /** + * Filter type + */ type: 'enum' | 'string' | 'array'; - /** Available filter options */ + /** + * Available filter options + */ options: FilterOption[]; } @@ -35,16 +45,24 @@ export interface FilterMetadata { * Filter option type */ export interface FilterOption { - /** Option ID (e.g., "google", "serif", "latin") */ + /** + * Option ID (e.g., "google", "serif", "latin") + */ id: string; - /** Display name (e.g., "Google Fonts", "Serif", "Latin") */ + /** + * Display name (e.g., "Google Fonts", "Serif", "Latin") + */ name: string; - /** Option value (e.g., "google", "serif", "latin") */ + /** + * Option value (e.g., "google", "serif", "latin") + */ value: string; - /** Number of fonts with this value */ + /** + * Number of fonts with this value + */ count: number; } @@ -52,7 +70,9 @@ export interface FilterOption { * Proxy filters API response */ export interface ProxyFiltersResponse { - /** Array of filter metadata */ + /** + * Array of filter metadata + */ filters: FilterMetadata[]; } diff --git a/src/features/GetFonts/index.ts b/src/features/GetFonts/index.ts index 6b3dff3..a57bb5f 100644 --- a/src/features/GetFonts/index.ts +++ b/src/features/GetFonts/index.ts @@ -4,6 +4,7 @@ export { mapManagerToParams, } from './lib'; +export { filtersStore } from './model/state/filters.svelte'; export { filterManager } from './model/state/manager.svelte'; export { diff --git a/src/features/GetFonts/model/index.ts b/src/features/GetFonts/model/index.ts index 36844c9..2c8f3c8 100644 --- a/src/features/GetFonts/model/index.ts +++ b/src/features/GetFonts/model/index.ts @@ -1,15 +1,56 @@ export type { + /** + * Top-level configuration for all filters + */ FilterConfig, + /** + * Configuration for a single grouping of filter properties + */ FilterGroupConfig, } from './types/filter'; -export { filtersStore } from './state/filters.svelte'; -export { filterManager } from './state/manager.svelte'; - +/** + * Global reactive filter state + */ export { + /** + * Low-level property selection store + */ + filtersStore, +} from './state/filters.svelte'; + +/** + * Main filter controller + */ +export { + /** + * High-level manager for syncing search and filters + */ + filterManager, +} from './state/manager.svelte'; + +/** + * Sorting logic + */ +export { + /** + * Map of human-readable labels to API sort keys + */ SORT_MAP, + /** + * List of all available sort options for the UI + */ SORT_OPTIONS, + /** + * Valid sort key values + */ type SortApiValue, + /** + * UI model for a single sort option + */ type SortOption, + /** + * Reactive store for the current sort selection + */ sortStore, } from './store/sortStore.svelte'; diff --git a/src/features/GetFonts/model/state/filters.svelte.ts b/src/features/GetFonts/model/state/filters.svelte.ts index 055e0d0..27e4aba 100644 --- a/src/features/GetFonts/model/state/filters.svelte.ts +++ b/src/features/GetFonts/model/state/filters.svelte.ts @@ -32,13 +32,19 @@ import { * Provides reactive access to filter data */ class FiltersStore { - /** TanStack Query result state */ + /** + * TanStack Query result state + */ protected result = $state>({} as any); - /** TanStack Query observer instance */ + /** + * TanStack Query observer instance + */ protected observer: QueryObserver; - /** Shared query client */ + /** + * Shared query client + */ protected qc = queryClient; /** diff --git a/src/features/GetFonts/model/store/sortStore.svelte.ts b/src/features/GetFonts/model/store/sortStore.svelte.ts index a886339..0a090c5 100644 --- a/src/features/GetFonts/model/store/sortStore.svelte.ts +++ b/src/features/GetFonts/model/store/sortStore.svelte.ts @@ -21,17 +21,23 @@ function createSortStore(initial: SortOption = 'Popularity') { let current = $state(initial); return { - /** Current display label (e.g. 'Popularity') */ + /** + * Current display label (e.g. 'Popularity') + */ get value() { return current; }, - /** Mapped API value (e.g. 'popularity') */ + /** + * Mapped API value (e.g. 'popularity') + */ get apiValue(): SortApiValue { return SORT_MAP[current]; }, - /** Set the active sort option by its display label */ + /** + * Set the active sort option by its display label + */ set(option: SortOption) { current = option; }, diff --git a/src/features/GetFonts/model/types/filter.ts b/src/features/GetFonts/model/types/filter.ts index 41f3193..047c86a 100644 --- a/src/features/GetFonts/model/types/filter.ts +++ b/src/features/GetFonts/model/types/filter.ts @@ -1,12 +1,27 @@ import type { Property } from '$shared/lib'; export interface FilterGroupConfig { + /** + * Unique identifier for the filter group (e.g. 'categories') + */ id: string; + /** + * Human-readable label displayed in the UI header + */ label: string; + /** + * List of toggleable properties within this group + */ properties: Property[]; } export interface FilterConfig { + /** + * Optional string to filter results by name + */ queryValue?: string; + /** + * Collection of filter groups to display + */ groups: FilterGroupConfig[]; } diff --git a/src/features/GetFonts/ui/Filters/Filters.stories.svelte b/src/features/GetFonts/ui/Filters/Filters.stories.svelte new file mode 100644 index 0000000..45add6f --- /dev/null +++ b/src/features/GetFonts/ui/Filters/Filters.stories.svelte @@ -0,0 +1,26 @@ + + + + {#snippet template()} + + {/snippet} + diff --git a/src/features/GetFonts/ui/Filters/Filters.svelte.test.ts b/src/features/GetFonts/ui/Filters/Filters.svelte.test.ts new file mode 100644 index 0000000..c879c4d --- /dev/null +++ b/src/features/GetFonts/ui/Filters/Filters.svelte.test.ts @@ -0,0 +1,74 @@ +import { + filterManager, + filtersStore, +} from '$features/GetFonts'; +import { + render, + screen, +} from '@testing-library/svelte'; +import { vi } from 'vitest'; +import Filters from './Filters.svelte'; + +describe('Filters', () => { + beforeEach(() => { + // Clear groups and mock filtersStore to be empty so the auto-sync effect doesn't overwrite us + filterManager.setGroups([]); + vi.spyOn(filtersStore, 'filters', 'get').mockReturnValue([]); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('Rendering', () => { + it('renders nothing when filter groups are empty', () => { + const { container } = render(Filters); + // It might render an empty container if the component has one, but we expect no children + expect(container.firstChild?.childNodes.length ?? 0).toBe(0); + }); + + it('renders a label for each filter group', () => { + filterManager.setGroups([ + { id: 'cat', label: 'Categories', properties: [] }, + { id: 'prov', label: 'Font Providers', properties: [] }, + ]); + render(Filters); + expect(screen.getByText('Categories')).toBeInTheDocument(); + expect(screen.getByText('Font Providers')).toBeInTheDocument(); + }); + + it('renders filter properties within groups', () => { + filterManager.setGroups([ + { + id: 'cat', + label: 'Category', + properties: [ + { id: 'serif', name: 'Serif', value: 'serif', selected: false }, + { id: 'sans', name: 'Sans-Serif', value: 'sans-serif', selected: false }, + ], + }, + ]); + render(Filters); + expect(screen.getByText('Serif')).toBeInTheDocument(); + expect(screen.getByText('Sans-Serif')).toBeInTheDocument(); + }); + + it('renders multiple groups with their properties', () => { + filterManager.setGroups([ + { + id: 'cat', + label: 'Category', + properties: [{ id: 'mono', name: 'Monospace', value: 'monospace', selected: false }], + }, + { + id: 'prov', + label: 'Provider', + properties: [{ id: 'google', name: 'Google', value: 'google', selected: false }], + }, + ]); + render(Filters); + expect(screen.getByText('Monospace')).toBeInTheDocument(); + expect(screen.getByText('Google')).toBeInTheDocument(); + }); + }); +}); diff --git a/src/features/GetFonts/ui/FiltersControl/FilterControls.stories.svelte b/src/features/GetFonts/ui/FiltersControl/FilterControls.stories.svelte new file mode 100644 index 0000000..e1643a6 --- /dev/null +++ b/src/features/GetFonts/ui/FiltersControl/FilterControls.stories.svelte @@ -0,0 +1,39 @@ + + + + {#snippet template()} + + + + {/snippet} + + + + {#snippet template()} + +
+ +
+
+ {/snippet} +
diff --git a/src/features/GetFonts/ui/FiltersControl/FilterControls.svelte b/src/features/GetFonts/ui/FiltersControl/FilterControls.svelte index 242a8bc..5187324 100644 --- a/src/features/GetFonts/ui/FiltersControl/FilterControls.svelte +++ b/src/features/GetFonts/ui/FiltersControl/FilterControls.svelte @@ -6,7 +6,7 @@ + + + {#snippet template()} + +
+ +
+
+ {/snippet} +
+ + + {#snippet template()} + +
+
+
+ {/snippet} +
diff --git a/src/features/SetupFont/ui/TypographyMenu/TypographyMenu.svelte b/src/features/SetupFont/ui/TypographyMenu/TypographyMenu.svelte index f49a28a..1e1cba0 100644 --- a/src/features/SetupFont/ui/TypographyMenu/TypographyMenu.svelte +++ b/src/features/SetupFont/ui/TypographyMenu/TypographyMenu.svelte @@ -1,13 +1,17 @@ {#if !hidden} - {#if responsive.isMobile} - + {#if responsive.isMobileOrTablet} + {#snippet child({ props })} - + {/snippet} { escapeKeydownBehavior="close" > -
+
CONTROLS @@ -133,7 +127,7 @@ $effect(() => {
- {#each controlManager.controls as control (control.id)} + {#each typographySettingsStore.controls as control (control.id)} { class={cn( 'flex items-center gap-1 md:gap-2 p-1.5 md:p-2', 'bg-surface/95 dark:bg-dark-bg/95 backdrop-blur-xl', - 'border border-black/5 dark:border-white/10', + 'border border-subtle', 'shadow-[0_20px_40px_-10px_rgba(0,0,0,0.1)]', 'rounded-none ring-1 ring-black/5 dark:ring-white/5', )} > -
+
- {#each controlManager.controls as control, i (control.id)} + {#each typographySettingsStore.controls as control, i (control.id)} {#if i > 0}
{/if} diff --git a/src/main.ts b/src/main.ts index c353493..fc2eaa3 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,3 +1,9 @@ +/** + * Application entry point + * + * Mounts the main App component to the DOM and initializes + * global styles. + */ import App from '$app/App.svelte'; import { mount } from 'svelte'; import '$app/styles/app.css'; diff --git a/src/routes/Page.svelte b/src/routes/Page.svelte index 19c108d..00481d6 100644 --- a/src/routes/Page.svelte +++ b/src/routes/Page.svelte @@ -3,10 +3,7 @@ Description: The main page component of the application. --> @@ -15,11 +12,7 @@ import { fade } from 'svelte/transition'; class="h-full flex flex-col gap-3 sm:gap-4" in:fade={{ duration: 500, delay: 150, easing: cubicIn }} > -
+
-
-
- -
diff --git a/src/shared/api/api.ts b/src/shared/api/api.ts index 40e12a8..3b17ea4 100644 --- a/src/shared/api/api.ts +++ b/src/shared/api/api.ts @@ -41,10 +41,14 @@ export class ApiError extends Error { * @param response - Original fetch Response object */ constructor( - /** HTTP status code */ + /** + * HTTP status code + */ public status: number, message: string, - /** Original Response object for inspection */ + /** + * Original Response object for inspection + */ public response?: Response, ) { super(message); diff --git a/src/shared/api/queryClient.ts b/src/shared/api/queryClient.ts index f6a25aa..cf16c92 100644 --- a/src/shared/api/queryClient.ts +++ b/src/shared/api/queryClient.ts @@ -15,15 +15,25 @@ import { QueryClient } from '@tanstack/query-core'; export const queryClient = new QueryClient({ defaultOptions: { queries: { - /** Data remains fresh for 5 minutes after fetch */ + /** + * Data remains fresh for 5 minutes after fetch + */ staleTime: 5 * 60 * 1000, - /** Unused cache entries are removed after 10 minutes */ + /** + * Unused cache entries are removed after 10 minutes + */ gcTime: 10 * 60 * 1000, - /** Don't refetch when window regains focus */ + /** + * Don't refetch when window regains focus + */ refetchOnWindowFocus: false, - /** Refetch on mount if data is stale */ + /** + * Refetch on mount if data is stale + */ refetchOnMount: true, - /** Retry failed requests up to 3 times */ + /** + * Retry failed requests up to 3 times + */ retry: 3, /** * Exponential backoff for retries diff --git a/src/shared/api/queryKeys.ts b/src/shared/api/queryKeys.ts index 93e5661..e9a4460 100644 --- a/src/shared/api/queryKeys.ts +++ b/src/shared/api/queryKeys.ts @@ -3,21 +3,35 @@ * Ensures consistent serialization for batch requests by sorting IDs. */ export const fontKeys = { - /** Base key for all font queries */ + /** + * Base key for all font queries + */ all: ['fonts'] as const, - /** Keys for font list queries */ + /** + * Keys for font list queries + */ lists: () => [...fontKeys.all, 'list'] as const, - /** Specific font list key with filter parameters */ + /** + * Specific font list key with filter parameters + */ list: (params: object) => [...fontKeys.lists(), params] as const, - /** Keys for font batch queries */ + /** + * Keys for font batch queries + */ batches: () => [...fontKeys.all, 'batch'] as const, - /** Specific batch key, sorted for stability */ + /** + * Specific batch key, sorted for stability + */ batch: (ids: string[]) => [...fontKeys.batches(), [...ids].sort()] as const, - /** Keys for font detail queries */ + /** + * Keys for font detail queries + */ details: () => [...fontKeys.all, 'detail'] as const, - /** Specific font detail key by ID */ + /** + * Specific font detail key by ID + */ detail: (id: string) => [...fontKeys.details(), id] as const, } as const; diff --git a/src/shared/assets/G.svg b/src/shared/assets/G.svg new file mode 100644 index 0000000..80b9576 --- /dev/null +++ b/src/shared/assets/G.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/shared/assets/GD.svg b/src/shared/assets/GD.svg deleted file mode 100644 index 510280c..0000000 --- a/src/shared/assets/GD.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/src/shared/assets/favicon.svg b/src/shared/assets/favicon.svg deleted file mode 100644 index cc5dc66..0000000 --- a/src/shared/assets/favicon.svg +++ /dev/null @@ -1 +0,0 @@ -svelte-logo \ No newline at end of file diff --git a/src/shared/lib/helpers/CharacterComparisonEngine/CharacterComparisonEngine.svelte.ts b/src/shared/lib/helpers/CharacterComparisonEngine/CharacterComparisonEngine.svelte.ts index 2f36bf1..c61d58c 100644 --- a/src/shared/lib/helpers/CharacterComparisonEngine/CharacterComparisonEngine.svelte.ts +++ b/src/shared/lib/helpers/CharacterComparisonEngine/CharacterComparisonEngine.svelte.ts @@ -12,20 +12,37 @@ import { * each font's actual advance widths independently. */ export interface ComparisonLine { - /** Full text of this line as returned by pretext. */ + /** + * Full text of this line as returned by pretext. + */ text: string; - /** Rendered width of this line in pixels — maximum across font A and font B. */ + /** + * Rendered width of this line in pixels — maximum across font A and font B. + */ width: number; + /** + * Individual character metadata for both fonts in this line + */ chars: Array<{ - /** The grapheme cluster string (may be >1 code unit for emoji, etc.). */ + /** + * The grapheme cluster string (may be >1 code unit for emoji, etc.). + */ char: string; - /** X offset from the start of the line in font A, in pixels. */ + /** + * X offset from the start of the line in font A, in pixels. + */ xA: number; - /** Advance width of this grapheme in font A, in pixels. */ + /** + * Advance width of this grapheme in font A, in pixels. + */ widthA: number; - /** X offset from the start of the line in font B, in pixels. */ + /** + * X offset from the start of the line in font B, in pixels. + */ xB: number; - /** Advance width of this grapheme in font B, in pixels. */ + /** + * Advance width of this grapheme in font B, in pixels. + */ widthB: number; }>; } @@ -34,9 +51,13 @@ export interface ComparisonLine { * Aggregated output of a dual-font layout pass. */ export interface ComparisonResult { - /** Per-line grapheme data for both fonts. Empty when input text is empty. */ + /** + * Per-line grapheme data for both fonts. Empty when input text is empty. + */ lines: ComparisonLine[]; - /** Total height in pixels. Equals `lines.length * lineHeight` (pretext guarantee). */ + /** + * Total height in pixels. Equals `lines.length * lineHeight` (pretext guarantee). + */ totalHeight: number; } @@ -74,11 +95,13 @@ export class CharacterComparisonEngine { #lastText = ''; #lastFontA = ''; #lastFontB = ''; + #lastSpacing = 0; + #lastSize = 0; // Cached layout results #lastWidth = -1; #lastLineHeight = -1; - #lastResult: ComparisonResult | null = null; + #lastResult = $state(null); constructor(locale?: string) { this.#segmenter = new Intl.Segmenter(locale, { granularity: 'grapheme' }); @@ -95,6 +118,8 @@ export class CharacterComparisonEngine { * @param fontB CSS font string for the second font: `"weight sizepx \"family\""`. * @param width Available line width in pixels. * @param lineHeight Line height in pixels (passed directly to pretext). + * @param spacing Letter spacing in em (from typography settings). + * @param size Current font size in pixels (used to convert spacing em to px). * @returns Per-line grapheme data for both fonts. Empty `lines` when `text` is empty. */ layout( @@ -103,12 +128,21 @@ export class CharacterComparisonEngine { fontB: string, width: number, lineHeight: number, + spacing: number = 0, + size: number = 16, ): ComparisonResult { if (!text) { return { lines: [], totalHeight: 0 }; } - const isFontChange = text !== this.#lastText || fontA !== this.#lastFontA || fontB !== this.#lastFontB; + const spacingPx = spacing * size; + + const isFontChange = text !== this.#lastText + || fontA !== this.#lastFontA + || fontB !== this.#lastFontB + || spacing !== this.#lastSpacing + || size !== this.#lastSize; + const isLayoutChange = width !== this.#lastWidth || lineHeight !== this.#lastLineHeight; if (!isFontChange && !isLayoutChange && this.#lastResult) { @@ -119,11 +153,13 @@ export class CharacterComparisonEngine { if (isFontChange) { this.#preparedA = prepareWithSegments(text, fontA); this.#preparedB = prepareWithSegments(text, fontB); - this.#unifiedPrepared = this.#createUnifiedPrepared(this.#preparedA, this.#preparedB); + this.#unifiedPrepared = this.#createUnifiedPrepared(this.#preparedA, this.#preparedB, spacingPx); this.#lastText = text; this.#lastFontA = fontA; this.#lastFontB = fontB; + this.#lastSpacing = spacing; + this.#lastSize = size; } if (!this.#unifiedPrepared || !this.#preparedA || !this.#preparedB) { @@ -150,9 +186,10 @@ export class CharacterComparisonEngine { for (let sIdx = start.segmentIndex; sIdx <= end.segmentIndex; sIdx++) { const segmentText = this.#preparedA!.segments[sIdx]; - if (segmentText === undefined) continue; + if (segmentText === undefined) { + continue; + } - // PERFORMANCE: Reuse segmenter results if possible, but for now just optimize the loop const graphemes = Array.from(this.#segmenter.segment(segmentText), s => s.segment); const advA = intA.breakableFitAdvances[sIdx]; @@ -163,8 +200,12 @@ export class CharacterComparisonEngine { for (let gIdx = gStart; gIdx < gEnd; gIdx++) { const char = graphemes[gIdx]; - const wA = advA != null ? advA[gIdx]! : intA.widths[sIdx]!; - const wB = advB != null ? advB[gIdx]! : intB.widths[sIdx]!; + let wA = advA != null ? advA[gIdx]! : intA.widths[sIdx]!; + let wB = advB != null ? advB[gIdx]! : intB.widths[sIdx]!; + + // Apply letter spacing (tracking) to the width of each character + wA += spacingPx; + wB += spacingPx; chars.push({ char, @@ -196,73 +237,107 @@ export class CharacterComparisonEngine { } /** - * Calculates character proximity and direction relative to a slider position. + * Calculates character states for an entire line in a single sequential pass. * - * Uses the most recent `layout()` result — must be called after `layout()`. - * No DOM calls are made; all geometry is derived from cached layout data. + * Walks characters left-to-right, accumulating the running x position using + * each character's actual rendered width: `widthB` for already-morphed characters + * (isPast=true) and `widthA` for upcoming ones. This ensures thresholds stay + * aligned with the visual DOM layout even when the two fonts have different widths. * - * @param lineIndex Zero-based index of the line within the last layout result. - * @param charIndex Zero-based index of the character within that line's `chars` array. + * @param line A single laid-out line from the last layout result. * @param sliderPos Current slider position as a percentage (0–100) of `containerWidth`. - * @param containerWidth Total container width in pixels, used to convert pixel offsets to %. - * @returns `proximity` in [0, 1] (1 = slider exactly over char center) and - * `isPast` (true when the slider has already passed the char center). + * @param containerWidth Total container width in pixels. + * @returns Per-character `proximity` and `isPast` in the same order as `line.chars`. */ - getCharState( - lineIndex: number, - charIndex: number, + getLineCharStates( + line: ComparisonLine, sliderPos: number, containerWidth: number, - ): { proximity: number; isPast: boolean } { - if (!this.#lastResult || !this.#lastResult.lines[lineIndex]) { - return { proximity: 0, isPast: false }; + ): Array<{ proximity: number; isPast: boolean }> { + if (!line) { + return []; } - - const line = this.#lastResult.lines[lineIndex]; - const char = line.chars[charIndex]; - - if (!char) return { proximity: 0, isPast: false }; - - // Center the comparison on the unified width - // In the UI, lines are centered. So we need to calculate the global X. - const lineXOffset = (containerWidth - line.width) / 2; - const charCenterX = lineXOffset + char.xA + (char.widthA / 2); - - const charGlobalPercent = (charCenterX / containerWidth) * 100; - - const distance = Math.abs(sliderPos - charGlobalPercent); + const chars = line.chars; + const n = chars.length; + const sliderX = (sliderPos / 100) * containerWidth; const range = 5; - const proximity = Math.max(0, 1 - distance / range); - const isPast = sliderPos > charGlobalPercent; - - return { proximity, isPast }; + // Prefix sums of widthA (left chars will be past → use widthA). + // Suffix sums of widthB (right chars will not be past → use widthB). + // This lets us compute, for each char i, what the total line width and + // char center would be at the exact moment the slider crosses that char: + // left side (0..i-1) already past → font A widths + // right side (i+1..n-1) not yet past → font B widths + const prefA = new Float64Array(n + 1); + const sufB = new Float64Array(n + 1); + for (let i = 0; i < n; i++) { + prefA[i + 1] = prefA[i] + chars[i].widthA; + } + for (let i = n - 1; i >= 0; i--) { + sufB[i] = sufB[i + 1] + chars[i].widthB; + } + // Per-char threshold: slider x at which this char should toggle isPast. + const thresholds = new Float64Array(n); + for (let i = 0; i < n; i++) { + const totalWidth = prefA[i] + chars[i].widthA + sufB[i + 1]; + const xOffset = (containerWidth - totalWidth) / 2; + thresholds[i] = xOffset + prefA[i] + chars[i].widthA / 2; + } + // Determine isPast for each char at the current slider position. + const isPastArr = new Uint8Array(n); + for (let i = 0; i < n; i++) { + isPastArr[i] = sliderX > thresholds[i] ? 1 : 0; + } + // Compute visual positions based on actual rendered widths (font A if past, B if not). + const totalRendered = chars.reduce((s, c, i) => s + (isPastArr[i] ? c.widthA : c.widthB), 0); + const xOffset = (containerWidth - totalRendered) / 2; + let currentX = xOffset; + return chars.map((char, i) => { + const isPast = isPastArr[i] === 1; + const charWidth = isPast ? char.widthA : char.widthB; + const visualCenter = currentX + charWidth / 2; + const charGlobalPercent = (visualCenter / containerWidth) * 100; + const distance = Math.abs(sliderPos - charGlobalPercent); + const proximity = Math.max(0, 1 - distance / range); + currentX += charWidth; + return { proximity, isPast }; + }); } /** * Internal helper to merge two prepared texts into a "worst-case" unified version */ - #createUnifiedPrepared(a: PreparedTextWithSegments, b: PreparedTextWithSegments): PreparedTextWithSegments { + #createUnifiedPrepared( + a: PreparedTextWithSegments, + b: PreparedTextWithSegments, + spacingPx: number = 0, + ): PreparedTextWithSegments { // Cast to `any`: accessing internal numeric arrays not in the public type signature. const intA = a as any; const intB = b as any; const unified = { ...intA }; - unified.widths = intA.widths.map((w: number, i: number) => Math.max(w, intB.widths[i])); + unified.widths = intA.widths.map((w: number, i: number) => Math.max(w, intB.widths[i]) + spacingPx); unified.lineEndFitAdvances = intA.lineEndFitAdvances.map((w: number, i: number) => - Math.max(w, intB.lineEndFitAdvances[i]) + Math.max(w, intB.lineEndFitAdvances[i]) + spacingPx ); unified.lineEndPaintAdvances = intA.lineEndPaintAdvances.map((w: number, i: number) => - Math.max(w, intB.lineEndPaintAdvances[i]) + Math.max(w, intB.lineEndPaintAdvances[i]) + spacingPx ); unified.breakableFitAdvances = intA.breakableFitAdvances.map((advA: number[] | null, i: number) => { const advB = intB.breakableFitAdvances[i]; - if (!advA && !advB) return null; - if (!advA) return advB; - if (!advB) return advA; + if (!advA && !advB) { + return null; + } + if (!advA) { + return advB.map((w: number) => w + spacingPx); + } + if (!advB) { + return advA.map((w: number) => w + spacingPx); + } - return advA.map((w: number, j: number) => Math.max(w, advB[j])); + return advA.map((w: number, j: number) => Math.max(w, advB[j]) + spacingPx); }); return unified; diff --git a/src/shared/lib/helpers/CharacterComparisonEngine/CharacterComparisonEngine.test.ts b/src/shared/lib/helpers/CharacterComparisonEngine/CharacterComparisonEngine.test.ts index 28c6c4c..09a4f5f 100644 --- a/src/shared/lib/helpers/CharacterComparisonEngine/CharacterComparisonEngine.test.ts +++ b/src/shared/lib/helpers/CharacterComparisonEngine/CharacterComparisonEngine.test.ts @@ -28,8 +28,6 @@ describe('CharacterComparisonEngine', () => { engine = new CharacterComparisonEngine(); }); - // --- layout() --- - it('returns empty result for empty string', () => { const result = engine.layout('', '400 16px "FontA"', '400 16px "FontB"', 500, 20); expect(result.lines).toHaveLength(0); @@ -111,58 +109,52 @@ describe('CharacterComparisonEngine', () => { expect(r2).not.toBe(r1); }); - // --- getCharState() --- - - it('getCharState returns proximity 1 when slider is exactly over char center', () => { - // 'A' only: FontA width=10. Container=500px. Line centered. - // lineXOffset = (500 - maxWidth) / 2. maxWidth = max(10, 15) = 15 (FontB is wider). - // charCenterX = lineXOffset + xA + widthA/2. - // Using xA=0, widthA=10: charCenterX = (500-15)/2 + 0 + 5 = 247.5 + 5 = 252.5 - // charGlobalPercent = (252.5 / 500) * 100 = 50.5 - // distance = |50.5 - 50.5| = 0 => proximity = 1 + it('getLineCharStates returns proximity 1 when slider is exactly over char center', () => { const containerWidth = 500; - engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', containerWidth, 20); - // Recalculate expected percent manually: - const lineWidth = Math.max(FONT_A_WIDTH, FONT_B_WIDTH); // 15 (unified worst-case) - const lineXOffset = (containerWidth - lineWidth) / 2; - const charCenterX = lineXOffset + 0 + FONT_A_WIDTH / 2; - const charPercent = (charCenterX / containerWidth) * 100; + const result = engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', containerWidth, 20); + // Single char: no neighbors → totalWidth = widthA, threshold = containerWidth/2. + // When isPast=false, visual center = (containerWidth - widthB)/2 + widthB/2 = containerWidth/2. + // So proximity=1 at exactly 50%. + const charPercent = 50; - const state = engine.getCharState(0, 0, charPercent, containerWidth); - expect(state.proximity).toBe(1); - expect(state.isPast).toBe(false); + const states = engine.getLineCharStates(result.lines[0], charPercent, containerWidth); + expect(states[0]?.proximity).toBe(1); + expect(states[0]?.isPast).toBe(false); }); - it('getCharState returns proximity 0 when slider is far from char', () => { - engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20); - // Slider at 0%, char is near 50% — distance > 5 range => proximity = 0 - const state = engine.getCharState(0, 0, 0, 500); - expect(state.proximity).toBe(0); + it('getLineCharStates returns proximity 0 when slider is far from char', () => { + const result = engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20); + const states = engine.getLineCharStates(result.lines[0], 0, 500); + expect(states[0]?.proximity).toBe(0); }); - it('getCharState isPast is true when slider has passed char center', () => { - engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20); - const state = engine.getCharState(0, 0, 100, 500); - expect(state.isPast).toBe(true); + it('getLineCharStates isPast is true when slider has passed char center', () => { + const result = engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20); + const states = engine.getLineCharStates(result.lines[0], 100, 500); + expect(states[0]?.isPast).toBe(true); }); - it('getCharState returns safe default for out-of-range lineIndex', () => { - engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20); - const state = engine.getCharState(99, 0, 50, 500); - expect(state.proximity).toBe(0); - expect(state.isPast).toBe(false); + it('getLineCharStates returns empty array for out-of-range lineIndex', () => { + const result = engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20); + // Passing an undefined object because the index doesn't exist. + const states = engine.getLineCharStates(result.lines[99], 50, 500); + expect(states).toEqual([]); }); - it('getCharState returns safe default for out-of-range charIndex', () => { - engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20); - const state = engine.getCharState(0, 99, 50, 500); - expect(state.proximity).toBe(0); - expect(state.isPast).toBe(false); + it('getLineCharStates returns empty array before layout() has been called', () => { + // Passing an undefined object because layout() hasn't been called. + const states = engine.getLineCharStates(undefined as any, 50, 500); + expect(states).toEqual([]); }); - it('getCharState returns safe default before layout() has been called', () => { - const state = engine.getCharState(0, 0, 50, 500); - expect(state.proximity).toBe(0); - expect(state.isPast).toBe(false); + it('getLineCharStates returns safe defaults for all chars', () => { + const result = engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20); + const states = engine.getLineCharStates(result.lines[0], 50, 500); + expect(states.length).toBeGreaterThan(0); + for (const s of states) { + expect(s.proximity).toBeGreaterThanOrEqual(0); + expect(s.proximity).toBeLessThanOrEqual(1); + expect(typeof s.isPast).toBe('boolean'); + } }); }); diff --git a/src/shared/lib/helpers/TextLayoutEngine/TextLayoutEngine.svelte.ts b/src/shared/lib/helpers/TextLayoutEngine/TextLayoutEngine.svelte.ts index 048bf0f..b581905 100644 --- a/src/shared/lib/helpers/TextLayoutEngine/TextLayoutEngine.svelte.ts +++ b/src/shared/lib/helpers/TextLayoutEngine/TextLayoutEngine.svelte.ts @@ -10,16 +10,29 @@ import { * sequences and combining characters each produce exactly one entry. */ export interface LayoutLine { - /** Full text of this line as returned by pretext. */ + /** + * Full text of this line as returned by pretext. + */ text: string; - /** Rendered width of this line in pixels. */ + /** + * Rendered width of this line in pixels. + */ width: number; + /** + * Individual character metadata for this line + */ chars: Array<{ - /** The grapheme cluster string (may be >1 code unit for emoji, etc.). */ + /** + * The grapheme cluster string (may be >1 code unit for emoji, etc.). + */ char: string; - /** X offset from the start of the line, in pixels. */ + /** + * X offset from the start of the line, in pixels. + */ x: number; - /** Advance width of this grapheme, in pixels. */ + /** + * Advance width of this grapheme, in pixels. + */ width: number; }>; } @@ -28,9 +41,13 @@ export interface LayoutLine { * Aggregated output of a single-font layout pass. */ export interface LayoutResult { - /** Per-line grapheme data. Empty when input text is empty. */ + /** + * Per-line grapheme data. Empty when input text is empty. + */ lines: LayoutLine[]; - /** Total height in pixels. Equals `lines.length * lineHeight` (pretext guarantee). */ + /** + * Total height in pixels. Equals `lines.length * lineHeight` (pretext guarantee). + */ totalHeight: number; } @@ -65,7 +82,9 @@ export class TextLayoutEngine { */ #segmenter: Intl.Segmenter; - /** @param locale BCP 47 language tag passed to Intl.Segmenter. Defaults to the runtime locale. */ + /** + * @param locale BCP 47 language tag passed to Intl.Segmenter. Defaults to the runtime locale. + */ constructor(locale?: string) { this.#segmenter = new Intl.Segmenter(locale, { granularity: 'grapheme' }); } @@ -108,7 +127,9 @@ export class TextLayoutEngine { // Both cursors are grapheme-level: start is inclusive, end is exclusive. for (let sIdx = start.segmentIndex; sIdx <= end.segmentIndex; sIdx++) { const segmentText = prepared.segments[sIdx]; - if (segmentText === undefined) continue; + if (segmentText === undefined) { + continue; + } const graphemes = Array.from(this.#segmenter.segment(segmentText), s => s.segment); const advances = breakableFitAdvances[sIdx]; diff --git a/src/shared/lib/helpers/createDebouncedState/createDebouncedState.svelte.ts b/src/shared/lib/helpers/createDebouncedState/createDebouncedState.svelte.ts index 93840cc..7115d31 100644 --- a/src/shared/lib/helpers/createDebouncedState/createDebouncedState.svelte.ts +++ b/src/shared/lib/helpers/createDebouncedState/createDebouncedState.svelte.ts @@ -32,7 +32,9 @@ export function createDebouncedState(initialValue: T, wait: number = 300) { }, wait); return { - /** Current value with immediate updates (for UI binding) */ + /** + * Current value with immediate updates (for UI binding) + */ get immediate() { return immediate; }, @@ -41,7 +43,9 @@ export function createDebouncedState(initialValue: T, wait: number = 300) { // Manually trigger the debounce on write updateDebounced(value); }, - /** Current value with debounced updates (for logic/operations) */ + /** + * Current value with debounced updates (for logic/operations) + */ get debounced() { return debounced; }, diff --git a/src/shared/lib/helpers/createEntityStore/createEntityStore.svelte.ts b/src/shared/lib/helpers/createEntityStore/createEntityStore.svelte.ts index c304ab2..3977f7f 100644 --- a/src/shared/lib/helpers/createEntityStore/createEntityStore.svelte.ts +++ b/src/shared/lib/helpers/createEntityStore/createEntityStore.svelte.ts @@ -28,7 +28,9 @@ import { SvelteMap } from 'svelte/reactivity'; * Base entity interface requiring an ID field */ export interface Entity { - /** Unique identifier for the entity */ + /** + * Unique identifier for the entity + */ id: string; } @@ -39,7 +41,9 @@ export interface Entity { * triggers updates when entities are added, removed, or modified. */ export class EntityStore { - /** Reactive map of entities keyed by ID */ + /** + * Reactive map of entities keyed by ID + */ #entities = new SvelteMap(); /** diff --git a/src/shared/lib/helpers/createFilter/createFilter.svelte.ts b/src/shared/lib/helpers/createFilter/createFilter.svelte.ts index 2787482..d9e523d 100644 --- a/src/shared/lib/helpers/createFilter/createFilter.svelte.ts +++ b/src/shared/lib/helpers/createFilter/createFilter.svelte.ts @@ -29,13 +29,21 @@ * @template TValue - The type of the property value (typically string) */ export interface Property { - /** Unique identifier for the property */ + /** + * Unique string identifier for the filterable property + */ id: string; - /** Human-readable display name */ + /** + * Human-readable label for UI display + */ name: string; - /** Underlying value for filtering logic */ + /** + * Underlying machine-readable value used for filtering logic + */ value: TValue; - /** Whether the property is currently selected */ + /** + * Current selection status (reactive) + */ selected?: boolean; } @@ -45,7 +53,9 @@ export interface Property { * @template TValue - The type of property values */ export interface FilterModel { - /** Array of filterable properties */ + /** + * Collection of properties that can be toggled in this filter + */ properties: Property[]; } diff --git a/src/shared/lib/helpers/createPersistentStore/createPersistentStore.test.ts b/src/shared/lib/helpers/createPersistentStore/createPersistentStore.test.ts index 9cbdac3..7435a2a 100644 --- a/src/shared/lib/helpers/createPersistentStore/createPersistentStore.test.ts +++ b/src/shared/lib/helpers/createPersistentStore/createPersistentStore.test.ts @@ -1,4 +1,6 @@ -/** @vitest-environment jsdom */ +/** + * @vitest-environment jsdom + */ import { afterEach, beforeEach, diff --git a/src/shared/lib/helpers/createPerspectiveManager/createPerspectiveManager.svelte.ts b/src/shared/lib/helpers/createPerspectiveManager/createPerspectiveManager.svelte.ts index 4ed4416..5c15c7a 100644 --- a/src/shared/lib/helpers/createPerspectiveManager/createPerspectiveManager.svelte.ts +++ b/src/shared/lib/helpers/createPerspectiveManager/createPerspectiveManager.svelte.ts @@ -32,19 +32,33 @@ import { Spring } from 'svelte/motion'; * Configuration options for perspective effects */ export interface PerspectiveConfig { - /** Z-axis translation per level in pixels */ + /** + * Z-axis translation per level in pixels + */ depthStep?: number; - /** Scale reduction per level (0-1) */ + /** + * Scale reduction per level (0-1) + */ scaleStep?: number; - /** Blur amount per level in pixels */ + /** + * Blur amount per level in pixels + */ blurStep?: number; - /** Opacity reduction per level (0-1) */ + /** + * Opacity reduction per level (0-1) + */ opacityStep?: number; - /** Parallax movement intensity per level */ + /** + * Parallax movement intensity per level + */ parallaxIntensity?: number; - /** Horizontal offset - positive for right, negative for left */ + /** + * Horizontal offset - positive for right, negative for left + */ horizontalOffset?: number; - /** Layout mode: 'center' for centered, 'split' for side-by-side */ + /** + * Layout mode: 'center' for centered, 'split' for side-by-side + */ layoutMode?: 'center' | 'split'; } diff --git a/src/shared/lib/helpers/createResponsiveManager/createResponsiveManager.svelte.ts b/src/shared/lib/helpers/createResponsiveManager/createResponsiveManager.svelte.ts index 413b848..3521aab 100644 --- a/src/shared/lib/helpers/createResponsiveManager/createResponsiveManager.svelte.ts +++ b/src/shared/lib/helpers/createResponsiveManager/createResponsiveManager.svelte.ts @@ -39,15 +39,25 @@ * Customize to match your design system's breakpoints. */ export interface Breakpoints { - /** Mobile devices - default 640px */ + /** + * Mobile devices - default 640px + */ mobile: number; - /** Tablet portrait - default 768px */ + /** + * Tablet portrait - default 768px + */ tabletPortrait: number; - /** Tablet landscape - default 1024px */ + /** + * Tablet landscape - default 1024px + */ tablet: number; - /** Desktop - default 1280px */ + /** + * Desktop - default 1280px + */ desktop: number; - /** Large desktop - default 1536px */ + /** + * Large desktop - default 1536px + */ desktopLarge: number; } @@ -140,7 +150,9 @@ export function createResponsiveManager(customBreakpoints?: Partial * @returns Cleanup function to remove listeners */ function init() { - if (typeof window === 'undefined') return; + if (typeof window === 'undefined') { + return; + } const handleResize = () => { width = window.innerWidth; @@ -206,66 +218,108 @@ export function createResponsiveManager(customBreakpoints?: Partial ); return { - /** Viewport width in pixels */ + /** + * Current viewport width in pixels (reactive) + */ get width() { return width; }, - /** Viewport height in pixels */ + /** + * Current viewport height in pixels (reactive) + */ get height() { return height; }, - // Standard breakpoints + /** + * True if viewport width is below the mobile threshold + */ get isMobile() { return isMobile; }, + /** + * True if viewport width is between mobile and tablet portrait thresholds + */ get isTabletPortrait() { return isTabletPortrait; }, + /** + * True if viewport width is between tablet portrait and desktop thresholds + */ get isTablet() { return isTablet; }, + /** + * True if viewport width is between desktop and large desktop thresholds + */ get isDesktop() { return isDesktop; }, + /** + * True if viewport width is at or above the large desktop threshold + */ get isDesktopLarge() { return isDesktopLarge; }, - // Convenience groupings + /** + * True if viewport width is below the desktop threshold + */ get isMobileOrTablet() { return isMobileOrTablet; }, + /** + * True if viewport width is at or above the tablet portrait threshold + */ get isTabletOrDesktop() { return isTabletOrDesktop; }, - // Orientation + /** + * Current screen orientation (portrait | landscape) + */ get orientation() { return orientation; }, + /** + * True if screen height is greater than width + */ get isPortrait() { return isPortrait; }, + /** + * True if screen width is greater than height + */ get isLandscape() { return isLandscape; }, - // Device capabilities + /** + * True if the device supports touch interaction + */ get isTouchDevice() { return isTouchDevice; }, - // Current breakpoint + /** + * Name of the currently active breakpoint (reactive) + */ get currentBreakpoint() { return currentBreakpoint; }, - // Methods + /** + * Initialization function to start event listeners + */ init, + /** + * Helper to check for custom width ranges + */ matches, - // Breakpoint values (for custom logic) + /** + * Underlying breakpoint pixel values + */ breakpoints, }; } diff --git a/src/shared/lib/helpers/createTypographyControl/createTypographyControl.svelte.ts b/src/shared/lib/helpers/createTypographyControl/createTypographyControl.svelte.ts index 10df8a7..8d225ce 100644 --- a/src/shared/lib/helpers/createTypographyControl/createTypographyControl.svelte.ts +++ b/src/shared/lib/helpers/createTypographyControl/createTypographyControl.svelte.ts @@ -34,13 +34,21 @@ import { * Defines the bounds and stepping behavior for a control */ export interface ControlDataModel { - /** Current numeric value */ + /** + * Initial or current numeric value + */ value: number; - /** Minimum allowed value (inclusive) */ + /** + * Lower inclusive bound + */ min: number; - /** Maximum allowed value (inclusive) */ + /** + * Upper inclusive bound + */ max: number; - /** Step size for increment/decrement operations */ + /** + * Precision for increment/decrement operations + */ step: number; } @@ -50,13 +58,21 @@ export interface ControlDataModel { * @template T - Type for the control identifier */ export interface ControlModel extends ControlDataModel { - /** Unique identifier for the control */ + /** + * Unique string identifier for the control + */ id: T; - /** ARIA label for the increase button */ + /** + * Label used by screen readers for the increase button + */ increaseLabel?: string; - /** ARIA label for the decrease button */ + /** + * Label used by screen readers for the decrease button + */ decreaseLabel?: string; - /** ARIA label for the control area */ + /** + * Overall label describing the control's purpose + */ controlLabel?: string; } @@ -109,8 +125,7 @@ export function createTypographyControl( return { /** - * Current control value (getter/setter) - * Setting automatically clamps to bounds and rounds to step precision + * Clamped and rounded control value (reactive) */ get value() { return value; @@ -122,27 +137,37 @@ export function createTypographyControl( } }, - /** Maximum allowed value */ + /** + * Upper limit for the control value + */ get max() { return max; }, - /** Minimum allowed value */ + /** + * Lower limit for the control value + */ get min() { return min; }, - /** Step increment size */ + /** + * Configured step increment + */ get step() { return step; }, - /** Whether the value is at or exceeds the maximum */ + /** + * True if current value is equal to or greater than max + */ get isAtMax() { return isAtMax; }, - /** Whether the value is at or below the minimum */ + /** + * True if current value is equal to or less than min + */ get isAtMin() { return isAtMin; }, diff --git a/src/shared/lib/helpers/createVirtualizer/createVirtualizer.svelte.ts b/src/shared/lib/helpers/createVirtualizer/createVirtualizer.svelte.ts index 301f1d6..d1faa3e 100644 --- a/src/shared/lib/helpers/createVirtualizer/createVirtualizer.svelte.ts +++ b/src/shared/lib/helpers/createVirtualizer/createVirtualizer.svelte.ts @@ -45,7 +45,9 @@ export interface VirtualItem { * Options are reactive - pass them through a function getter to enable updates. */ export interface VirtualizerOptions { - /** Total number of items in the data array */ + /** + * Total number of items in the underlying data array + */ count: number; /** * Function to estimate the size of an item at a given index. @@ -60,7 +62,10 @@ export interface VirtualizerOptions { * as fonts finish loading, eliminating the DOM-measurement snap on load. */ estimateSize: (index: number) => number; - /** Number of extra items to render outside viewport for smoother scrolling (default: 5) */ + /** + * Number of extra items to render outside viewport for smoother scrolling + * @default 5 + */ overscan?: number; /** * Function to get the key of an item at a given index. @@ -170,7 +175,9 @@ export function createVirtualizer( const { count, data } = options; // Implicit dependency const v = _version; - if (count === 0 || containerHeight === 0 || !data) return []; + if (count === 0 || containerHeight === 0 || !data) { + return []; + } const overscan = options.overscan ?? 5; @@ -251,15 +258,18 @@ export function createVirtualizer( // Calculate initial offset ONCE const getElementOffset = () => { const rect = node.getBoundingClientRect(); - return rect.top + window.scrollY; + const scrollY = typeof window !== 'undefined' ? window.scrollY : 0; + return rect.top + scrollY; }; let cachedOffsetTop = 0; let rafId: number | null = null; - containerHeight = window.innerHeight; + containerHeight = typeof window !== 'undefined' ? window.innerHeight : 0; const handleScroll = () => { - if (rafId !== null) return; + if (rafId !== null) { + return; + } rafId = requestAnimationFrame(() => { // Get current position of element relative to viewport @@ -318,7 +328,9 @@ export function createVirtualizer( }; const resizeObserver = new ResizeObserver(([entry]) => { - if (entry) containerHeight = entry.contentRect.height; + if (entry) { + containerHeight = entry.contentRect.height; + } }); node.addEventListener('scroll', handleScroll, { passive: true }); @@ -418,7 +430,9 @@ export function createVirtualizer( * ``` */ function scrollToIndex(index: number, align: 'start' | 'center' | 'end' | 'auto' = 'auto') { - if (!elementRef || index < 0 || index >= options.count) return; + if (!elementRef || index < 0 || index >= options.count) { + return; + } const itemStart = offsets[index]; const itemSize = measuredSizes[index] ?? options.estimateSize(index); @@ -426,16 +440,24 @@ export function createVirtualizer( const { useWindowScroll } = optionsGetter(); if (useWindowScroll) { - if (align === 'center') target = itemStart - window.innerHeight / 2 + itemSize / 2; - if (align === 'end') target = itemStart - window.innerHeight + itemSize; + if (align === 'center') { + target = itemStart - window.innerHeight / 2 + itemSize / 2; + } + if (align === 'end') { + target = itemStart - window.innerHeight + itemSize; + } // Add container offset to target to get absolute document position const absoluteTarget = target + elementOffsetTop; window.scrollTo({ top: absoluteTarget, behavior: 'smooth' }); } else { - if (align === 'center') target = itemStart - containerHeight / 2 + itemSize / 2; - if (align === 'end') target = itemStart - containerHeight + itemSize; + if (align === 'center') { + target = itemStart - containerHeight / 2 + itemSize / 2; + } + if (align === 'end') { + target = itemStart - containerHeight + itemSize; + } elementRef.scrollTo({ top: target, behavior: 'smooth' }); } @@ -464,27 +486,45 @@ export function createVirtualizer( } return { + /** + * Current vertical scroll position in pixels (reactive) + */ get scrollOffset() { return scrollOffset; }, + /** + * Measured height of the visible container area (reactive) + */ get containerHeight() { return containerHeight; }, - /** Computed array of visible items to render (reactive) */ + /** + * Computed array of visible items to render (reactive) + */ get items() { return items; }, - /** Total height of all items in pixels (reactive) */ + /** + * Total height of all items in pixels (reactive) + */ get totalSize() { return totalSize; }, - /** Svelte action for the scrollable container element */ + /** + * Svelte action for the scrollable container element + */ container, - /** Svelte action for measuring individual item elements */ + /** + * Svelte action for measuring individual item elements + */ measureElement, - /** Programmatic scroll method to scroll to a specific item */ + /** + * Programmatic scroll method to scroll to a specific item + */ scrollToIndex, - /** Programmatic scroll method to scroll to a specific pixel offset */ + /** + * Programmatic scroll method to scroll to a specific pixel offset + */ scrollToOffset, }; } diff --git a/src/shared/lib/helpers/createVirtualizer/createVirtualizer.test.ts b/src/shared/lib/helpers/createVirtualizer/createVirtualizer.test.ts index d78b551..faee1cd 100644 --- a/src/shared/lib/helpers/createVirtualizer/createVirtualizer.test.ts +++ b/src/shared/lib/helpers/createVirtualizer/createVirtualizer.test.ts @@ -1,4 +1,6 @@ -/** @vitest-environment jsdom */ +/** + * @vitest-environment jsdom + */ import { afterEach, describe, diff --git a/src/shared/lib/helpers/index.ts b/src/shared/lib/helpers/index.ts index 1580dcd..ba2bb77 100644 --- a/src/shared/lib/helpers/index.ts +++ b/src/shared/lib/helpers/index.ts @@ -22,59 +22,178 @@ * ``` */ +/** + * Filter management + */ export { + /** + * Reactive filter factory + */ createFilter, + /** + * Filter instance type + */ type Filter, + /** + * Initial state model + */ type FilterModel, + /** + * Filterable property definition + */ type Property, } from './createFilter/createFilter.svelte'; +/** + * Bounded numeric controls + */ export { + /** + * Base numeric configuration + */ type ControlDataModel, + /** + * Extended model with labels + */ type ControlModel, + /** + * Reactive control factory + */ createTypographyControl, + /** + * Control instance type + */ type TypographyControl, } from './createTypographyControl/createTypographyControl.svelte'; +/** + * List virtualization + */ export { + /** + * Reactive virtualizer factory + */ createVirtualizer, + /** + * Rendered item layout data + */ type VirtualItem, + /** + * Virtualizer instance type + */ type Virtualizer, + /** + * Configuration options + */ type VirtualizerOptions, } from './createVirtualizer/createVirtualizer.svelte'; -export { createDebouncedState } from './createDebouncedState/createDebouncedState.svelte'; - +/** + * UI State + */ export { + /** + * Immediate/debounced state factory + */ + createDebouncedState, +} from './createDebouncedState/createDebouncedState.svelte'; + +/** + * Entity collections + */ +export { + /** + * Reactive entity store factory + */ createEntityStore, + /** + * Base entity requirement + */ type Entity, + /** + * Entity store instance type + */ type EntityStore, } from './createEntityStore/createEntityStore.svelte'; +/** + * Comparison logic + */ export { + /** + * Character-by-character comparison utility + */ CharacterComparisonEngine, + /** + * Single line of comparison results + */ type ComparisonLine, + /** + * Full comparison output + */ type ComparisonResult, } from './CharacterComparisonEngine/CharacterComparisonEngine.svelte'; +/** + * Text layout + */ export { + /** + * Single line layout information + */ type LayoutLine as TextLayoutLine, + /** + * Full multi-line layout information + */ type LayoutResult as TextLayoutResult, + /** + * High-level text measurement engine + */ TextLayoutEngine, } from './TextLayoutEngine/TextLayoutEngine.svelte'; +/** + * Persistence + */ export { + /** + * LocalStorage-backed reactive store factory + */ createPersistentStore, + /** + * Persistent store instance type + */ type PersistentStore, } from './createPersistentStore/createPersistentStore.svelte'; +/** + * Responsive design + */ export { + /** + * Breakpoint tracking factory + */ createResponsiveManager, + /** + * Responsive manager instance type + */ type ResponsiveManager, + /** + * Singleton manager for global usage + */ responsiveManager, } from './createResponsiveManager/createResponsiveManager.svelte'; +/** + * 3D Perspectives + */ export { + /** + * Motion-aware perspective factory + */ createPerspectiveManager, + /** + * Perspective manager instance type + */ type PerspectiveManager, } from './createPerspectiveManager/createPerspectiveManager.svelte'; diff --git a/src/shared/lib/index.ts b/src/shared/lib/index.ts index 33c077c..42fccfb 100644 --- a/src/shared/lib/index.ts +++ b/src/shared/lib/index.ts @@ -39,6 +39,7 @@ export { export { buildQueryString, clampNumber, + cn, debounce, getDecimalPlaces, roundToStepPrecision, diff --git a/src/shared/lib/storybook/MockIcon.svelte b/src/shared/lib/storybook/MockIcon.svelte index d5b6fc4..618121d 100644 --- a/src/shared/lib/storybook/MockIcon.svelte +++ b/src/shared/lib/storybook/MockIcon.svelte @@ -7,7 +7,7 @@ correctly via the HTML element's class attribute. -->
- - {@render children()} - + {@render children()}
diff --git a/src/shared/lib/utils/cn.test.ts b/src/shared/lib/utils/cn.test.ts new file mode 100644 index 0000000..34475b0 --- /dev/null +++ b/src/shared/lib/utils/cn.test.ts @@ -0,0 +1,30 @@ +import { + describe, + expect, + it, +} from 'vitest'; +import { cn } from './cn'; + +describe('cn utility', () => { + it('should merge classes with clsx', () => { + expect(cn('class1', 'class2')).toBe('class1 class2'); + expect(cn('class1', { class2: true, class3: false })).toBe('class1 class2'); + }); + + it('should resolve tailwind specificity conflicts', () => { + // text-neutral-400 vs text-brand (text-brand should win) + expect(cn('text-neutral-400', 'text-brand')).toBe('text-brand'); + + // p-4 vs p-2 + expect(cn('p-4', 'p-2')).toBe('p-2'); + + // dark mode classes should be handled correctly too + expect(cn('text-neutral-400 dark:text-neutral-400', 'text-brand dark:text-brand')).toBe( + 'text-brand dark:text-brand', + ); + }); + + it('should handle undefined and null inputs', () => { + expect(cn('class1', undefined, null, 'class2')).toBe('class1 class2'); + }); +}); diff --git a/src/shared/lib/utils/cn.ts b/src/shared/lib/utils/cn.ts new file mode 100644 index 0000000..1e1842f --- /dev/null +++ b/src/shared/lib/utils/cn.ts @@ -0,0 +1,13 @@ +import { + type ClassValue, + clsx, +} from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +/** + * Utility for merging Tailwind classes with clsx and tailwind-merge. + * This resolves specificity conflicts between Tailwind classes. + */ +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/src/shared/lib/utils/getSkeletonWidth/getSkeletonWidth.ts b/src/shared/lib/utils/getSkeletonWidth/getSkeletonWidth.ts new file mode 100644 index 0000000..e038fab --- /dev/null +++ b/src/shared/lib/utils/getSkeletonWidth/getSkeletonWidth.ts @@ -0,0 +1,13 @@ +/** + * Generates a consistent but varied width for skeleton placeholders. + * Uses a predefined sequence to ensure stability between renders. + * + * @param index - Index of the item in a list to pick a width from the sequence + * @param multiplier - Multiplier to apply to the base sequence values (default: 4) + * @returns CSS width value (e.g., "128px") + */ +export function getSkeletonWidth(index: number, multiplier = 4): string { + const sequence = [32, 48, 40, 56, 36, 44, 52, 38, 46, 42, 34, 50]; + const base = sequence[index % sequence.length]; + return `${base * multiplier}px`; +} diff --git a/src/shared/lib/utils/index.ts b/src/shared/lib/utils/index.ts index 6d708ab..21eef79 100644 --- a/src/shared/lib/utils/index.ts +++ b/src/shared/lib/utils/index.ts @@ -15,8 +15,10 @@ export { type QueryParamValue, } from './buildQueryString/buildQueryString'; export { clampNumber } from './clampNumber/clampNumber'; +export { cn } from './cn'; export { debounce } from './debounce/debounce'; export { getDecimalPlaces } from './getDecimalPlaces/getDecimalPlaces'; +export { getSkeletonWidth } from './getSkeletonWidth/getSkeletonWidth'; export { roundToStepPrecision } from './roundToStepPrecision/roundToStepPrecision'; export { smoothScroll } from './smoothScroll/smoothScroll'; export { splitArray } from './splitArray/splitArray'; diff --git a/src/shared/lib/utils/smoothScroll/smoothScroll.test.ts b/src/shared/lib/utils/smoothScroll/smoothScroll.test.ts index 39d6044..37dba65 100644 --- a/src/shared/lib/utils/smoothScroll/smoothScroll.test.ts +++ b/src/shared/lib/utils/smoothScroll/smoothScroll.test.ts @@ -1,4 +1,6 @@ -/** @vitest-environment jsdom */ +/** + * @vitest-environment jsdom + */ import { afterEach, beforeEach, diff --git a/src/shared/lib/utils/smoothScroll/smoothScroll.ts b/src/shared/lib/utils/smoothScroll/smoothScroll.ts index 00e90c8..2d17324 100644 --- a/src/shared/lib/utils/smoothScroll/smoothScroll.ts +++ b/src/shared/lib/utils/smoothScroll/smoothScroll.ts @@ -18,7 +18,9 @@ export function smoothScroll(node: HTMLAnchorElement) { event.preventDefault(); const hash = node.getAttribute('href'); - if (!hash || hash === '#') return; + if (!hash || hash === '#') { + return; + } const targetElement = document.querySelector(hash); diff --git a/src/shared/lib/utils/throttle/throttle.ts b/src/shared/lib/utils/throttle/throttle.ts index e48e0b3..32caa3a 100644 --- a/src/shared/lib/utils/throttle/throttle.ts +++ b/src/shared/lib/utils/throttle/throttle.ts @@ -35,7 +35,9 @@ export function throttle any>( fn(...args); } else { // Schedule for end of wait period (trailing edge) - if (timeoutId) clearTimeout(timeoutId); + if (timeoutId) { + clearTimeout(timeoutId); + } timeoutId = setTimeout(() => { lastCall = Date.now(); fn(...args); diff --git a/src/shared/shadcn/hooks/is-mobile.svelte.ts b/src/shared/shadcn/hooks/is-mobile.svelte.ts deleted file mode 100644 index 2dfb0eb..0000000 --- a/src/shared/shadcn/hooks/is-mobile.svelte.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { MediaQuery } from 'svelte/reactivity'; - -const DEFAULT_MOBILE_BREAKPOINT = 768; - -export class IsMobile extends MediaQuery { - constructor(breakpoint: number = DEFAULT_MOBILE_BREAKPOINT) { - super(`max-width: ${breakpoint - 1}px`); - } -} diff --git a/src/shared/shadcn/ui/popover/index.ts b/src/shared/shadcn/ui/popover/index.ts deleted file mode 100644 index e5456bb..0000000 --- a/src/shared/shadcn/ui/popover/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -import Close from './popover-close.svelte'; -import Content from './popover-content.svelte'; -import Portal from './popover-portal.svelte'; -import Trigger from './popover-trigger.svelte'; -import Root from './popover.svelte'; - -export { - Close, - Close as PopoverClose, - Content, - Content as PopoverContent, - Portal, - Portal as PopoverPortal, - Root, - // - Root as Popover, - Trigger, - Trigger as PopoverTrigger, -}; diff --git a/src/shared/shadcn/ui/popover/popover-close.svelte b/src/shared/shadcn/ui/popover/popover-close.svelte deleted file mode 100644 index 71124e2..0000000 --- a/src/shared/shadcn/ui/popover/popover-close.svelte +++ /dev/null @@ -1,7 +0,0 @@ - - - diff --git a/src/shared/shadcn/ui/popover/popover-content.svelte b/src/shared/shadcn/ui/popover/popover-content.svelte deleted file mode 100644 index 304e895..0000000 --- a/src/shared/shadcn/ui/popover/popover-content.svelte +++ /dev/null @@ -1,34 +0,0 @@ - - - - - diff --git a/src/shared/shadcn/ui/popover/popover-portal.svelte b/src/shared/shadcn/ui/popover/popover-portal.svelte deleted file mode 100644 index 8e68a02..0000000 --- a/src/shared/shadcn/ui/popover/popover-portal.svelte +++ /dev/null @@ -1,7 +0,0 @@ - - - diff --git a/src/shared/shadcn/ui/popover/popover-trigger.svelte b/src/shared/shadcn/ui/popover/popover-trigger.svelte deleted file mode 100644 index 1192af2..0000000 --- a/src/shared/shadcn/ui/popover/popover-trigger.svelte +++ /dev/null @@ -1,17 +0,0 @@ - - - diff --git a/src/shared/shadcn/ui/popover/popover.svelte b/src/shared/shadcn/ui/popover/popover.svelte deleted file mode 100644 index 7cd4812..0000000 --- a/src/shared/shadcn/ui/popover/popover.svelte +++ /dev/null @@ -1,7 +0,0 @@ - - - diff --git a/src/shared/shadcn/ui/tooltip/index.ts b/src/shared/shadcn/ui/tooltip/index.ts deleted file mode 100644 index 247b714..0000000 --- a/src/shared/shadcn/ui/tooltip/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -import Content from './tooltip-content.svelte'; -import Portal from './tooltip-portal.svelte'; -import Provider from './tooltip-provider.svelte'; -import Trigger from './tooltip-trigger.svelte'; -import Root from './tooltip.svelte'; - -export { - Content, - Content as TooltipContent, - Portal, - Portal as TooltipPortal, - Provider, - Provider as TooltipProvider, - Root, - // - Root as Tooltip, - Trigger, - Trigger as TooltipTrigger, -}; diff --git a/src/shared/shadcn/ui/tooltip/tooltip-content.svelte b/src/shared/shadcn/ui/tooltip/tooltip-content.svelte deleted file mode 100644 index 8e4e4c3..0000000 --- a/src/shared/shadcn/ui/tooltip/tooltip-content.svelte +++ /dev/null @@ -1,53 +0,0 @@ - - - - - {@render children?.()} - - {#snippet child({ props })} -
-
- {/snippet} -
-
-
diff --git a/src/shared/shadcn/ui/tooltip/tooltip-portal.svelte b/src/shared/shadcn/ui/tooltip/tooltip-portal.svelte deleted file mode 100644 index d52ed4e..0000000 --- a/src/shared/shadcn/ui/tooltip/tooltip-portal.svelte +++ /dev/null @@ -1,7 +0,0 @@ - - - diff --git a/src/shared/shadcn/ui/tooltip/tooltip-provider.svelte b/src/shared/shadcn/ui/tooltip/tooltip-provider.svelte deleted file mode 100644 index 7795b7f..0000000 --- a/src/shared/shadcn/ui/tooltip/tooltip-provider.svelte +++ /dev/null @@ -1,7 +0,0 @@ - - - diff --git a/src/shared/shadcn/ui/tooltip/tooltip-trigger.svelte b/src/shared/shadcn/ui/tooltip/tooltip-trigger.svelte deleted file mode 100644 index 16086d4..0000000 --- a/src/shared/shadcn/ui/tooltip/tooltip-trigger.svelte +++ /dev/null @@ -1,7 +0,0 @@ - - - diff --git a/src/shared/shadcn/ui/tooltip/tooltip.svelte b/src/shared/shadcn/ui/tooltip/tooltip.svelte deleted file mode 100644 index 6a316fc..0000000 --- a/src/shared/shadcn/ui/tooltip/tooltip.svelte +++ /dev/null @@ -1,7 +0,0 @@ - - - diff --git a/src/shared/shadcn/utils/shadcn-utils.ts b/src/shared/shadcn/utils/shadcn-utils.ts deleted file mode 100644 index 155cee4..0000000 --- a/src/shared/shadcn/utils/shadcn-utils.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { - type ClassValue, - clsx, -} from 'clsx'; -import { twMerge } from 'tailwind-merge'; - -/** - * Utility function to merge Tailwind CSS classes - * Combines clsx for conditional classes and tailwind-merge to handle conflicts - */ -export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)); -} - -/** - * Type utility to add a ref property to HTML element attributes - * Used in shadcn-svelte components to support element references - * @template T - The attributes type (e.g., HTMLAttributes) - * @template E - The element type (e.g., HTMLDivElement) - */ -export type WithElementRef = T & { - /** - * Reference to the DOM element - */ - ref?: E | null; -}; - -/** - * Type utility to remove 'children' and 'child' properties from a type - * Used in shadcn-svelte components that use Snippets instead of children - * @template T - The type to remove children from - */ -export type WithoutChildren = Omit; - -/** - * Type utility to remove 'children' and 'child' properties from a type - * Used in shadcn-svelte components that use Snippets instead of children - * @template T - The type to remove children and child from - */ -export type WithoutChildrenOrChild = Omit; diff --git a/src/shared/types/common.ts b/src/shared/types/common.ts index 3dfe594..5911a63 100644 --- a/src/shared/types/common.ts +++ b/src/shared/types/common.ts @@ -7,8 +7,12 @@ * @template T - Type of the response data */ export interface ApiResponse { - /** Response payload data */ + /** + * Primary data payload returned by the server + */ data: T; - /** HTTP status code */ + /** + * HTTP status code (e.g. 200, 404, 500) + */ status: number; } diff --git a/src/shared/ui/Badge/Badge.svelte b/src/shared/ui/Badge/Badge.svelte index aa42df9..9c46dd3 100644 --- a/src/shared/ui/Badge/Badge.svelte +++ b/src/shared/ui/Badge/Badge.svelte @@ -3,7 +3,7 @@ Pill badge with border and optional status dot. --> @@ -52,7 +53,7 @@ import ButtonGroup from './ButtonGroup.svelte'; name="Default/Basic" parameters={{ docs: { description: { story: 'Standard text button at all sizes' } } }} > - {#snippet template(args)} + {#snippet template(args: ComponentProps)} @@ -67,7 +68,7 @@ import ButtonGroup from './ButtonGroup.svelte'; name="Default/With Icon" args={{ variant: 'secondary', size: 'md', iconPosition: 'left', active: false, animate: true }} > - {#snippet template(args)} + {#snippet template(args: ComponentProps)} {/snippet} @@ -90,7 +91,7 @@ import ButtonGroup from './ButtonGroup.svelte'; name="Secondary" args={{ variant: 'secondary', size: 'md', iconPosition: 'left', active: false, animate: true }} > - {#snippet template(args)} + {#snippet template(args: ComponentProps)} {/snippet} @@ -99,7 +100,7 @@ import ButtonGroup from './ButtonGroup.svelte'; name="Icon" args={{ variant: 'icon', size: 'md', iconPosition: 'left', active: false, animate: true }} > - {#snippet template(args)} + {#snippet template(args: ComponentProps)} diff --git a/src/shared/ui/Button/Button.svelte b/src/shared/ui/Button/Button.svelte index cdd2196..e2b51aa 100644 --- a/src/shared/ui/Button/Button.svelte +++ b/src/shared/ui/Button/Button.svelte @@ -3,7 +3,7 @@ design-system button. Uppercase, zero border-radius, Space Grotesk. --> - {#snippet template(args)} + {#snippet template(args: ComponentProps)} @@ -40,7 +41,7 @@ import { Button } from '$shared/ui/Button'; - {#snippet template(args)} + {#snippet template(args: ComponentProps)} @@ -51,7 +52,7 @@ import { Button } from '$shared/ui/Button'; - {#snippet template(args)} + {#snippet template(args: ComponentProps)} @@ -61,7 +62,7 @@ import { Button } from '$shared/ui/Button'; - {#snippet template(args)} + {#snippet template(args: ComponentProps)} @@ -78,7 +79,7 @@ import { Button } from '$shared/ui/Button'; }, }} > - {#snippet template(args)} + {#snippet template(args: ComponentProps)}

Dark Mode

diff --git a/src/shared/ui/Button/ButtonGroup.svelte b/src/shared/ui/Button/ButtonGroup.svelte index fd06816..1f56489 100644 --- a/src/shared/ui/Button/ButtonGroup.svelte +++ b/src/shared/ui/Button/ButtonGroup.svelte @@ -1,10 +1,10 @@ - {#snippet template(args)} + {#snippet template(args: ComponentProps)}
{#snippet icon()} @@ -74,7 +75,7 @@ import TrashIcon from '@lucide/svelte/icons/trash-2'; name="Variants" args={{ size: 'md', active: false, animate: true }} > - {#snippet template(args)} + {#snippet template(args: ComponentProps)}
{#snippet icon()} @@ -99,7 +100,7 @@ import TrashIcon from '@lucide/svelte/icons/trash-2'; name="Active State" args={{ size: 'md', animate: true }} > - {#snippet template(args)} + {#snippet template(args: ComponentProps)}
{#snippet icon()} @@ -123,7 +124,7 @@ import TrashIcon from '@lucide/svelte/icons/trash-2'; }, }} > - {#snippet template(args)} + {#snippet template(args: ComponentProps)}

Dark Mode

diff --git a/src/shared/ui/Button/ToggleButton.stories.svelte b/src/shared/ui/Button/ToggleButton.stories.svelte index 73b115e..86d45b6 100644 --- a/src/shared/ui/Button/ToggleButton.stories.svelte +++ b/src/shared/ui/Button/ToggleButton.stories.svelte @@ -44,6 +44,7 @@ const { Story } = defineMeta({ @@ -51,7 +52,7 @@ let selected = $state(false); name="Default" args={{ variant: 'tertiary', size: 'md', selected: false, animate: true }} > - {#snippet template(args)} + {#snippet template(args: ComponentProps)} Toggle Me {/snippet} @@ -60,7 +61,7 @@ let selected = $state(false); name="Selected/Unselected" args={{ variant: 'tertiary', size: 'md', animate: true }} > - {#snippet template(args)} + {#snippet template(args: ComponentProps)}
Unselected @@ -76,7 +77,7 @@ let selected = $state(false); name="Variants" args={{ size: 'md', selected: true, animate: true }} > - {#snippet template(args)} + {#snippet template(args: ComponentProps)}
Primary @@ -101,9 +102,15 @@ let selected = $state(false); name="Interactive" args={{ variant: 'tertiary', size: 'md', animate: true }} > - {#snippet template(args)} + {#snippet template(args: ComponentProps)}
- selected = !selected}> + { + selected = !selected; + }} + > Click to toggle Currently: {selected ? 'selected' : 'unselected'} @@ -119,7 +126,7 @@ let selected = $state(false); }, }} > - {#snippet template(args)} + {#snippet template(args: ComponentProps)}

Dark Mode

diff --git a/src/shared/ui/ComboControl/ComboControl.stories.svelte b/src/shared/ui/ComboControl/ComboControl.stories.svelte index 776669f..bdaf27f 100644 --- a/src/shared/ui/ComboControl/ComboControl.stories.svelte +++ b/src/shared/ui/ComboControl/ComboControl.stories.svelte @@ -30,6 +30,7 @@ const { Story } = defineMeta({ @@ -40,7 +41,7 @@ const horizontalControl = createTypographyControl({ min: 0, max: 100, step: 1, v label: 'Size', }} > - {#snippet template(args)} + {#snippet template(args: ComponentProps)} {/snippet} diff --git a/src/shared/ui/ComboControl/ComboControl.svelte b/src/shared/ui/ComboControl/ComboControl.svelte index 5750544..0dffe98 100644 --- a/src/shared/ui/ComboControl/ComboControl.svelte +++ b/src/shared/ui/ComboControl/ComboControl.svelte @@ -5,16 +5,12 @@ --> + + + {#snippet template()} +
+ +
Control content here
+
+
+ {/snippet} +
+ + + {#snippet template()} +
+ +
+ + + +
+
+
+ {/snippet} +
diff --git a/src/shared/ui/ControlGroup/ControlGroup.svelte b/src/shared/ui/ControlGroup/ControlGroup.svelte index bfcde08..b6a8be3 100644 --- a/src/shared/ui/ControlGroup/ControlGroup.svelte +++ b/src/shared/ui/ControlGroup/ControlGroup.svelte @@ -3,7 +3,7 @@ Labeled container for form controls --> -
-
+
+
{label}
{@render children?.()} diff --git a/src/shared/ui/Divider/Divider.svelte b/src/shared/ui/Divider/Divider.svelte index 8085069..74baa67 100644 --- a/src/shared/ui/Divider/Divider.svelte +++ b/src/shared/ui/Divider/Divider.svelte @@ -3,7 +3,7 @@ 1px separator line, horizontal or vertical. --> - - {#snippet template(args)} - + + {#snippet template(args: ComponentProps)} + {/snippet} - - {#snippet template(args)} - + + {#snippet template(args: ComponentProps)} + {/snippet} diff --git a/src/shared/ui/FilterGroup/FilterGroup.svelte b/src/shared/ui/FilterGroup/FilterGroup.svelte index 3235c1f..917c465 100644 --- a/src/shared/ui/FilterGroup/FilterGroup.svelte +++ b/src/shared/ui/FilterGroup/FilterGroup.svelte @@ -4,7 +4,7 @@ --> + + - {#snippet template(args)} + {#snippet template(args: ComponentProps)} Footnote @@ -25,7 +29,7 @@ const { Story } = defineMeta({ - {#snippet template(args)} + {#snippet template(args: ComponentProps)} {#snippet render({ class: className })} Footnote diff --git a/src/shared/ui/Footnote/Footnote.svelte b/src/shared/ui/Footnote/Footnote.svelte index 2b72ce9..cf69ca8 100644 --- a/src/shared/ui/Footnote/Footnote.svelte +++ b/src/shared/ui/Footnote/Footnote.svelte @@ -3,7 +3,7 @@ Provides classes for styling footnotes --> - {#snippet template(args)} + {#snippet template(args: ComponentProps)} {/snippet} - {#snippet template(args)} + {#snippet template(args: ComponentProps)}
- - - - + + + +
{/snippet}
- - {#snippet template(args)} - + + {#snippet template(args: ComponentProps)} + {/snippet} - - {#snippet template(args)} - + + {#snippet template(args: ComponentProps)} + {/snippet} - {#snippet template(args)} + {#snippet template(args: ComponentProps)} {#snippet rightIcon()} @@ -106,7 +107,7 @@ const placeholder = 'Enter text'; - {#snippet template(args)} + {#snippet template(args: ComponentProps)} {#snippet leftIcon()} @@ -115,9 +116,9 @@ const placeholder = 'Enter text'; {/snippet} - - {#snippet template(args)} - + + {#snippet template(args: ComponentProps)} + {#snippet rightIcon()} {/snippet} diff --git a/src/shared/ui/Input/Input.svelte b/src/shared/ui/Input/Input.svelte index dd6db7d..65745e7 100644 --- a/src/shared/ui/Input/Input.svelte +++ b/src/shared/ui/Input/Input.svelte @@ -3,12 +3,16 @@ design-system input. Zero border-radius, Space Grotesk, precise states. --> - {#snippet template(args)} + {#snippet template(args: ComponentProps)} {/snippet} @@ -72,7 +73,7 @@ import CircleIcon from '@lucide/svelte/icons/circle'; name="Default variant" args={{ variant: 'default', size: 'sm' }} > - {#snippet template(args)} + {#snippet template(args: ComponentProps)} {/snippet} @@ -81,7 +82,7 @@ import CircleIcon from '@lucide/svelte/icons/circle'; name="Accent variant" args={{ variant: 'accent', size: 'sm' }} > - {#snippet template(args)} + {#snippet template(args: ComponentProps)} {/snippet} @@ -90,7 +91,7 @@ import CircleIcon from '@lucide/svelte/icons/circle'; name="Muted variant" args={{ variant: 'muted', size: 'sm' }} > - {#snippet template(args)} + {#snippet template(args: ComponentProps)} {/snippet} @@ -99,7 +100,7 @@ import CircleIcon from '@lucide/svelte/icons/circle'; name="Success variant" args={{ variant: 'success', size: 'sm' }} > - {#snippet template(args)} + {#snippet template(args: ComponentProps)} {/snippet} @@ -108,7 +109,7 @@ import CircleIcon from '@lucide/svelte/icons/circle'; name="Warning variant" args={{ variant: 'warning', size: 'sm' }} > - {#snippet template(args)} + {#snippet template(args: ComponentProps)} {/snippet}
@@ -117,7 +118,7 @@ import CircleIcon from '@lucide/svelte/icons/circle'; name="Error variant" args={{ variant: 'error', size: 'sm' }} > - {#snippet template(args)} + {#snippet template(args: ComponentProps)} {/snippet}
@@ -139,7 +140,7 @@ import CircleIcon from '@lucide/svelte/icons/circle'; name="Uppercase" args={{ uppercase: true, size: 'sm' }} > - {#snippet template(args)} + {#snippet template(args: ComponentProps)} {/snippet}
@@ -148,7 +149,7 @@ import CircleIcon from '@lucide/svelte/icons/circle'; name="Lowercase" args={{ uppercase: false, size: 'sm' }} > - {#snippet template(args)} + {#snippet template(args: ComponentProps)} {/snippet} @@ -157,7 +158,7 @@ import CircleIcon from '@lucide/svelte/icons/circle'; name="Bold" args={{ bold: true, size: 'sm' }} > - {#snippet template(args)} + {#snippet template(args: ComponentProps)} {/snippet} @@ -166,7 +167,7 @@ import CircleIcon from '@lucide/svelte/icons/circle'; name="With icon (left)" args={{ variant: 'default', size: 'sm', iconPosition: 'left' }} > - {#snippet template(args)} + {#snippet template(args: ComponentProps)}
diff --git a/src/shared/ui/Loader/Loader.svelte.test.ts b/src/shared/ui/Loader/Loader.svelte.test.ts new file mode 100644 index 0000000..14fa156 --- /dev/null +++ b/src/shared/ui/Loader/Loader.svelte.test.ts @@ -0,0 +1,28 @@ +import { + render, + screen, +} from '@testing-library/svelte'; +import Loader from './Loader.svelte'; + +describe('Loader', () => { + it('renders the default message', () => { + render(Loader); + expect(screen.getByText('analyzing_data')).toBeInTheDocument(); + }); + + it('renders a custom message', () => { + render(Loader, { message: 'loading_fonts' }); + expect(screen.getByText('loading_fonts')).toBeInTheDocument(); + }); + + it('renders the SVG spinner', () => { + const { container } = render(Loader); + expect(container.querySelector('svg')).toBeInTheDocument(); + }); + + it('renders the divider', () => { + const { container } = render(Loader); + const divider = container.querySelector('.w-px.h-3'); + expect(divider).toBeInTheDocument(); + }); +}); diff --git a/src/shared/ui/Logo/Logo.stories.svelte b/src/shared/ui/Logo/Logo.stories.svelte index 1e94cf0..f0aedc7 100644 --- a/src/shared/ui/Logo/Logo.stories.svelte +++ b/src/shared/ui/Logo/Logo.stories.svelte @@ -16,8 +16,12 @@ const { Story } = defineMeta({ }); + + - {#snippet template(args)} + {#snippet template(args: ComponentProps)} {/snippet} diff --git a/src/shared/ui/Logo/Logo.svelte b/src/shared/ui/Logo/Logo.svelte index b3c098b..7edb647 100644 --- a/src/shared/ui/Logo/Logo.svelte +++ b/src/shared/ui/Logo/Logo.svelte @@ -3,7 +3,7 @@ Project logo with apropriate styles --> + + + + + {#snippet template()} +
+ + {#snippet children({ className })} +
+ Front — fully visible +
+ {/snippet} +
+
+ {/snippet} +
+ + + {#snippet template()} +
+ + {#snippet children({ className })} +
+ Back — blurred and scaled down +
+ {/snippet} +
+
+ {/snippet} +
+ + + {#snippet template()} +
+ + {#snippet children({ className })} +
+ Left half +
+ {/snippet} +
+
+ {/snippet} +
diff --git a/src/shared/ui/PerspectivePlan/PerspectivePlan.svelte b/src/shared/ui/PerspectivePlan/PerspectivePlan.svelte index 4cfeae4..a43341b 100644 --- a/src/shared/ui/PerspectivePlan/PerspectivePlan.svelte +++ b/src/shared/ui/PerspectivePlan/PerspectivePlan.svelte @@ -5,7 +5,7 @@ --> @@ -36,9 +38,10 @@ let defaultSearchValue = $state(''); args={{ value: defaultSearchValue, placeholder: 'Type here...', + variant: 'filled', }} > - {#snippet template(args)} - + {#snippet template(args: ComponentProps)} + {/snippet} diff --git a/src/shared/ui/SearchBar/SearchBar.svelte b/src/shared/ui/SearchBar/SearchBar.svelte index 3c565e4..f9efca8 100644 --- a/src/shared/ui/SearchBar/SearchBar.svelte +++ b/src/shared/ui/SearchBar/SearchBar.svelte @@ -20,7 +20,7 @@ let { }: Props = $props(); - + {#snippet rightIcon(size)} {/snippet} diff --git a/src/shared/ui/SearchBar/SearchBar.svelte.test.ts b/src/shared/ui/SearchBar/SearchBar.svelte.test.ts new file mode 100644 index 0000000..89913e3 --- /dev/null +++ b/src/shared/ui/SearchBar/SearchBar.svelte.test.ts @@ -0,0 +1,45 @@ +import { + fireEvent, + render, + screen, +} from '@testing-library/svelte'; +import SearchBar from './SearchBar.svelte'; + +describe('SearchBar', () => { + it('renders an input element', () => { + render(SearchBar); + expect(screen.getByRole('textbox')).toBeInTheDocument(); + }); + + it('renders placeholder text', () => { + render(SearchBar, { placeholder: 'Search…' }); + expect(screen.getByPlaceholderText('Search…')).toBeInTheDocument(); + }); + + it('renders with initial value', () => { + render(SearchBar, { value: 'Roboto' }); + expect(screen.getByDisplayValue('Roboto')).toBeInTheDocument(); + }); + + it('renders search icon', () => { + const { container } = render(SearchBar); + expect(container.querySelector('svg')).toBeInTheDocument(); + }); + + it('updates value on user input', async () => { + render(SearchBar); + const input = screen.getByRole('textbox') as HTMLInputElement; + await fireEvent.input(input, { target: { value: 'Inter' } }); + expect(input.value).toBe('Inter'); + }); + + it('is not disabled by default', () => { + render(SearchBar); + expect(screen.getByRole('textbox')).not.toBeDisabled(); + }); + + it('is disabled when disabled prop is true', () => { + render(SearchBar, { disabled: true }); + expect(screen.getByRole('textbox')).toBeDisabled(); + }); +}); diff --git a/src/shared/ui/Section/SectionHeader/SectionHeader.svelte b/src/shared/ui/Section/SectionHeader/SectionHeader.svelte index 9e76d42..c7e285d 100644 --- a/src/shared/ui/Section/SectionHeader/SectionHeader.svelte +++ b/src/shared/ui/Section/SectionHeader/SectionHeader.svelte @@ -3,7 +3,7 @@ Numbered section heading with optional subtitle and pulse dot. --> + + + + + {#snippet template(args: ComponentProps)} +
+ +
+ {/snippet} +
+ + + {#snippet template(args: ComponentProps)} +
+

Above

+ +

Below

+
+ {/snippet} +
diff --git a/src/shared/ui/Section/SectionSeparator/SectionSeparator.svelte b/src/shared/ui/Section/SectionSeparator/SectionSeparator.svelte index fd55462..264a981 100644 --- a/src/shared/ui/Section/SectionSeparator/SectionSeparator.svelte +++ b/src/shared/ui/Section/SectionSeparator/SectionSeparator.svelte @@ -3,7 +3,7 @@ A horizontal separator line used to visually separate sections within a page. --> + + + + + {#snippet template(args: ComponentProps)} + + {/snippet} + + + + {#snippet template(args: ComponentProps)} + + {/snippet} + + + + {#snippet template(args: ComponentProps)} + + {/snippet} + diff --git a/src/shared/ui/Section/SectionTitle/SectionTitle.svelte b/src/shared/ui/Section/SectionTitle/SectionTitle.svelte index 53325c0..aa266d1 100644 --- a/src/shared/ui/Section/SectionTitle/SectionTitle.svelte +++ b/src/shared/ui/Section/SectionTitle/SectionTitle.svelte @@ -13,7 +13,7 @@ interface Props { const { text }: Props = $props(); {#if text} -

+

{text}

{/if} diff --git a/src/shared/ui/SidebarContainer/SidebarContainer.stories.svelte b/src/shared/ui/SidebarContainer/SidebarContainer.stories.svelte new file mode 100644 index 0000000..e57d55c --- /dev/null +++ b/src/shared/ui/SidebarContainer/SidebarContainer.stories.svelte @@ -0,0 +1,102 @@ + + + + {#snippet template()} + +
+ + {#snippet sidebar({ onClose })} +
+ +

Sidebar Content

+
+ {/snippet} +
+
Main content
+
+
+ {/snippet} +
+ + + {#snippet template()} + +
+ + {#snippet sidebar({ onClose })} +
+ +

Sidebar Content

+
+ {/snippet} +
+
+ Main content — sidebar is collapsed to zero width +
+
+
+ {/snippet} +
+ + + {#snippet template()} + +
+ + {#snippet sidebar({ onClose })} +
+ +

Sidebar Content

+
+ {/snippet} +
+
Main content
+
+
+ {/snippet} +
diff --git a/src/shared/ui/SidebarContainer/SidebarContainer.svelte b/src/shared/ui/SidebarContainer/SidebarContainer.svelte index e83c8d2..3bfb2ec 100644 --- a/src/shared/ui/SidebarContainer/SidebarContainer.svelte +++ b/src/shared/ui/SidebarContainer/SidebarContainer.svelte @@ -4,7 +4,7 @@ --> + + - {#snippet template(args)} + {#snippet template(args: ComponentProps)}
diff --git a/src/shared/ui/Skeleton/Skeleton.svelte b/src/shared/ui/Skeleton/Skeleton.svelte index 95cfd74..7d07150 100644 --- a/src/shared/ui/Skeleton/Skeleton.svelte +++ b/src/shared/ui/Skeleton/Skeleton.svelte @@ -3,7 +3,7 @@ Generic loading placeholder with shimmer animation. --> - - {#snippet template(args)} + + {#snippet template(args: ComponentProps)}

Value: {args.value}

@@ -54,8 +64,17 @@ let valueHigh = $state(75); {/snippet} - - {#snippet template(args)} + + {#snippet template(args: ComponentProps)}
@@ -66,8 +85,17 @@ let valueHigh = $state(75); {/snippet} - - {#snippet template(args)} + + {#snippet template(args: ComponentProps)}

Slider with inline label

@@ -75,8 +103,17 @@ let valueHigh = $state(75); {/snippet} - - {#snippet template(args)} + + {#snippet template(args: ComponentProps)}

Thumb: 45° rotated square

@@ -103,8 +140,17 @@ let valueHigh = $state(75); {/snippet} - - {#snippet template(args)} + + {#snippet template(args: ComponentProps)}

Step: 1 (default)

diff --git a/src/shared/ui/Slider/Slider.svelte b/src/shared/ui/Slider/Slider.svelte index 1b481bc..2b2de67 100644 --- a/src/shared/ui/Slider/Slider.svelte +++ b/src/shared/ui/Slider/Slider.svelte @@ -69,8 +69,8 @@ let { const isVertical = $derived(orientation === 'vertical'); -const labelClasses = `font-mono text-[0.625rem] tabular-nums shrink-0 - text-neutral-500 dark:text-neutral-400 +const labelClasses = `font-mono text-2xs tabular-nums shrink-0 + text-secondary group-hover:text-neutral-700 dark:group-hover:text-neutral-300 transition-colors`; diff --git a/src/shared/ui/Slider/Slider.svelte.test.ts b/src/shared/ui/Slider/Slider.svelte.test.ts new file mode 100644 index 0000000..bb0aff9 --- /dev/null +++ b/src/shared/ui/Slider/Slider.svelte.test.ts @@ -0,0 +1,62 @@ +import { + render, + screen, +} from '@testing-library/svelte'; +import Slider from './Slider.svelte'; + +describe('Slider', () => { + describe('Rendering', () => { + it('renders a slider element', () => { + render(Slider); + expect(screen.getByRole('slider')).toBeInTheDocument(); + }); + + it('displays formatted value', () => { + render(Slider, { value: 50 }); + expect(screen.getByText('50')).toBeInTheDocument(); + }); + + it('applies a custom formatter', () => { + const { container } = render(Slider, { value: 25, format: (v: number) => `${v}%` }); + expect(container.textContent).toContain('25%'); + }); + }); + + describe('Props', () => { + it('respects min and max attributes', () => { + render(Slider, { min: 10, max: 90, value: 50 }); + const slider = screen.getByRole('slider'); + expect(slider).toHaveAttribute('aria-valuemin', '10'); + expect(slider).toHaveAttribute('aria-valuemax', '90'); + }); + + it('reflects value as aria-valuenow', () => { + render(Slider, { value: 42 }); + expect(screen.getByRole('slider')).toHaveAttribute('aria-valuenow', '42'); + }); + + it('is disabled when disabled=true', () => { + render(Slider, { disabled: true }); + expect(screen.getByRole('slider')).toHaveAttribute('aria-disabled', 'true'); + }); + + it('is not disabled by default', () => { + render(Slider, { value: 0 }); + expect(screen.getByRole('slider')).not.toHaveAttribute('aria-disabled', 'true'); + }); + }); + + describe('Orientations', () => { + it('renders horizontal by default', () => { + const { container } = render(Slider); + expect(screen.getByRole('slider')).toHaveAttribute('aria-orientation', 'horizontal'); + expect(container.querySelector('.cursor-col-resize')).toBeInTheDocument(); + }); + + it('renders vertical when orientation="vertical"', () => { + const { container } = render(Slider, { orientation: 'vertical' }); + expect(screen.getByRole('slider')).toHaveAttribute('aria-orientation', 'vertical'); + expect(container.querySelector('.cursor-row-resize')).toBeInTheDocument(); + }); + }); +}); diff --git a/src/shared/ui/Stat/Stat.svelte b/src/shared/ui/Stat/Stat.svelte index 80e6855..a4bbc2e 100644 --- a/src/shared/ui/Stat/Stat.svelte +++ b/src/shared/ui/Stat/Stat.svelte @@ -3,7 +3,7 @@ A single key:value pair in Space Mono. Optional trailing divider. --> + + + + + {#snippet template(args: ComponentProps)} + + {/snippet} + + + + {#snippet template(args: ComponentProps)} + + {/snippet} + + + + {#snippet template(args: ComponentProps)} + + {/snippet} + diff --git a/src/shared/ui/Stat/StatGroup.svelte b/src/shared/ui/Stat/StatGroup.svelte index 662d45f..f80c91d 100644 --- a/src/shared/ui/Stat/StatGroup.svelte +++ b/src/shared/ui/Stat/StatGroup.svelte @@ -3,7 +3,7 @@ Renders multiple Stat components in a row with auto-separators. --> - - {#snippet template(args)} + + {#snippet template(args: ComponentProps)}
- + {#snippet children({ item })}
{item}
{/snippet} @@ -57,10 +62,13 @@ const emptyDataSet: string[] = []; {/snippet} - - {#snippet template(args)} + + {#snippet template(args: ComponentProps)}
- + {#snippet children({ item })}
{item}
{/snippet} @@ -69,9 +77,12 @@ const emptyDataSet: string[] = []; {/snippet} - - {#snippet template(args)} - + + {#snippet template(args: ComponentProps)} + {#snippet children({ item })}
{item}
{/snippet} diff --git a/src/shared/ui/VirtualList/VirtualList.svelte b/src/shared/ui/VirtualList/VirtualList.svelte index ee13838..3b4884c 100644 --- a/src/shared/ui/VirtualList/VirtualList.svelte +++ b/src/shared/ui/VirtualList/VirtualList.svelte @@ -10,8 +10,8 @@ --> {#snippet content()} diff --git a/src/shared/ui/index.ts b/src/shared/ui/index.ts index b4aa258..d02efb3 100644 --- a/src/shared/ui/index.ts +++ b/src/shared/ui/index.ts @@ -1,28 +1,156 @@ -export { default as Badge } from './Badge/Badge.svelte'; export { + /** + * Pill-shaped status indicator + */ + default as Badge, +} from './Badge/Badge.svelte'; +export { + /** + * Main action trigger + */ Button, + /** + * Horizontal layout for related buttons + */ ButtonGroup, + /** + * Button optimized for single-icon display + */ IconButton, + /** + * State-aware toggle switch + */ ToggleButton, } from './Button'; -export { default as ComboControl } from './ComboControl/ComboControl.svelte'; -export { default as ContentEditable } from './ContentEditable/ContentEditable.svelte'; -export { default as ControlGroup } from './ControlGroup/ControlGroup.svelte'; -export { default as Divider } from './Divider/Divider.svelte'; -export { default as FilterGroup } from './FilterGroup/FilterGroup.svelte'; -export { default as Footnote } from './Footnote/Footnote.svelte'; -export { default as Input } from './Input/Input.svelte'; -export { default as Label } from './Label/Label.svelte'; -export { default as Loader } from './Loader/Loader.svelte'; -export { default as Logo } from './Logo/Logo.svelte'; -export { default as PerspectivePlan } from './PerspectivePlan/PerspectivePlan.svelte'; -export { default as SearchBar } from './SearchBar/SearchBar.svelte'; -export { default as Section } from './Section/Section.svelte'; -export type { TitleStatusChangeHandler } from './Section/types'; -export { default as SidebarContainer } from './SidebarContainer/SidebarContainer.svelte'; -export { default as Skeleton } from './Skeleton/Skeleton.svelte'; -export { default as Slider } from './Slider/Slider.svelte'; -export { default as Stat } from './Stat/Stat.svelte'; -export { default as StatGroup } from './Stat/StatGroup.svelte'; -export { default as TechText } from './TechText/TechText.svelte'; -export { default as VirtualList } from './VirtualList/VirtualList.svelte'; +export { + /** + * Input with associated increment/decrement controls + */ + default as ComboControl, +} from './ComboControl/ComboControl.svelte'; +export { + /** + * Rich text input using contenteditable attribute + */ + default as ContentEditable, +} from './ContentEditable/ContentEditable.svelte'; +export { + /** + * Semantic grouping for related UI controls + */ + default as ControlGroup, +} from './ControlGroup/ControlGroup.svelte'; +export { + /** + * Simple horizontal line separator + */ + default as Divider, +} from './Divider/Divider.svelte'; +export { + /** + * Filterable property set with selection logic + */ + default as FilterGroup, +} from './FilterGroup/FilterGroup.svelte'; +export { + /** + * Small text for secondary meta-information + */ + default as Footnote, +} from './Footnote/Footnote.svelte'; +export { + /** + * Design-system standard text input + */ + default as Input, +} from './Input/Input.svelte'; +export { + /** + * Text label for input fields + */ + default as Label, +} from './Label/Label.svelte'; +export { + /** + * Styled link with optional icon + */ + default as Link, +} from './Link/Link.svelte'; +export { + /** + * Full-page or component-level progress spinner + */ + default as Loader, +} from './Loader/Loader.svelte'; +export { + /** + * Main application logo + */ + default as Logo, +} from './Logo/Logo.svelte'; +export { + /** + * 3D perspective background/container + */ + default as PerspectivePlan, +} from './PerspectivePlan/PerspectivePlan.svelte'; +export { + /** + * Specialized input with search icon and clear state + */ + default as SearchBar, +} from './SearchBar/SearchBar.svelte'; +export { + /** + * Content section with header and optional title tracking + */ + default as Section, +} from './Section/Section.svelte'; +export { + /** + * Callback for section visibility status changes + */ + type TitleStatusChangeHandler, +} from './Section/types'; +export { + /** + * Structural sidebar component + */ + default as SidebarContainer, +} from './SidebarContainer/SidebarContainer.svelte'; +export { + /** + * Loading placeholder with pulsing animation + */ + default as Skeleton, +} from './Skeleton/Skeleton.svelte'; +export { + /** + * Range selector with numeric feedback + */ + default as Slider, +} from './Slider/Slider.svelte'; +export { + /** + * Individual numeric statistic display + */ + default as Stat, +} from './Stat/Stat.svelte'; +export { + /** + * Grouping for multiple statistics + */ + default as StatGroup, +} from './Stat/StatGroup.svelte'; +export { + /** + * Mono-spaced technical/metadata text + */ + default as TechText, +} from './TechText/TechText.svelte'; +export { + /** + * High-performance list renderer for large datasets + */ + default as VirtualList, +} from './VirtualList/VirtualList.svelte'; diff --git a/src/widgets/ComparisonView/lib/index.ts b/src/widgets/ComparisonView/lib/index.ts new file mode 100644 index 0000000..1d97db6 --- /dev/null +++ b/src/widgets/ComparisonView/lib/index.ts @@ -0,0 +1,2 @@ +export * from './utils/dotTransition'; +export * from './utils/getPretextFontString'; diff --git a/src/widgets/ComparisonView/lib/utils/dotTransition.ts b/src/widgets/ComparisonView/lib/utils/dotTransition.ts new file mode 100644 index 0000000..78fb4c3 --- /dev/null +++ b/src/widgets/ComparisonView/lib/utils/dotTransition.ts @@ -0,0 +1,99 @@ +import { VIRTUAL_INDEX_NOT_LOADED } from '$entities/Font'; +import { cubicOut } from 'svelte/easing'; +import { + type CrossfadeParams, + type TransitionConfig, + crossfade, +} from 'svelte/transition'; + +/** + * Custom parameters for dot transitions in virtualized lists. + */ +export interface DotTransitionParams extends CrossfadeParams { + /** + * Unique key for crossfade pairing + */ + key: any; + /** + * Current index of the item in the list + */ + index: number; + /** + * Target index to move towards (e.g. counterpart side index) + */ + otherIndex: number; +} + +/** + * Type-safe helper to create dot transition parameters. + */ +export function getDotTransitionParams( + key: 'active-dot' | 'inactive-dot', + index: number, + otherIndex: number, +): DotTransitionParams { + return { key, index, otherIndex }; +} + +/** + * Type guard for DotTransitionParams. + */ +function isDotTransitionParams(p: CrossfadeParams): p is DotTransitionParams { + return ( + p !== null + && typeof p === 'object' + && 'index' in p + && 'otherIndex' in p + ); +} + +/** + * Creates a crossfade transition pair optimized for virtualized font lists. + * + * It uses the 'index' and 'otherIndex' params to calculate the direction + * of the slide animation when a matching pair cannot be found in the DOM + * (e.g. because it was virtualized out). + */ +export function createDotCrossfade() { + return crossfade({ + duration: 300, + easing: cubicOut, + fallback(node: Element, params: CrossfadeParams, _intro: boolean): TransitionConfig { + if (!isDotTransitionParams(params)) { + return { + duration: 300, + easing: cubicOut, + css: t => `opacity: ${t};`, + }; + } + + const { index, otherIndex } = params; + + // If the other target is unknown, just fade in place + if (otherIndex === undefined || otherIndex === -1) { + return { + duration: 300, + easing: cubicOut, + css: t => `opacity: ${t};`, + }; + } + + const diff = otherIndex - index; + const sign = diff > 0 ? 1 : (diff < 0 ? -1 : 0); + + // Use container height for a full-height slide + const listEl = node.closest('[data-font-list]'); + const h = listEl?.clientHeight ?? 300; + const fromY = sign * h; + + return { + duration: 300, + easing: cubicOut, + css: (t, u) => ` + transform: translateY(${fromY * u}px); + opacity: ${t}; + `, + }; + }, + }); +} diff --git a/src/widgets/ComparisonView/lib/utils/getPretextFontString.test.ts b/src/widgets/ComparisonView/lib/utils/getPretextFontString.test.ts new file mode 100644 index 0000000..40d1645 --- /dev/null +++ b/src/widgets/ComparisonView/lib/utils/getPretextFontString.test.ts @@ -0,0 +1,35 @@ +import { + describe, + expect, + it, +} from 'vitest'; +import { getPretextFontString } from './getPretextFontString'; + +describe('getPretextFontString', () => { + it('correctly formats the font string for pretext', () => { + const weight = 400; + const sizePx = 16; + const fontName = 'Inter'; + const expected = '400 16px "Inter"'; + + expect(getPretextFontString(weight, sizePx, fontName)).toBe(expected); + }); + + it('works with different weight and size', () => { + const weight = 700; + const sizePx = 32; + const fontName = 'Roboto'; + const expected = '700 32px "Roboto"'; + + expect(getPretextFontString(weight, sizePx, fontName)).toBe(expected); + }); + + it('handles font names with spaces', () => { + const weight = 400; + const sizePx = 16; + const fontName = 'Open Sans'; + const expected = '400 16px "Open Sans"'; + + expect(getPretextFontString(weight, sizePx, fontName)).toBe(expected); + }); +}); diff --git a/src/widgets/ComparisonView/lib/utils/getPretextFontString.ts b/src/widgets/ComparisonView/lib/utils/getPretextFontString.ts new file mode 100644 index 0000000..ca9080e --- /dev/null +++ b/src/widgets/ComparisonView/lib/utils/getPretextFontString.ts @@ -0,0 +1,11 @@ +/** + * Formats a font configuration into a string format required by @chenglou/pretext. + * + * @param weight - Numeric font weight (e.g., 400). + * @param sizePx - Font size in pixels. + * @param fontName - The font family name. + * @returns A formatted font string: `"weight sizepx \"fontName\""`. + */ +export function getPretextFontString(weight: number, sizePx: number, fontName: string): string { + return `${weight} ${sizePx}px "${fontName}"`; +} diff --git a/src/widgets/ComparisonView/model/stores/comparisonStore.svelte.ts b/src/widgets/ComparisonView/model/stores/comparisonStore.svelte.ts index 28c90fe..812976a 100644 --- a/src/widgets/ComparisonView/model/stores/comparisonStore.svelte.ts +++ b/src/widgets/ComparisonView/model/stores/comparisonStore.svelte.ts @@ -21,20 +21,22 @@ import { fontStore, getFontUrl, } from '$entities/Font'; -import { - DEFAULT_TYPOGRAPHY_CONTROLS_DATA, - createTypographyControlManager, -} from '$features/SetupFont'; +import { typographySettingsStore } from '$features/SetupFont/model'; import { createPersistentStore } from '$shared/lib'; import { untrack } from 'svelte'; +import { getPretextFontString } from '../../lib'; /** * Storage schema for comparison state */ interface ComparisonState { - /** Font ID for side A (left/top) */ + /** + * Unique identifier for the primary font being compared (Side A) + */ fontAId: string | null; - /** Font ID for side B (right/bottom) */ + /** + * Unique identifier for the secondary font being compared (Side B) + */ fontBId: string | null; } @@ -56,21 +58,33 @@ const storage = createPersistentStore('glyphdiff:comparison', { * storage is empty. */ export class ComparisonStore { - /** Font for side A */ + /** + * The primary font model for Side A (left/top) + */ #fontA = $state(); - /** Font for side B */ + /** + * The secondary font model for Side B (right/bottom) + */ #fontB = $state(); - /** Sample text to display */ + /** + * The preview text string displayed in the comparison area + */ #sampleText = $state('The quick brown fox jumps over the lazy dog'); - /** Whether fonts are loaded and ready to display */ + /** + * Flag indicating if both fonts are successfully loaded and ready for rendering + */ #fontsReady = $state(false); - /** Active side for single-font operations */ + /** + * Currently active side (A or B) for single-font adjustments + */ #side = $state('A'); - /** Slider position for character morphing (0-100) */ + /** + * Interactive slider position (0-100) used for morphing/layout transitions + */ #sliderPosition = $state(50); - /** Typography controls for this comparison */ - #typography = createTypographyControlManager(DEFAULT_TYPOGRAPHY_CONTROLS_DATA, 'glyphdiff:comparison:typography'); - /** TanStack Query-backed batch font fetcher */ + /** + * TanStack Query-backed store for efficient batch font retrieval + */ #batchStore: BatchFontStore; constructor() { @@ -82,16 +96,22 @@ export class ComparisonStore { // Effect 1: Sync batch results → fontA / fontB $effect(() => { const fonts = this.#batchStore.fonts; - if (fonts.length === 0) return; + if (fonts.length === 0) { + return; + } const { fontAId: aId, fontBId: bId } = storage.value; if (aId) { const fa = fonts.find(f => f.id === aId); - if (fa) this.#fontA = fa; + if (fa) { + this.#fontA = fa; + } } if (bId) { const fb = fonts.find(f => f.id === bId); - if (fb) this.#fontB = fb; + if (fb) { + this.#fontB = fb; + } } }); @@ -99,9 +119,11 @@ export class ComparisonStore { $effect(() => { const fa = this.#fontA; const fb = this.#fontB; - const weight = this.#typography.weight; + const weight = typographySettingsStore.weight; - if (!fa || !fb) return; + if (!fa || !fb) { + return; + } const configs: FontLoadRequestConfig[] = []; [fa, fb].forEach(f => { @@ -125,7 +147,9 @@ export class ComparisonStore { // Effect 3: Set default fonts when storage is empty $effect(() => { - if (this.#fontA && this.#fontB) return; + if (this.#fontA && this.#fontB) { + return; + } const fonts = fontStore.fonts; if (fonts.length >= 2) { @@ -137,6 +161,27 @@ export class ComparisonStore { }); } }); + + // Effect 4: Pin fontA/fontB so eviction never removes on-screen fonts + $effect(() => { + const fa = this.#fontA; + const fb = this.#fontB; + const w = typographySettingsStore.weight; + if (fa) { + appliedFontsManager.pin(fa.id, w, fa.features?.isVariable); + } + if (fb) { + appliedFontsManager.pin(fb.id, w, fb.features?.isVariable); + } + return () => { + if (fa) { + appliedFontsManager.unpin(fa.id, w, fa.features?.isVariable); + } + if (fb) { + appliedFontsManager.unpin(fb.id, w, fb.features?.isVariable); + } + }; + }); }); } @@ -152,15 +197,17 @@ export class ComparisonStore { return; } - const weight = this.#typography.weight; - const size = this.#typography.renderedSize; + const weight = typographySettingsStore.weight; + const size = typographySettingsStore.renderedSize; const fontAName = this.#fontA?.name; const fontBName = this.#fontB?.name; - if (!fontAName || !fontBName) return; + if (!fontAName || !fontBName) { + return; + } - const fontAString = `${weight} ${size}px "${fontAName}"`; - const fontBString = `${weight} ${size}px "${fontBName}"`; + const fontAString = getPretextFontString(weight, size, fontAName); + const fontBString = getPretextFontString(weight, size, fontBName); // Check if already loaded to avoid UI flash const isALoaded = document.fonts.check(fontAString); @@ -201,14 +248,9 @@ export class ComparisonStore { }; } - // ── Getters / Setters ───────────────────────────────────────────────────── - - /** Typography control manager */ - get typography() { - return this.#typography; - } - - /** Font for side A */ + /** + * Primary font for comparison (reactive) + */ get fontA() { return this.#fontA; } @@ -218,7 +260,9 @@ export class ComparisonStore { this.updateStorage(); } - /** Font for side B */ + /** + * Secondary font for comparison (reactive) + */ get fontB() { return this.#fontB; } @@ -228,7 +272,9 @@ export class ComparisonStore { this.updateStorage(); } - /** Sample text to display */ + /** + * Shared preview text string (reactive) + */ get text() { return this.#sampleText; } @@ -237,7 +283,9 @@ export class ComparisonStore { this.#sampleText = value; } - /** Active side for single-font operations */ + /** + * Side currently selected for focused manipulation (reactive) + */ get side() { return this.#side; } @@ -246,7 +294,9 @@ export class ComparisonStore { this.#side = value; } - /** Slider position (0-100) for character morphing */ + /** + * Morphing slider position (0-100) used by Character components (reactive) + */ get sliderPosition() { return this.#sliderPosition; } @@ -255,12 +305,16 @@ export class ComparisonStore { this.#sliderPosition = value; } - /** Whether both fonts are selected and loaded */ + /** + * True if both fonts are ready for side-by-side display (reactive) + */ get isReady() { return !!this.#fontA && !!this.#fontB && this.#fontsReady; } - /** Whether currently loading (batch fetch in flight or fonts not yet painted) */ + /** + * True if any font is currently being fetched or loaded (reactive) + */ get isLoading() { return this.#batchStore.isLoading || !this.#fontsReady; } @@ -273,7 +327,7 @@ export class ComparisonStore { this.#fontB = undefined; this.#batchStore.setIds([]); storage.clear(); - this.#typography.reset(); + typographySettingsStore.reset(); } } diff --git a/src/widgets/ComparisonView/model/stores/comparisonStore.test.ts b/src/widgets/ComparisonView/model/stores/comparisonStore.test.ts index 013d450..775f922 100644 --- a/src/widgets/ComparisonView/model/stores/comparisonStore.test.ts +++ b/src/widgets/ComparisonView/model/stores/comparisonStore.test.ts @@ -5,7 +5,9 @@ * Controls network behaviour via vi.spyOn on the proxyFonts API layer. */ -/** @vitest-environment jsdom */ +/** + * @vitest-environment jsdom + */ import type { UnifiedFont } from '$entities/Font'; import { UNIFIED_FONTS } from '$entities/Font/lib/mocks'; @@ -18,8 +20,6 @@ import { vi, } from 'vitest'; -// ── Persistent-store mock ───────────────────────────────────────────────────── - const mockStorage = vi.hoisted(() => { const storage: any = {}; storage._value = { fontAId: null, fontBId: null }; @@ -51,17 +51,19 @@ vi.mock('$shared/lib/helpers/createPersistentStore/createPersistentStore.svelte' createPersistentStore: vi.fn(() => mockStorage), })); -// ── $entities/Font mock — keep real BatchFontStore, stub singletons ─────────── - -vi.mock('$entities/Font', async () => { +vi.mock('$entities/Font', async importOriginal => { + const actual = await importOriginal(); const { BatchFontStore } = await import( '$entities/Font/model/store/batchFontStore.svelte' ); return { + ...actual, BatchFontStore, fontStore: { fonts: [] }, appliedFontsManager: { touch: vi.fn(), + pin: vi.fn(), + unpin: vi.fn(), getFontStatus: vi.fn(), ready: vi.fn(() => Promise.resolve()), }, @@ -69,8 +71,6 @@ vi.mock('$entities/Font', async () => { }; }); -// ── $features/SetupFont mock ────────────────────────────────────────────────── - vi.mock('$features/SetupFont', () => ({ DEFAULT_TYPOGRAPHY_CONTROLS_DATA: [], createTypographyControlManager: vi.fn(() => ({ @@ -80,14 +80,21 @@ vi.mock('$features/SetupFont', () => ({ })), })); -// ── Imports (after mocks) ───────────────────────────────────────────────────── +vi.mock('$features/SetupFont/model', () => ({ + typographySettingsStore: { + weight: 400, + renderedSize: 48, + reset: vi.fn(), + }, +})); -import { fontStore } from '$entities/Font'; +import { + appliedFontsManager, + fontStore, +} from '$entities/Font'; import * as proxyFonts from '$entities/Font/api/proxy/proxyFonts'; import { ComparisonStore } from './comparisonStore.svelte'; -// ── Tests ───────────────────────────────────────────────────────────────────── - describe('ComparisonStore', () => { const mockFontA: UnifiedFont = UNIFIED_FONTS.roboto; // id: 'roboto' const mockFontB: UnifiedFont = UNIFIED_FONTS.openSans; // id: 'open-sans' @@ -114,8 +121,6 @@ describe('ComparisonStore', () => { }); }); - // ── Initialization ──────────────────────────────────────────────────────── - describe('Initialization', () => { it('should create store with initial empty state', () => { const store = new ComparisonStore(); @@ -124,8 +129,6 @@ describe('ComparisonStore', () => { }); }); - // ── Restoration from Storage ────────────────────────────────────────────── - describe('Restoration from Storage (via BatchFontStore)', () => { it('should restore fontA and fontB from stored IDs', async () => { mockStorage._value.fontAId = mockFontA.id; @@ -154,8 +157,6 @@ describe('ComparisonStore', () => { }); }); - // ── Default Fallbacks ───────────────────────────────────────────────────── - describe('Default Fallbacks', () => { it('should update storage with default IDs when storage is empty', async () => { (fontStore as any).fonts = [mockFontA, mockFontB]; @@ -170,8 +171,6 @@ describe('ComparisonStore', () => { }); }); - // ── Loading State ───────────────────────────────────────────────────────── - describe('Aggregate Loading State', () => { it('should be loading initially when storage has IDs', async () => { mockStorage._value.fontAId = mockFontA.id; @@ -187,8 +186,6 @@ describe('ComparisonStore', () => { }); }); - // ── Reset ───────────────────────────────────────────────────────────────── - describe('Reset Functionality', () => { it('should reset all state and clear storage', () => { const store = new ComparisonStore(); @@ -209,4 +206,53 @@ describe('ComparisonStore', () => { expect(store.fontB).toBeUndefined(); }); }); + + describe('Pin / Unpin (eviction guard)', () => { + it('pins fontA and fontB when they are loaded', async () => { + mockStorage._value.fontAId = mockFontA.id; + mockStorage._value.fontBId = mockFontB.id; + vi.spyOn(proxyFonts, 'fetchFontsByIds').mockResolvedValue([mockFontA, mockFontB]); + + new ComparisonStore(); + + await vi.waitFor(() => { + expect(appliedFontsManager.pin).toHaveBeenCalledWith( + mockFontA.id, + 400, + mockFontA.features?.isVariable, + ); + expect(appliedFontsManager.pin).toHaveBeenCalledWith( + mockFontB.id, + 400, + mockFontB.features?.isVariable, + ); + }, { timeout: 2000 }); + }); + + it('unpins the old font when fontA is replaced', async () => { + mockStorage._value.fontAId = mockFontA.id; + mockStorage._value.fontBId = mockFontB.id; + vi.spyOn(proxyFonts, 'fetchFontsByIds').mockResolvedValue([mockFontA, mockFontB]); + + const store = new ComparisonStore(); + await vi.waitFor(() => expect(store.fontA?.id).toBe(mockFontA.id), { timeout: 2000 }); + + const mockFontC: typeof mockFontA = { ...mockFontA, id: 'playfair', name: 'Playfair Display' }; + vi.spyOn(proxyFonts, 'fetchFontsByIds').mockResolvedValue([mockFontC, mockFontB]); + store.fontA = mockFontC; + + await vi.waitFor(() => { + expect(appliedFontsManager.unpin).toHaveBeenCalledWith( + mockFontA.id, + 400, + mockFontA.features?.isVariable, + ); + expect(appliedFontsManager.pin).toHaveBeenCalledWith( + mockFontC.id, + 400, + mockFontC.features?.isVariable, + ); + }, { timeout: 2000 }); + }); + }); }); diff --git a/src/widgets/ComparisonView/ui/Character/Character.svelte b/src/widgets/ComparisonView/ui/Character/Character.svelte index 0269015..c247f15 100644 --- a/src/widgets/ComparisonView/ui/Character/Character.svelte +++ b/src/widgets/ComparisonView/ui/Character/Character.svelte @@ -3,7 +3,8 @@ Renders a single character with morphing animation -->
-
-
-
diff --git a/src/widgets/ComparisonView/ui/Header/Header.svelte b/src/widgets/ComparisonView/ui/Header/Header.svelte index 70f2fcc..eaaab84 100644 --- a/src/widgets/ComparisonView/ui/Header/Header.svelte +++ b/src/widgets/ComparisonView/ui/Header/Header.svelte @@ -5,9 +5,8 @@
{#each chars as c, index} {@render character?.({ char: c.char, index })} diff --git a/src/widgets/ComparisonView/ui/Search/Search.svelte b/src/widgets/ComparisonView/ui/Search/Search.svelte new file mode 100644 index 0000000..f6b2c45 --- /dev/null +++ b/src/widgets/ComparisonView/ui/Search/Search.svelte @@ -0,0 +1,20 @@ + + + +
+ +
diff --git a/src/widgets/ComparisonView/ui/Search/Search.svelte.test.ts b/src/widgets/ComparisonView/ui/Search/Search.svelte.test.ts new file mode 100644 index 0000000..e5a89d2 --- /dev/null +++ b/src/widgets/ComparisonView/ui/Search/Search.svelte.test.ts @@ -0,0 +1,32 @@ +import { filterManager } from '$features/GetFonts'; +import { + render, + screen, +} from '@testing-library/svelte'; +import Search from './Search.svelte'; + +describe('Search', () => { + beforeEach(() => { + filterManager.queryValue = ''; + }); + + describe('Rendering', () => { + it('renders a text input', () => { + render(Search); + expect(screen.getByRole('textbox')).toBeInTheDocument(); + }); + + it('has "Typeface Search" placeholder', () => { + render(Search); + expect(screen.getByPlaceholderText('Typeface Search')).toBeInTheDocument(); + }); + }); + + describe('Value binding', () => { + it('reflects filterManager.queryValue as initial value', () => { + filterManager.queryValue = 'Inter'; + render(Search); + expect(screen.getByRole('textbox')).toHaveValue('Inter'); + }); + }); +}); diff --git a/src/widgets/ComparisonView/ui/Sidebar/Sidebar.svelte b/src/widgets/ComparisonView/ui/Sidebar/Sidebar.svelte index 85b52f9..c44c8f5 100644 --- a/src/widgets/ComparisonView/ui/Sidebar/Sidebar.svelte +++ b/src/widgets/ComparisonView/ui/Sidebar/Sidebar.svelte @@ -5,7 +5,7 @@ Content (font list, controls) is injected via snippets. --> - +
- +
@@ -113,7 +113,7 @@ let showFiltersOpen = $state(true);
- +
@@ -122,13 +122,13 @@ let showFiltersOpen = $state(true);
- +
- +

Font Browser

@@ -145,7 +145,7 @@ let showFiltersOpen = $state(true);
- +

@@ -157,7 +157,7 @@ let showFiltersOpen = $state(true);

- +

Resize browser to see responsive layout

diff --git a/src/widgets/FontSearch/ui/FontSearch/FontSearch.svelte b/src/widgets/FontSearch/ui/FontSearch/FontSearch.svelte index f7a5aa9..30c6ca4 100644 --- a/src/widgets/FontSearch/ui/FontSearch/FontSearch.svelte +++ b/src/widgets/FontSearch/ui/FontSearch/FontSearch.svelte @@ -65,6 +65,7 @@ function toggleFilters() { id="font-search" class="w-full" placeholder="Typeface Search" + aria-label="Search typefaces" bind:value={filterManager.queryValue} fullWidth /> diff --git a/src/widgets/FontSearch/ui/FontSearchSection/FontSearchSection.svelte b/src/widgets/FontSearch/ui/FontSearchSection/FontSearchSection.svelte index a39f92c..361c541 100644 --- a/src/widgets/FontSearch/ui/FontSearchSection/FontSearchSection.svelte +++ b/src/widgets/FontSearch/ui/FontSearchSection/FontSearchSection.svelte @@ -5,7 +5,7 @@ + + + {#snippet template()} +
+
+
+ {/snippet} +
+ + + {#snippet template()} +
+

Footer should be hidden on mobile.

+
+
+ {/snippet} +
diff --git a/src/widgets/Footer/ui/Footer/Footer.svelte b/src/widgets/Footer/ui/Footer/Footer.svelte new file mode 100644 index 0000000..bb12a42 --- /dev/null +++ b/src/widgets/Footer/ui/Footer/Footer.svelte @@ -0,0 +1,44 @@ + + + +
+ + {#if isVertical} +
+
+ + GlyphDiff © 2025 — {currentYear} + +
+ {/if} + + +
+ +
+
diff --git a/src/widgets/Footer/ui/Footer/Footer.svelte.test.ts b/src/widgets/Footer/ui/Footer/Footer.svelte.test.ts new file mode 100644 index 0000000..420c95b --- /dev/null +++ b/src/widgets/Footer/ui/Footer/Footer.svelte.test.ts @@ -0,0 +1,54 @@ +import { + render, + screen, +} from '@testing-library/svelte'; +import { setContext } from 'svelte'; +import Footer from './Footer.svelte'; + +// Mock component to provide context +import ContextWrapper from '$shared/lib/providers/ResponsiveProvider/ResponsiveProvider.svelte'; + +describe('Footer', () => { + const currentYear = new Date().getFullYear(); + + it('renders on desktop', () => { + // Mock responsive context + const mockResponsive = { + isDesktop: true, + isDesktopLarge: false, + }; + + const { container } = render(Footer, { + context: new Map([['responsive', mockResponsive]]), + }); + + expect(screen.getByText(`GlyphDiff © 2025 — ${currentYear}`)).toBeInTheDocument(); + expect(screen.getByText('allmy.work')).toBeInTheDocument(); + }); + + it('renders on large desktop', () => { + const mockResponsive = { + isDesktop: false, + isDesktopLarge: true, + }; + + render(Footer, { + context: new Map([['responsive', mockResponsive]]), + }); + + expect(screen.getByText(`GlyphDiff © 2025 — ${currentYear}`)).toBeInTheDocument(); + }); + + it('does not render on mobile or tablet', () => { + const mockResponsive = { + isDesktop: false, + isDesktopLarge: false, + }; + + render(Footer, { + context: new Map([['responsive', mockResponsive]]), + }); + + expect(screen.queryByText(/GlyphDiff/)).not.toBeInTheDocument(); + }); +}); diff --git a/src/widgets/Footer/ui/FooterLink/FooterLink.svelte b/src/widgets/Footer/ui/FooterLink/FooterLink.svelte new file mode 100644 index 0000000..942125b --- /dev/null +++ b/src/widgets/Footer/ui/FooterLink/FooterLink.svelte @@ -0,0 +1,55 @@ + + + + + {text} + {#snippet icon()} + + {/snippet} + diff --git a/src/widgets/SampleList/model/stores/layoutStore/layoutStore.svelte.ts b/src/widgets/SampleList/model/stores/layoutStore/layoutStore.svelte.ts index cf25fb5..a2975c5 100644 --- a/src/widgets/SampleList/model/stores/layoutStore/layoutStore.svelte.ts +++ b/src/widgets/SampleList/model/stores/layoutStore/layoutStore.svelte.ts @@ -22,6 +22,9 @@ import { responsiveManager } from '$shared/lib'; export type LayoutMode = 'list' | 'grid'; interface LayoutConfig { + /** + * Active display mode (list | grid) + */ mode: LayoutMode; } @@ -40,9 +43,13 @@ const DEFAULT_CONFIG: LayoutConfig = { * calculation. Persists user preference to localStorage. */ class LayoutManager { - /** Current layout mode */ + /** + * Reactive layout mode state + */ #mode = $state(DEFAULT_CONFIG.mode); - /** Persistent storage for layout preference */ + /** + * Persistence layer for saving layout between sessions + */ #store = createPersistentStore(STORAGE_KEY, DEFAULT_CONFIG); constructor() { @@ -53,7 +60,9 @@ class LayoutManager { } } - /** Current layout mode ('list' or 'grid') */ + /** + * Current active layout mode + */ get mode(): LayoutMode { return this.#mode; } @@ -66,12 +75,16 @@ class LayoutManager { return responsiveManager.isMobile || responsiveManager.isTabletPortrait ? SM_GAP_PX : MD_GAP_PX; } - /** Whether currently in list mode */ + /** + * True if currently showing a single-column list + */ get isListMode(): boolean { return this.#mode === 'list'; } - /** Whether currently in grid mode */ + /** + * True if currently showing a multi-column grid + */ get isGridMode(): boolean { return this.#mode === 'grid'; } diff --git a/src/widgets/SampleList/model/stores/layoutStore/layoutStore.test.ts b/src/widgets/SampleList/model/stores/layoutStore/layoutStore.test.ts index 028a174..cd3483d 100644 --- a/src/widgets/SampleList/model/stores/layoutStore/layoutStore.test.ts +++ b/src/widgets/SampleList/model/stores/layoutStore/layoutStore.test.ts @@ -1,4 +1,6 @@ -/** @vitest-environment jsdom */ +/** + * @vitest-environment jsdom + */ import { afterEach, diff --git a/src/widgets/SampleList/ui/LayoutSwitch/LayoutSwitch.svelte.test.ts b/src/widgets/SampleList/ui/LayoutSwitch/LayoutSwitch.svelte.test.ts new file mode 100644 index 0000000..bf591bb --- /dev/null +++ b/src/widgets/SampleList/ui/LayoutSwitch/LayoutSwitch.svelte.test.ts @@ -0,0 +1,73 @@ +import { + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/svelte'; +import { layoutManager } from '../../model'; +import LayoutSwitch from './LayoutSwitch.svelte'; + +describe('LayoutSwitch', () => { + beforeEach(() => { + layoutManager.reset(); + }); + + describe('Rendering', () => { + it('renders two icon buttons', () => { + render(LayoutSwitch); + expect(screen.getAllByRole('button')).toHaveLength(2); + }); + }); + + describe('Active state', () => { + it('list button is active in list mode', () => { + render(LayoutSwitch); + const [listBtn] = screen.getAllByRole('button'); + expect(listBtn).toHaveClass('text-brand'); + }); + + it('grid button is not active in list mode', () => { + render(LayoutSwitch); + const [, gridBtn] = screen.getAllByRole('button'); + expect(gridBtn).not.toHaveClass('text-brand'); + }); + + it('grid button is active in grid mode', () => { + layoutManager.setMode('grid'); + render(LayoutSwitch); + const [, gridBtn] = screen.getAllByRole('button'); + expect(gridBtn).toHaveClass('text-brand'); + }); + + it('list button is not active in grid mode', () => { + layoutManager.setMode('grid'); + render(LayoutSwitch); + const [listBtn] = screen.getAllByRole('button'); + expect(listBtn).not.toHaveClass('text-brand'); + }); + }); + + describe('Interaction', () => { + it('clicking second button switches to grid mode', async () => { + render(LayoutSwitch); + expect(layoutManager.mode).toBe('list'); + await fireEvent.click(screen.getAllByRole('button')[1]); + expect(layoutManager.mode).toBe('grid'); + }); + + it('clicking first button when in grid mode switches to list mode', async () => { + layoutManager.setMode('grid'); + render(LayoutSwitch); + await fireEvent.click(screen.getAllByRole('button')[0]); + expect(layoutManager.mode).toBe('list'); + }); + + it('active button updates reactively after toggle', async () => { + render(LayoutSwitch); + const [listBtn, gridBtn] = screen.getAllByRole('button'); + await fireEvent.click(gridBtn); + await waitFor(() => expect(gridBtn).toHaveClass('text-brand')); + await waitFor(() => expect(listBtn).not.toHaveClass('text-brand')); + }); + }); +}); diff --git a/src/widgets/SampleList/ui/SampleList/SampleList.stories.svelte b/src/widgets/SampleList/ui/SampleList/SampleList.stories.svelte index d6552f3..dec9c4c 100644 --- a/src/widgets/SampleList/ui/SampleList/SampleList.stories.svelte +++ b/src/widgets/SampleList/ui/SampleList/SampleList.stories.svelte @@ -89,7 +89,7 @@ const { Story } = defineMeta({ }); - +
@@ -101,13 +101,13 @@ const { Story } = defineMeta({
- +
- +
@@ -119,7 +119,7 @@ const { Story } = defineMeta({
- +
@@ -131,7 +131,7 @@ const { Story } = defineMeta({
- +
@@ -143,7 +143,7 @@ const { Story } = defineMeta({
- +
diff --git a/src/widgets/SampleList/ui/SampleList/SampleList.svelte b/src/widgets/SampleList/ui/SampleList/SampleList.svelte index 69f4b3f..2dbda8f 100644 --- a/src/widgets/SampleList/ui/SampleList/SampleList.svelte +++ b/src/widgets/SampleList/ui/SampleList/SampleList.svelte @@ -14,7 +14,7 @@ import { import { FontSampler } from '$features/DisplayFont'; import { TypographyMenu, - controlManager, + typographySettingsStore, } from '$features/SetupFont'; import { throttle } from '$shared/lib/utils'; import { Skeleton } from '$shared/ui'; @@ -46,7 +46,9 @@ let isAboveMiddle = $state(false); let containerWidth = $state(0); const checkPosition = throttle(() => { - if (!wrapper) return; + if (!wrapper) { + return; + } const rect = wrapper.getBoundingClientRect(); const viewportMiddle = innerHeight / 2; @@ -60,11 +62,11 @@ const checkPosition = throttle(() => { const fontRowHeight = $derived.by(() => createFontRowSizeResolver({ getFonts: () => fontStore.fonts, - getWeight: () => controlManager.weight, + getWeight: () => typographySettingsStore.weight, getPreviewText: () => text, getContainerWidth: () => containerWidth, - getFontSizePx: () => controlManager.renderedSize, - getLineHeightPx: () => controlManager.height * controlManager.renderedSize, + getFontSizePx: () => typographySettingsStore.renderedSize, + getLineHeightPx: () => typographySettingsStore.height * typographySettingsStore.renderedSize, getStatus: key => appliedFontsManager.statuses.get(key), contentHorizontalPadding: SAMPLER_CONTENT_PADDING_X, chromeHeight: SAMPLER_CHROME_HEIGHT, @@ -97,7 +99,7 @@ const fontRowHeight = $derived.by(() => { cleanup(); + queryClient.clear(); }); // Mock window.matchMedia for components that use it diff --git a/vitest.setup.jsdom.ts b/vitest.setup.jsdom.ts new file mode 100644 index 0000000..0aa5e51 --- /dev/null +++ b/vitest.setup.jsdom.ts @@ -0,0 +1,46 @@ +import { vi } from 'vitest'; + +// jsdom lacks ResizeObserver +global.ResizeObserver = class { + observe = vi.fn(); + unobserve = vi.fn(); + disconnect = vi.fn(); +} as unknown as typeof ResizeObserver; + +// jsdom lacks Web Animations API +Element.prototype.animate = vi.fn().mockReturnValue({ + onfinish: null, + cancel: vi.fn(), + finish: vi.fn(), + pause: vi.fn(), + play: vi.fn(), +}); + +// jsdom lacks SVG geometry methods +(SVGElement.prototype as any).getTotalLength = vi.fn(() => 0); + +// Robust localStorage mock for jsdom environment +const localStorageMock = (() => { + let store: Record = {}; + return { + getItem: vi.fn((key: string) => store[key] || null), + setItem: vi.fn((key: string, value: string) => { + store[key] = value.toString(); + }), + removeItem: vi.fn((key: string) => { + delete store[key]; + }), + clear: vi.fn(() => { + store = {}; + }), + key: vi.fn((index: number) => Object.keys(store)[index] || null), + get length() { + return Object.keys(store).length; + }, + }; +})(); + +Object.defineProperty(window, 'localStorage', { + value: localStorageMock, + writable: true, +});