diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 0000000..7b519c7 --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,195 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["import"], + "categories": { + "correctness": "error", + "suspicious": "warn", + "perf": "warn", + // style/restriction off: opt-in, contradictory grab-bags. Wanted rules enabled individually below. + "style": "off", + "restriction": "off" + }, + "env": { + "browser": true, + "es2021": true + }, + "ignorePatterns": [ + "node_modules", + "dist", + "build", + ".svelte-kit", + ".vercel", + "*.config.js", + "*.config.ts" + ], + "rules": { + "no-console": "warn", + "no-debugger": "error", + "no-alert": "warn", + + // no-cycle resolves $-aliases via tsconfig auto-discovery (no resolver config in oxlint) + "import/no-cycle": "error", + "import/no-duplicates": "warn", + "import/no-unassigned-import": "off", // CSS/side-effect imports are intentional + + "no-sequences": "error", + "no-underscore-dangle": "off", + "no-shadow": "warn", + "no-implicit-coercion": "warn", + "no-await-in-loop": "warn", + "no-return-assign": "warn", + "no-new": "warn", + "no-unneeded-ternary": "warn" + }, + // FSD boundaries. oxlint has no zone rule, so layer/segment direction is enforced + // with no-restricted-imports patterns scoped per glob. Layer order (high->low): + // app(exempt top shell) > routes > widgets > features > entities > shared. + // A layer bans imports from itself (cross-slice via alias) and every layer above. + // Overrides are LAST-WINS, not merged: a file matching two overrides keeps only the + // last rule config. So the domain override (below) is a self-contained superset, and + // the test/story override (last) fully disables boundary checks for those files. + "overrides": [ + // shared = lowest layer: imports nothing above it + { + "files": ["src/shared/**"], + "rules": { + "no-restricted-imports": ["error", { + "patterns": [ + { + "group": [ + "$app", + "$app/*", + "$routes", + "$routes/*", + "$widgets", + "$widgets/*", + "$features", + "$features/*", + "$entities", + "$entities/*" + ], + "message": "FSD layer violation: `shared` is the lowest layer and may not import from any layer above it." + } + ] + }] + } + }, + // entities: import shared only; no other entity via alias; interior ui<-only-ui + { + "files": ["src/entities/**"], + "rules": { + "no-restricted-imports": ["error", { + "patterns": [ + { + "group": ["$app", "$app/*", "$routes", "$routes/*", "$widgets", "$widgets/*", "$features", "$features/*"], + "message": "FSD layer violation: `entities` may only import from `shared`." + }, + { + "group": ["$entities", "$entities/*"], + "message": "FSD cross-slice violation: do not import another entity via its alias. Use relative imports inside your own slice; invert the dependency through a higher layer for cross-slice needs." + }, + { + "group": ["../ui", "../ui/*", "../../ui/*"], + "message": "FSD segment violation: only `ui` may import `ui`. Interior direction is ui -> model -> domain." + } + ] + }] + } + }, + // features: import entities/shared only; no other feature via alias + { + "files": ["src/features/**"], + "rules": { + "no-restricted-imports": ["error", { + "patterns": [ + { + "group": ["$app", "$app/*", "$routes", "$routes/*", "$widgets", "$widgets/*"], + "message": "FSD layer violation: `features` may only import from `entities` and `shared`." + }, + { + "group": ["$features", "$features/*"], + "message": "FSD cross-slice violation: do not import another feature via its alias. Invert the dependency through a higher layer (widget/route)." + }, + { + "group": ["../ui", "../ui/*", "../../ui/*"], + "message": "FSD segment violation: only `ui` may import `ui`. Interior direction is ui -> model -> domain." + } + ] + }] + } + }, + // widgets: import features/entities/shared only; no other widget via alias + { + "files": ["src/widgets/**"], + "rules": { + "no-restricted-imports": ["error", { + "patterns": [ + { + "group": ["$app", "$app/*", "$routes", "$routes/*"], + "message": "FSD layer violation: `widgets` may only import from `features`, `entities`, and `shared`." + }, + { + "group": ["$widgets", "$widgets/*"], + "message": "FSD cross-slice violation: do not import another widget via its alias. Invert the dependency through the route layer." + }, + { + "group": ["../ui", "../ui/*", "../../ui/*"], + "message": "FSD segment violation: only `ui` may import `ui`. Interior direction is ui -> model -> domain." + } + ] + }] + } + }, + // routes: top of the FSD list, imports any layer below; only app is above it + { + "files": ["src/routes/**"], + "rules": { + "no-restricted-imports": ["error", { + "patterns": [ + { "group": ["$app", "$app/*"], "message": "FSD layer violation: `routes` may not import from `app`." } + ] + }] + } + }, + // domain (FSD+): pure logic. Imports NO layer (not even shared) and no sibling + // model/ui segment. Superset: wins over the layer override above for these files. + { + "files": ["src/**/domain/**"], + "rules": { + "no-restricted-imports": ["error", { + "patterns": [ + { + "group": [ + "$app", + "$app/*", + "$routes", + "$routes/*", + "$widgets", + "$widgets/*", + "$features", + "$features/*", + "$entities", + "$entities/*", + "$shared", + "$shared/*" + ], + "message": "FSD+ domain isolation: `domain` is pure business logic and may not import any layer (including `shared`). Allowed: relative imports within `domain` and framework-agnostic npm packages." + }, + { + "group": ["../model", "../model/*", "../../model/*", "../ui", "../ui/*", "../../ui/*"], + "message": "FSD+ domain isolation: `domain` may not import sibling `model` or `ui` segments. Dependency flows ui -> model -> domain, never back." + } + ] + }] + } + }, + // tests/stories/fixtures legitimately cross-import (e.g. $entities/Font/testing). + // Must be LAST so last-wins disables boundary checks for them. + { + "files": ["**/*.test.ts", "**/*.spec.ts", "**/*.stories.svelte", "src/**/testing/**"], + "rules": { + "no-restricted-imports": "off" + } + } + ] +} diff --git a/e2e/preview-text.test.ts b/e2e/preview-text.test.ts index f138363..c3e60f5 100644 --- a/e2e/preview-text.test.ts +++ b/e2e/preview-text.test.ts @@ -8,9 +8,9 @@ test.describe('preview text', () => { await comparison.pickPair('Inter', 'Roboto'); await comparison.setPreviewText('Sphinx'); - // Each grapheme renders as a `.char-wrap` cell in the slider once - // both fonts are loaded. Six glyphs → six cells. - await expect(comparison.slider.locator('.char-wrap')).toHaveCount(6); + // Window chars render as `.char-wrap` cells for crossfade. + // With WINDOW_SIZE=5, "Sphinx" (6 chars) fits 5 in the window. + await expect(comparison.slider.locator('.char-wrap')).toHaveCount(5); }); test('preserves the typed value in the input', async ({ comparison }) => { diff --git a/oxlint.json b/oxlint.json deleted file mode 100644 index 5d0d57b..0000000 --- a/oxlint.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "categories": { - "correctness": "error", - "suspicious": "warn", - "perf": "warn", - "style": "warn", - "restriction": "error" - }, - "env": { - "browser": true, - "es2021": true - }, - "ignore": [ - "node_modules", - "dist", - "build", - ".svelte-kit", - ".vercel", - "*.config.js", - "*.config.ts" - ], - "rules": { - "no-console": "off", - "no-debugger": "error", - "no-alert": "warn" - } -} diff --git a/package.json b/package.json index e42ae0a..f9f5b58 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,10 @@ "version": "0.0.1", "packageManager": "yarn@4.11.0", "type": "module", + "sideEffects": [ + "*.css", + "**/router.ts" + ], "scripts": { "dev": "vite", "build": "vite build", @@ -65,6 +69,7 @@ }, "dependencies": { "@chenglou/pretext": "0.0.6", - "@tanstack/svelte-query": "6.1.28" + "@tanstack/svelte-query": "6.1.28", + "sv-router": "^0.16.3" } } diff --git a/src/app/App.svelte b/src/app/App.svelte index 38a8bce..ad5a97e 100644 --- a/src/app/App.svelte +++ b/src/app/App.svelte @@ -6,21 +6,27 @@ /** * App Component * - * Application entry point component. Wraps the main page route within the shared + * Application entry point component. Wraps the active route within the shared * layout shell. This is the root component mounted by the application. * * Structure: * - QueryProvider provides TanStack Query client for data fetching * - Layout provides sidebar, header/footer, and page container - * - Page renders the current route content + * - Router renders the matched route component */ -import Page from '$routes/Page.svelte'; -import { QueryProvider } from './providers'; +import '$routes/router'; +import { Router } from 'sv-router'; +import { + AppBindingsProvider, + QueryProvider, +} from './providers'; import Layout from './ui/Layout.svelte'; - - - + + + + + diff --git a/src/app/providers/AppBindings.svelte b/src/app/providers/AppBindings.svelte new file mode 100644 index 0000000..3640dda --- /dev/null +++ b/src/app/providers/AppBindings.svelte @@ -0,0 +1,24 @@ + + + +{@render children?.()} diff --git a/src/app/providers/QueryProvider.svelte b/src/app/providers/QueryProvider.svelte index 8806e54..eeb42b7 100644 --- a/src/app/providers/QueryProvider.svelte +++ b/src/app/providers/QueryProvider.svelte @@ -6,7 +6,7 @@ descendants of this provider. --> diff --git a/src/app/providers/index.ts b/src/app/providers/index.ts index 80a7590..b8363e6 100644 --- a/src/app/providers/index.ts +++ b/src/app/providers/index.ts @@ -1 +1,2 @@ +export { default as AppBindingsProvider } from './AppBindings.svelte'; export { default as QueryProvider } from './QueryProvider.svelte'; diff --git a/src/app/ui/Layout.svelte b/src/app/ui/Layout.svelte index 40075be..753d6f9 100644 --- a/src/app/ui/Layout.svelte +++ b/src/app/ui/Layout.svelte @@ -3,7 +3,7 @@ Application shell with providers and page wrapper --> diff --git a/src/features/DisplayFont/ui/FontSampler/FontSampler.stories.svelte b/src/entities/Font/ui/FontSampler/FontSampler.stories.svelte similarity index 82% rename from src/features/DisplayFont/ui/FontSampler/FontSampler.stories.svelte rename to src/entities/Font/ui/FontSampler/FontSampler.stories.svelte index ffc2a3f..b01a5c8 100644 --- a/src/features/DisplayFont/ui/FontSampler/FontSampler.stories.svelte +++ b/src/entities/Font/ui/FontSampler/FontSampler.stories.svelte @@ -4,7 +4,7 @@ import { defineMeta } from '@storybook/addon-svelte-csf'; import FontSampler from './FontSampler.svelte'; const { Story } = defineMeta({ - title: 'Features/FontSampler', + title: 'Entities/Font/FontSampler', component: FontSampler, tags: ['autodocs'], parameters: { @@ -21,6 +21,11 @@ const { Story } = defineMeta({ control: 'object', description: 'Font information object', }, + status: { + control: 'select', + options: ['loading', 'loaded', 'error'], + description: 'Font-load status, supplied by the composing widget and forwarded to FontApplicator', + }, text: { control: 'text', description: 'Editable sample text (two-way bindable)', @@ -34,8 +39,8 @@ const { Story } = defineMeta({ {#snippet template(args: ComponentProps)} @@ -101,9 +116,11 @@ const mockGeorgia: UnifiedFont = { name="Long Text" args={{ font: mockGeorgia, + status: 'loaded', text: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.', index: 1, + typography: mockTypography, }} > {#snippet template(args: ComponentProps)} diff --git a/src/features/DisplayFont/ui/FontSampler/FontSampler.svelte b/src/entities/Font/ui/FontSampler/FontSampler.svelte similarity index 68% rename from src/features/DisplayFont/ui/FontSampler/FontSampler.svelte rename to src/entities/Font/ui/FontSampler/FontSampler.svelte index 614301b..98e82d5 100644 --- a/src/features/DisplayFont/ui/FontSampler/FontSampler.svelte +++ b/src/entities/Font/ui/FontSampler/FontSampler.svelte @@ -4,11 +4,6 @@ Visual design matches FontCard: sharp corners, red hover accent, header stats. --> @@ -67,9 +100,8 @@ const stats = $derived([ min-h-60 rounded-none " - style:font-weight={typographySettingsStore.weight} + style:font-weight={typography.weight} > -
- {fontType} + {font?.category} {/if} @@ -130,19 +162,18 @@ const stats = $derived([
-
- +
- +
{#each stats as stat, i} @@ -154,7 +185,6 @@ const stats = $derived([ {/each}
-
import { debounce } from '$shared/lib/utils'; -import { - Skeleton, - VirtualList, -} from '$shared/ui'; +import { VirtualList } from '$shared/ui'; import type { ComponentProps, Snippet, } from 'svelte'; import { fade } from 'svelte/transition'; -import { getFontUrl } from '../../lib'; +import { createFontLoadRequestContfig } from '../../lib/createFontLoadRequestContfig/createFontLoadRequestContfig'; import { type FontLoadRequestConfig, type UnifiedFont, - fontCatalogStore, - fontLifecycleManager, + getFontCatalog, + getFontLifecycleManager, } from '../../model'; interface Props extends @@ -55,17 +52,28 @@ let { ...rest }: Props = $props(); -const isLoading = $derived( - fontCatalogStore.isFetching || fontCatalogStore.isLoading, -); +const fontCatalog = getFontCatalog(); +const fontLifecycleManager = getFontLifecycleManager(); + +const isLoading = $derived(fontCatalog?.isLoading); +const isFetching = $derived(fontCatalog.isFetching); +const hasMore = $derived(fontCatalog?.pagination?.hasMore); +const fonts = $derived(fontCatalog.fonts); +const total = $derived(fontCatalog?.pagination.total); let visibleFonts = $state([]); -let isCatchingUp = $state(false); +let isCatchingUp = $state(false); -const showInitialSkeleton = $derived(!!skeleton && isLoading && fontCatalogStore.fonts.length === 0); -const showCatchupSkeleton = $derived(!!skeleton && isCatchingUp); +const showInitialSkeleton = $derived.by(() => ( + !!skeleton && (isLoading || isFetching) && fontCatalog.fonts.length === 0 +)); +const showCatchupSkeleton = $derived.by(() => ( + !!skeleton && isCatchingUp +)); // Settled query with no matches — empty state replaces the (otherwise blank) list. -const showEmpty = $derived(!!empty && !isLoading && !isCatchingUp && fontCatalogStore.fonts.length === 0); +const showEmpty = $derived.by(() => ( + !!empty && !(isLoading || isFetching) && !isCatchingUp && fontCatalog.fonts.length === 0 +)); function handleInternalVisibleChange(items: UnifiedFont[]) { visibleFonts = items; @@ -79,12 +87,12 @@ function handleInternalVisibleChange(items: UnifiedFont[]) { * font files for thousands of intermediate fonts. */ async function handleJump(targetIndex: number) { - if (isCatchingUp || !fontCatalogStore.pagination.hasMore) { + if (isCatchingUp || !hasMore) { return; } isCatchingUp = true; try { - await fontCatalogStore.fetchAllPagesTo(targetIndex); + await fontCatalog.fetchAllPagesTo(targetIndex); } finally { isCatchingUp = false; } @@ -105,13 +113,7 @@ $effect(() => { if (isCatchingUp) { return; } - const configs: FontLoadRequestConfig[] = visibleFonts.flatMap(item => { - const url = getFontUrl(item, weight); - if (!url) { - return []; - } - return [{ id: item.id, name: item.name, weight, url, isVariable: item.features?.isVariable }]; - }); + const configs = visibleFonts.flatMap(item => createFontLoadRequestContfig(item, weight)); if (configs.length > 0) { debouncedTouch(configs); } @@ -137,13 +139,11 @@ $effect(() => { * Load more fonts by moving to the next page */ function loadMore() { - if ( - !fontCatalogStore.pagination.hasMore - || fontCatalogStore.isFetching - ) { + if (!hasMore || isFetching) { return; } - fontCatalogStore.nextPage(); + + fontCatalog.nextPage(); } /** @@ -153,12 +153,10 @@ function loadMore() { * of the loaded items. Only fetches if there are more pages available. */ function handleNearBottom(_lastVisibleIndex: number) { - const { hasMore } = fontCatalogStore.pagination; - // VirtualList already checks if we're near the bottom of loaded items. // Guard isCatchingUp: fetchAllPagesTo bypasses TQ so isFetching stays false // during batch catch-up, which would otherwise let nextPage() race with it. - if (hasMore && !fontCatalogStore.isFetching && !isCatchingUp) { + if (hasMore && !isFetching && !isCatchingUp) { loadMore(); } } @@ -177,9 +175,9 @@ function handleNearBottom(_lastVisibleIndex: number) { {:else} [] = [ + { + id: 'font_size', + value: DEFAULT_FONT_SIZE, + max: MAX_FONT_SIZE, + min: MIN_FONT_SIZE, + step: FONT_SIZE_STEP, + increaseLabel: 'Increase Font Size', + decreaseLabel: 'Decrease Font Size', + controlLabel: 'Size', + }, + { + id: 'font_weight', + value: DEFAULT_FONT_WEIGHT, + max: MAX_FONT_WEIGHT, + min: MIN_FONT_WEIGHT, + step: FONT_WEIGHT_STEP, + increaseLabel: 'Increase Font Weight', + decreaseLabel: 'Decrease Font Weight', + controlLabel: 'Weight', + }, + { + id: 'line_height', + value: DEFAULT_LINE_HEIGHT, + max: MAX_LINE_HEIGHT, + min: MIN_LINE_HEIGHT, + step: LINE_HEIGHT_STEP, + increaseLabel: 'Increase Line Height', + decreaseLabel: 'Decrease Line Height', + controlLabel: 'Leading', + }, + { + id: 'letter_spacing', + value: DEFAULT_LETTER_SPACING, + max: MAX_LETTER_SPACING, + min: MIN_LETTER_SPACING, + step: LETTER_SPACING_STEP, + increaseLabel: 'Increase Letter Spacing', + decreaseLabel: 'Decrease Letter Spacing', + controlLabel: 'Tracking', + }, +]; diff --git a/src/features/AdjustTypography/model/index.ts b/src/features/AdjustTypography/model/index.ts index 67419fd..6e26ac4 100644 --- a/src/features/AdjustTypography/model/index.ts +++ b/src/features/AdjustTypography/model/index.ts @@ -1,5 +1,10 @@ +export { + MULTIPLIER_L, + MULTIPLIER_M, + MULTIPLIER_S, +} from './const/const'; export { createTypographySettingsStore, + getTypographySettingsStore, type TypographySettingsStore, - typographySettingsStore, } from './store/typographySettingsStore/typographySettingsStore.svelte'; diff --git a/src/features/AdjustTypography/model/store/typographySettingsStore/typographySettingsStore.svelte.ts b/src/features/AdjustTypography/model/store/typographySettingsStore/typographySettingsStore.svelte.ts index 58eabd9..22576a9 100644 --- a/src/features/AdjustTypography/model/store/typographySettingsStore/typographySettingsStore.svelte.ts +++ b/src/features/AdjustTypography/model/store/typographySettingsStore/typographySettingsStore.svelte.ts @@ -11,22 +11,27 @@ */ import { - type ControlId, DEFAULT_FONT_SIZE, DEFAULT_FONT_WEIGHT, DEFAULT_LETTER_SPACING, DEFAULT_LINE_HEIGHT, - DEFAULT_TYPOGRAPHY_CONTROLS_DATA, -} from '$entities/Font'; + // Deep path (not the root barrel) on purpose: pulls only these pure + // constants, not the entity's UI/store graph (+ @tanstack) — keeps this + // feature store and its spec light at import. See audit D-1. +} from '$entities/Font/model/const/const'; import { - type ControlDataModel, - type ControlModel, type PersistentStore, - type TypographyControl, createPersistentStore, - createTypographyControl, + createSingleton, } from '$shared/lib'; +import type { NumericControl } from '$shared/ui'; import { SvelteMap } from 'svelte/reactivity'; +import { DEFAULT_TYPOGRAPHY_CONTROLS_DATA } from '../../const/const'; +import type { + ControlId, + ControlModel, +} from '../../types/typography'; +import { createTypographyControl } from '../../typographyControl/createTypographyControl.svelte'; /** * Epsilon for detecting "significant" base-size changes when reconciling @@ -36,7 +41,7 @@ import { SvelteMap } from 'svelte/reactivity'; */ const BASE_SIZE_EPSILON = 0.01; -type ControlOnlyFields = Omit, keyof ControlDataModel>; +type ControlOnlyFields = Omit, 'value' | 'min' | 'max' | 'step'>; /** * A control with its associated instance @@ -45,7 +50,7 @@ export interface Control extends ControlOnlyFields { /** * The reactive typography control instance */ - instance: TypographyControl; + instance: NumericControl; } /** @@ -93,6 +98,12 @@ export class TypographySettingsStore { * The underlying font size before responsive scaling is applied */ #baseSize = $state(DEFAULT_FONT_SIZE); + /** + * Disposes the $effect.root that backs the storage-sync effects. + * $effect.root lives outside component lifecycle, so callers must invoke + * destroy() to avoid leaking the subscriptions. + */ + #disposeEffects: () => void; constructor(configs: ControlModel[], storage: PersistentStore) { this.#storage = storage; @@ -116,7 +127,7 @@ export class TypographySettingsStore { // The Sync Effect (UI -> Storage) // We access .value explicitly to ensure Svelte 5 tracks the dependency - $effect.root(() => { + this.#disposeEffects = $effect.root(() => { $effect(() => { // EXPLICIT DEPENDENCIES: Accessing these triggers the effect const fontSize = this.#baseSize; @@ -154,6 +165,14 @@ export class TypographySettingsStore { }); } + /** + * Tears down the storage-sync effects. Call on unmount / store disposal. + */ + destroy(): void { + this.#disposeEffects(); + this.#storage.destroy(); + } + /** * Gets initial value for a control from storage or defaults */ @@ -288,9 +307,6 @@ export class TypographySettingsStore { if (c.id === 'font_size') { c.instance.value = defaults.fontSize * this.#multiplier; } else { - // Map storage key to control id - const key = c.id.replace('_', '') as keyof TypographySettings; - // Simplified for brevity, you'd map these properly: if (c.id === 'font_weight') { c.instance.value = defaults.fontWeight; } @@ -335,10 +351,19 @@ export function createTypographySettingsStore( return new TypographySettingsStore(configs, storage); } +export type TypographySettingsStoreInstance = ReturnType; + /** - * App-wide typography settings singleton, keyed for the comparison view. + * App-wide typography settings store, keyed for the comparison view. + * Created on first access so its persistent-store sync effects aren't set up + * at module load. */ -export const typographySettingsStore = createTypographySettingsStore( - DEFAULT_TYPOGRAPHY_CONTROLS_DATA, - COMPARISON_STORAGE_KEY, +const typographySettingsStore = createSingleton( + () => createTypographySettingsStore(DEFAULT_TYPOGRAPHY_CONTROLS_DATA, COMPARISON_STORAGE_KEY), + instance => instance.destroy(), ); + +export const getTypographySettingsStore = typographySettingsStore.get; + +// test-only reset, so specs don't share persisted typography state or leak effects +export const __resetTypographySettingsStore = typographySettingsStore.reset; diff --git a/src/features/AdjustTypography/model/store/typographySettingsStore/typographySettingsStore.test.ts b/src/features/AdjustTypography/model/store/typographySettingsStore/typographySettingsStore.test.ts index 094bf75..cbb0390 100644 --- a/src/features/AdjustTypography/model/store/typographySettingsStore/typographySettingsStore.test.ts +++ b/src/features/AdjustTypography/model/store/typographySettingsStore/typographySettingsStore.test.ts @@ -6,8 +6,7 @@ import { DEFAULT_FONT_WEIGHT, DEFAULT_LETTER_SPACING, DEFAULT_LINE_HEIGHT, - DEFAULT_TYPOGRAPHY_CONTROLS_DATA, -} from '$entities/Font'; +} from '$entities/Font/model/const/const'; import { beforeEach, describe, @@ -15,6 +14,7 @@ import { it, vi, } from 'vitest'; +import { DEFAULT_TYPOGRAPHY_CONTROLS_DATA } from '../../const/const'; import { type TypographySettings, TypographySettingsStore, @@ -51,6 +51,7 @@ describe('TypographySettingsStore - Unit Tests', () => { let mockPersistentStore: { value: TypographySettings; clear: () => void; + destroy: () => void; }; const createMockPersistentStore = (initialValue: TypographySettings) => { @@ -70,6 +71,7 @@ describe('TypographySettingsStore - Unit Tests', () => { letterSpacing: DEFAULT_LETTER_SPACING, }; }, + destroy() {}, }; }; @@ -535,6 +537,7 @@ describe('TypographySettingsStore - Unit Tests', () => { mockStorage = v; }, clear: clearSpy, + destroy() {}, }; const manager = new TypographySettingsStore( diff --git a/src/features/AdjustTypography/model/types/typography.ts b/src/features/AdjustTypography/model/types/typography.ts new file mode 100644 index 0000000..4b89019 --- /dev/null +++ b/src/features/AdjustTypography/model/types/typography.ts @@ -0,0 +1,27 @@ +import type { + ControlLabels, + NumericControl, +} from '$shared/ui'; + +/** + * Identifiers for the adjustable typography axes + */ +export type ControlId = 'font_size' | 'font_weight' | 'line_height' | 'letter_spacing'; + +/** + * Static configuration for one typography control. + * + * Derived from the SSOT contract types — declares no fields of its own beyond + * the domain `id`. Bounds come from NumericControl, labels from ControlLabels. + * + * @template T - Control identifier type + */ +export type ControlModel = + & Pick + & ControlLabels + & { + /** + * Unique identifier for the control + */ + id: T; + }; diff --git a/src/features/AdjustTypography/model/typographyControl/createTypographyControl.svelte.ts b/src/features/AdjustTypography/model/typographyControl/createTypographyControl.svelte.ts new file mode 100644 index 0000000..452ea33 --- /dev/null +++ b/src/features/AdjustTypography/model/typographyControl/createTypographyControl.svelte.ts @@ -0,0 +1,67 @@ +/** + * Bounded numeric control for typography settings. + * + * Produces a reactive control that clamps to [min, max] and rounds to step. + * Implements the NumericControl contract that ComboControl renders. + */ +import { + clampNumber, + roundToStepPrecision, +} from '$shared/lib/utils'; +import type { NumericControl } from '$shared/ui'; + +/** + * Bounds + initial value seed for a control + */ +type ControlSeed = Pick; + +/** + * Create a reactive bounded numeric control. + * + * @param initialState - Initial value and bounds + * @returns A NumericControl whose value is always clamped and step-rounded + */ +export function createTypographyControl(initialState: ControlSeed): NumericControl { + let value = $state(initialState.value); + let max = $state(initialState.max); + let min = $state(initialState.min); + let step = $state(initialState.step); + + const { isAtMax, isAtMin } = $derived({ + isAtMax: value >= max, + isAtMin: value <= min, + }); + + return { + get value() { + return value; + }, + set value(newValue) { + const rounded = roundToStepPrecision(clampNumber(newValue, min, max), step); + if (value !== rounded) { + value = rounded; + } + }, + get max() { + return max; + }, + get min() { + return min; + }, + get step() { + return step; + }, + get isAtMax() { + return isAtMax; + }, + get isAtMin() { + return isAtMin; + }, + increase() { + value = roundToStepPrecision(clampNumber(value + step, min, max), step); + }, + decrease() { + value = roundToStepPrecision(clampNumber(value - step, min, max), step); + }, + }; +} diff --git a/src/shared/lib/helpers/createTypographyControl/createTypographyControl.test.ts b/src/features/AdjustTypography/model/typographyControl/createTypographyControl.test.ts similarity index 98% rename from src/shared/lib/helpers/createTypographyControl/createTypographyControl.test.ts rename to src/features/AdjustTypography/model/typographyControl/createTypographyControl.test.ts index 31ca633..85d683a 100644 --- a/src/shared/lib/helpers/createTypographyControl/createTypographyControl.test.ts +++ b/src/features/AdjustTypography/model/typographyControl/createTypographyControl.test.ts @@ -1,12 +1,10 @@ -import { - type TypographyControl, - createTypographyControl, -} from '$shared/lib'; +import type { NumericControl } from '$shared/ui'; import { describe, expect, it, } from 'vitest'; +import { createTypographyControl } from './createTypographyControl.svelte'; /** * Test Strategy for createTypographyControl Helper @@ -34,7 +32,7 @@ describe('createTypographyControl - Unit Tests', () => { min?: number; max?: number; step?: number; - }): TypographyControl { + }): NumericControl { return createTypographyControl({ value: initialValue, min: options?.min ?? 0, diff --git a/src/features/AdjustTypography/ui/TypographyMenu/TypographyMenu.svelte b/src/features/AdjustTypography/ui/TypographyMenu/TypographyMenu.svelte index e52b422..d628a42 100644 --- a/src/features/AdjustTypography/ui/TypographyMenu/TypographyMenu.svelte +++ b/src/features/AdjustTypography/ui/TypographyMenu/TypographyMenu.svelte @@ -5,11 +5,6 @@ Desktop: inline bar with combo controls. --> diff --git a/src/features/ChangeAppTheme/ui/ThemeSwitch/ThemeSwitch.svelte.test.ts b/src/features/ChangeAppTheme/ui/ThemeSwitch/ThemeSwitch.svelte.test.ts index 393bf55..08d8859 100644 --- a/src/features/ChangeAppTheme/ui/ThemeSwitch/ThemeSwitch.svelte.test.ts +++ b/src/features/ChangeAppTheme/ui/ThemeSwitch/ThemeSwitch.svelte.test.ts @@ -3,16 +3,25 @@ import { render, screen, } from '@testing-library/svelte'; -import { themeManager } from '../../model'; +import { afterEach } from 'vitest'; +import { getThemeManager } from '../../model'; +import { __resetThemeManager } from '../../model/store/ThemeManager/ThemeManager.svelte'; import ThemeSwitch from './ThemeSwitch.svelte'; const context = new Map([['responsive', { isMobile: false }]]); describe('ThemeSwitch', () => { + let themeManager: ReturnType; + beforeEach(() => { + themeManager = getThemeManager(); themeManager.setTheme('light'); }); + afterEach(() => { + __resetThemeManager(); + }); + describe('Rendering', () => { it('renders an icon button', () => { render(ThemeSwitch, { context }); diff --git a/src/features/DisplayFont/index.ts b/src/features/DisplayFont/index.ts deleted file mode 100644 index a15fd38..0000000 --- a/src/features/DisplayFont/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { FontSampler } from './ui'; diff --git a/src/features/DisplayFont/ui/index.ts b/src/features/DisplayFont/ui/index.ts deleted file mode 100644 index b055bdf..0000000 --- a/src/features/DisplayFont/ui/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import FontSampler from './FontSampler/FontSampler.svelte'; - -export { FontSampler }; diff --git a/src/features/FetchFontsByIds/index.ts b/src/features/FetchFontsByIds/index.ts deleted file mode 100644 index 600c651..0000000 --- a/src/features/FetchFontsByIds/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { FontsByIdsStore } from './model'; diff --git a/src/features/FetchFontsByIds/model/index.ts b/src/features/FetchFontsByIds/model/index.ts deleted file mode 100644 index c449f87..0000000 --- a/src/features/FetchFontsByIds/model/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { FontsByIdsStore } from './store/fontsByIdsStore/fontsByIdsStore.svelte'; diff --git a/src/features/FilterAndSortFonts/api/filters/filters.ts b/src/features/FilterAndSortFonts/api/filters/filters.ts index ef38be4..8e0e51e 100644 --- a/src/features/FilterAndSortFonts/api/filters/filters.ts +++ b/src/features/FilterAndSortFonts/api/filters/filters.ts @@ -9,7 +9,7 @@ import { api } from '$shared/api/api'; import { API_ENDPOINTS } from '$shared/api/endpoints'; -import { NonRetryableError } from '$shared/api/queryClient'; +import { NonRetryableError } from '$shared/api/nonRetryableError'; const PROXY_API_URL = API_ENDPOINTS.filters; diff --git a/src/features/FilterAndSortFonts/api/index.ts b/src/features/FilterAndSortFonts/api/index.ts index 56cc8f4..2e8de2a 100644 --- a/src/features/FilterAndSortFonts/api/index.ts +++ b/src/features/FilterAndSortFonts/api/index.ts @@ -1 +1,6 @@ -export * from './filters/filters'; +export { fetchProxyFilters } from './filters/filters'; +export type { + FilterMetadata, + FilterOption, + ProxyFiltersResponse, +} from './filters/filters'; diff --git a/src/features/FilterAndSortFonts/index.ts b/src/features/FilterAndSortFonts/index.ts index bd666a3..d0535aa 100644 --- a/src/features/FilterAndSortFonts/index.ts +++ b/src/features/FilterAndSortFonts/index.ts @@ -1,24 +1,28 @@ export { mapAppliedFiltersToParams } from './lib'; export { - type AppliedFilterStore, - appliedFilterStore, - /** - * Filter Store - */ - availableFilterStore, /** * Filter Manager */ createAppliedFilterStore, + /** + * Lazy store accessors + */ + getAppliedFilterStore, + getAvailableFilterStore, + getSortStore, /** * Sort Store */ SORT_MAP, SORT_OPTIONS, - type SortApiValue, - type SortOption, - sortStore, + startFilterBindings, +} from './model'; + +export type { + AppliedFilterStore, + SortApiValue, + SortOption, } from './model'; export { diff --git a/src/features/FilterAndSortFonts/lib/mapper/mapFilterMetadataToGroups.test.ts b/src/features/FilterAndSortFonts/lib/mapper/mapFilterMetadataToGroups.test.ts new file mode 100644 index 0000000..7c42e82 --- /dev/null +++ b/src/features/FilterAndSortFonts/lib/mapper/mapFilterMetadataToGroups.test.ts @@ -0,0 +1,83 @@ +import { + describe, + expect, + it, +} from 'vitest'; +import type { + FilterMetadata, + FilterOption, +} from '../../api/filters/filters'; +import { mapFilterMetadataToGroups } from './mapFilterMetadataToGroups'; + +/** + * Build a FilterOption with a known value and count. + */ +function option(value: string, count: number): FilterOption { + return { id: value, name: value, value, count }; +} + +/** + * Build filter metadata for one group from (value, count) entries. + */ +function metadata(id: string, options: Array<[string, number]>): FilterMetadata { + return { + id, + name: id, + description: '', + type: 'array', + options: options.map(([value, count]) => option(value, count)), + }; +} + +describe('mapFilterMetadataToGroups', () => { + it('maps id and name onto group id and label', () => { + const [group] = mapFilterMetadataToGroups([metadata('categories', [['serif', 1]])]); + + expect(group.id).toBe('categories'); + expect(group.label).toBe('categories'); + }); + + it('projects each option to a property with selected: false', () => { + const [group] = mapFilterMetadataToGroups([metadata('providers', [['google', 5]])]); + + expect(group.properties).toEqual([ + { id: 'google', name: 'google', value: 'google', selected: false }, + ]); + }); + + it('orders properties by descending count', () => { + const [group] = mapFilterMetadataToGroups([ + metadata('subsets', [['latin', 2], ['cyrillic', 9], ['greek', 5]]), + ]); + + expect(group.properties.map(p => p.value)).toEqual(['cyrillic', 'greek', 'latin']); + }); + + it('does not mutate the source options array (TanStack cache safety)', () => { + const source = metadata('subsets', [['latin', 2], ['cyrillic', 9]]); + const originalOrder = source.options.map(o => o.value); + + mapFilterMetadataToGroups([source]); + + expect(source.options.map(o => o.value)).toEqual(originalOrder); + }); + + it('maps every group, preserving group order', () => { + const groups = mapFilterMetadataToGroups([ + metadata('providers', [['google', 1]]), + metadata('categories', [['serif', 1]]), + ]); + + expect(groups.map(g => g.id)).toEqual(['providers', 'categories']); + }); + + it('returns an empty group list for empty metadata', () => { + expect(mapFilterMetadataToGroups([])).toEqual([]); + }); + + it('yields an empty properties list when a group has no options', () => { + const [group] = mapFilterMetadataToGroups([metadata('providers', [])]); + + expect(group.properties).toEqual([]); + }); +}); diff --git a/src/features/FilterAndSortFonts/lib/mapper/mapFilterMetadataToGroups.ts b/src/features/FilterAndSortFonts/lib/mapper/mapFilterMetadataToGroups.ts new file mode 100644 index 0000000..f17244e --- /dev/null +++ b/src/features/FilterAndSortFonts/lib/mapper/mapFilterMetadataToGroups.ts @@ -0,0 +1,36 @@ +import type { FilterMetadata } from '../../api/filters/filters'; +import type { FilterGroupConfig } from '../../model'; + +/** + * Map backend filter metadata into the group configs `appliedFilterStore.setGroups` + * consumes. + * + * Inverse direction of `mapAppliedFiltersToParams`: that maps applied selections out + * to API params; this maps the API's available-filter catalog in to the UI model. + * + * Options are ordered by descending font count so the most populated values surface + * first. The source array is copied before sorting — `metadata` is TanStack-cached + * query data, and `.sort()` mutates in place; sorting the live cache both corrupts it + * and, when called from a reactive effect, writes into that effect's own read + * dependency (triggering an update loop). + * + * Every property starts unselected; selection state is owned by the store, not the + * backend catalog. + * + * @param metadata - Available-filter catalog from the filters endpoint + * @returns Group configs ready for `setGroups` + */ +export function mapFilterMetadataToGroups(metadata: FilterMetadata[]): FilterGroupConfig[] { + return metadata.map(filter => ({ + id: filter.id, + label: filter.name, + properties: [...filter.options] + .sort((a, b) => b.count - a.count) + .map(opt => ({ + id: opt.id, + name: opt.name, + value: opt.value, + selected: false, + })), + })); +} diff --git a/src/features/FilterAndSortFonts/model/index.ts b/src/features/FilterAndSortFonts/model/index.ts index 755643a..6dcafff 100644 --- a/src/features/FilterAndSortFonts/model/index.ts +++ b/src/features/FilterAndSortFonts/model/index.ts @@ -14,9 +14,9 @@ export type { */ export { /** - * Low-level property selection store + * Lazy accessor for the app-wide filter-metadata store */ - availableFilterStore, + getAvailableFilterStore, } from './store/availableFilterStore/availableFilterStore.svelte'; /** @@ -27,26 +27,30 @@ export { * Reactive interface returned by `createAppliedFilterStore` */ type AppliedFilterStore, - /** - * High-level manager for syncing search and filters - */ - appliedFilterStore, /** * Factory for constructing a filter manager instance */ createAppliedFilterStore, + /** + * Lazy accessor for the app-wide filter manager + */ + getAppliedFilterStore, } from './store/appliedFilterStore/appliedFilterStore.svelte'; /** * Side-effect import: installs the global appliedFilterStore+sortStore → fontCatalogStore * bridge on first import of this feature barrel. No exports. */ -import './store/bindings.svelte'; +export { startFilterBindings } from './store/bindings.svelte'; /** * Sorting logic */ export { + /** + * Lazy accessor for the app-wide sort store + */ + getSortStore, /** * Map of human-readable labels to API sort keys */ @@ -63,8 +67,4 @@ export { * UI model for a single sort option */ type SortOption, - /** - * Reactive store for the current sort selection - */ - sortStore, } from './store/sortStore/sortStore.svelte'; diff --git a/src/features/FilterAndSortFonts/model/store/appliedFilterStore/appliedFilterStore.svelte.ts b/src/features/FilterAndSortFonts/model/store/appliedFilterStore/appliedFilterStore.svelte.ts index f2e8258..c0bee23 100644 --- a/src/features/FilterAndSortFonts/model/store/appliedFilterStore/appliedFilterStore.svelte.ts +++ b/src/features/FilterAndSortFonts/model/store/appliedFilterStore/appliedFilterStore.svelte.ts @@ -23,7 +23,10 @@ * ``` */ -import { createFilter } from '$shared/lib'; +import { + createFilter, + createSingleton, +} from '$shared/lib'; import { createDebouncedState } from '$shared/lib/helpers'; import type { FilterConfig, @@ -42,8 +45,13 @@ import type { export function createAppliedFilterStore(config: FilterConfig) { const search = createDebouncedState(config.queryValue ?? ''); - // Create filter instances upfront - const groups = $state( + // Create filter instances upfront. + // `let` (not `const`) so setGroups can REASSIGN the whole array. In-place + // `groups.length = 0; groups.push(...)` is forbidden here: push reads the + // array's length signal, so a $effect that calls setGroups would both read + // and write `groups.length` in one run and re-trigger itself forever + // (effect_update_depth_exceeded). + let groups = $state( config.groups.map(config => ({ id: config.id, label: config.label, @@ -62,14 +70,11 @@ export function createAppliedFilterStore(config: FilterCo * Used when dynamic filter data loads from backend */ setGroups(newGroups: FilterGroupConfig[]) { - groups.length = 0; - groups.push( - ...newGroups.map(g => ({ - id: g.id, - label: g.label, - instance: createFilter({ properties: g.properties }), - })), - ); + groups = newGroups.map(g => ({ + id: g.id, + label: g.label, + instance: createFilter({ properties: g.properties }), + })); }, /** * Current search query value (immediate, for UI binding) @@ -128,13 +133,20 @@ export function createAppliedFilterStore(config: FilterCo export type AppliedFilterStore = ReturnType; /** - * App-wide filter manager singleton. + * App-wide filter manager, created on first access. * * Constructed with empty groups; the availableFilterStore → appliedFilterStore wiring * lives in `./bindings.svelte` and populates groups once backend filter * metadata arrives. */ -export const appliedFilterStore = createAppliedFilterStore({ - queryValue: '', - groups: [], -}); +const appliedFilterStore = createSingleton(() => + createAppliedFilterStore({ + queryValue: '', + groups: [], + }) +); + +export const getAppliedFilterStore = appliedFilterStore.get; + +// test-only reset, so specs don't share filter/selection state +export const __resetAppliedFilterStore = appliedFilterStore.reset; diff --git a/src/features/FilterAndSortFonts/model/store/appliedFilterStore/appliedFilterStore.test.ts b/src/features/FilterAndSortFonts/model/store/appliedFilterStore/appliedFilterStore.test.ts index c546ac1..02c2333 100644 --- a/src/features/FilterAndSortFonts/model/store/appliedFilterStore/appliedFilterStore.test.ts +++ b/src/features/FilterAndSortFonts/model/store/appliedFilterStore/appliedFilterStore.test.ts @@ -29,12 +29,6 @@ import { createAppliedFilterStore } from './appliedFilterStore.svelte'; * testing Svelte 5 reactive code in Node.js. */ -// Helper to flush Svelte effects (they run in microtasks) -async function flushEffects() { - await Promise.resolve(); - await Promise.resolve(); -} - // Helper to create test properties function createTestProperties(count: number, selectedIndices: number[] = []): Property[] { return Array.from({ length: count }, (_, i) => ({ diff --git a/src/features/FilterAndSortFonts/model/store/availableFilterStore/availableFilterStore.svelte.ts b/src/features/FilterAndSortFonts/model/store/availableFilterStore/availableFilterStore.svelte.ts index 7eff164..f11b276 100644 --- a/src/features/FilterAndSortFonts/model/store/availableFilterStore/availableFilterStore.svelte.ts +++ b/src/features/FilterAndSortFonts/model/store/availableFilterStore/availableFilterStore.svelte.ts @@ -20,8 +20,9 @@ import type { FilterMetadata } from '$features/FilterAndSortFonts/api/filters/fi import { DEFAULT_QUERY_GC_TIME_MS, DEFAULT_QUERY_STALE_TIME_MS, - queryClient, + getQueryClient, } from '$shared/api/queryClient'; +import { createSingleton } from '$shared/lib/helpers/createSingleton/createSingleton'; import { type QueryKey, QueryObserver, @@ -49,7 +50,7 @@ export class AvailableFilterStore { /** * Shared query client */ - protected qc = queryClient; + protected qc = getQueryClient(); /** * Creates a new filters store @@ -127,6 +128,15 @@ export class AvailableFilterStore { } /** - * Singleton instance + * App-wide filter-metadata store, created on first access. Lazy so the + * QueryObserver isn't constructed at module load. */ -export const availableFilterStore = new AvailableFilterStore(); +const availableFilterStore = createSingleton( + () => new AvailableFilterStore(), + instance => instance.destroy(), +); + +export const getAvailableFilterStore = availableFilterStore.get; + +// test-only reset, so specs don't share a live observer +export const __resetAvailableFilterStore = availableFilterStore.reset; diff --git a/src/features/FilterAndSortFonts/model/store/availableFilterStore/availableFilterStore.test.ts b/src/features/FilterAndSortFonts/model/store/availableFilterStore/availableFilterStore.test.ts index 8f489d4..e36fe76 100644 --- a/src/features/FilterAndSortFonts/model/store/availableFilterStore/availableFilterStore.test.ts +++ b/src/features/FilterAndSortFonts/model/store/availableFilterStore/availableFilterStore.test.ts @@ -1,4 +1,6 @@ -import { queryClient } from '$shared/api/queryClient'; +import { getQueryClient } from '$shared/api/queryClient'; + +const queryClient = getQueryClient(); import { afterEach, beforeEach, diff --git a/src/features/FilterAndSortFonts/model/store/bindings.svelte.ts b/src/features/FilterAndSortFonts/model/store/bindings.svelte.ts index 417fc3e..ddb0c6c 100644 --- a/src/features/FilterAndSortFonts/model/store/bindings.svelte.ts +++ b/src/features/FilterAndSortFonts/model/store/bindings.svelte.ts @@ -9,52 +9,34 @@ * observer, so it lives at module scope, not in any individual widget. */ -import { fontCatalogStore } from '$entities/Font'; +import { getFontCatalog } from '$entities/Font'; import { untrack } from 'svelte'; import { mapAppliedFiltersToParams } from '../../lib/mapper/mapAppliedFiltersToParams'; -import { appliedFilterStore } from './appliedFilterStore/appliedFilterStore.svelte'; -import { availableFilterStore } from './availableFilterStore/availableFilterStore.svelte'; -import { sortStore } from './sortStore/sortStore.svelte'; +import { mapFilterMetadataToGroups } from '../../lib/mapper/mapFilterMetadataToGroups'; +import { getAppliedFilterStore } from './appliedFilterStore/appliedFilterStore.svelte'; +import { getAvailableFilterStore } from './availableFilterStore/availableFilterStore.svelte'; +import { getSortStore } from './sortStore/sortStore.svelte'; -$effect.root(() => { - /** - * Populate appliedFilterStore groups when backend filter metadata resolves. - * availableFilterStore is async; until it loads, appliedFilterStore has empty groups - * and the UI renders nothing for them. - */ - $effect(() => { - const dynamicFilters = availableFilterStore.filters; +export function startFilterBindings(): () => void { + const appliedFilterStore = getAppliedFilterStore(); + const availableFilterStore = getAvailableFilterStore(); + const sortStore = getSortStore(); - if (dynamicFilters.length > 0) { - appliedFilterStore.setGroups( - dynamicFilters.map(filter => ({ - id: filter.id, - label: filter.name, - properties: filter.options.sort((a, b) => b.count - a.count).map(opt => ({ - id: opt.id, - name: opt.name, - value: opt.value, - selected: false, - })), - })), - ); - } + const stop = $effect.root(() => { + $effect(() => { + const dynamicFilters = availableFilterStore.filters; + if (dynamicFilters.length > 0) { + appliedFilterStore.setGroups(mapFilterMetadataToGroups(dynamicFilters)); + } + }); + + $effect(() => { + const params = mapAppliedFiltersToParams(appliedFilterStore); + const sort = sortStore.apiValue; + const catalog = getFontCatalog(); + untrack(() => catalog.setParams({ ...params, sort })); + }); }); - /** - * Mirror filter selections + debounced search query + sort into fontCatalogStore params. - * - * Filters and sort are merged into one setParams call to avoid a startup race: - * two separate effects each issued setOptions with a different queryKey on the - * first flush, producing an orphaned `?limit=50&offset=0` fetch immediately - * followed by the real `?limit=50&sort=popularity&offset=0` fetch. - * - * untrack the write so fontCatalogStore's internal $state reads don't feed back - * into this effect's dependency graph. - */ - $effect(() => { - const params = mapAppliedFiltersToParams(appliedFilterStore); - const sort = sortStore.apiValue; - untrack(() => fontCatalogStore.setParams({ ...params, sort })); - }); -}); + return stop; // hand the caller the cleanup +} diff --git a/src/features/FilterAndSortFonts/model/store/sortStore/sortStore.svelte.ts b/src/features/FilterAndSortFonts/model/store/sortStore/sortStore.svelte.ts index 3a8908f..b748471 100644 --- a/src/features/FilterAndSortFonts/model/store/sortStore/sortStore.svelte.ts +++ b/src/features/FilterAndSortFonts/model/store/sortStore/sortStore.svelte.ts @@ -1,3 +1,5 @@ +import { createSingleton } from '$shared/lib/helpers/createSingleton/createSingleton'; + /** * Sort store — manages the current sort option for font listings. * @@ -44,4 +46,14 @@ export function createSortStore(initial: SortOption = 'Popularity') { }; } -export const sortStore = createSortStore(); +export type SortStore = ReturnType; + +/** + * App-wide sort store, created on first access. + */ +const sortStore = createSingleton(() => createSortStore()); + +export const getSortStore = sortStore.get; + +// test-only reset, so specs don't share selection state +export const __resetSortStore = sortStore.reset; diff --git a/src/features/FilterAndSortFonts/model/store/sortStore/sortStore.test.ts b/src/features/FilterAndSortFonts/model/store/sortStore/sortStore.test.ts index fcdc41b..b78cbc8 100644 --- a/src/features/FilterAndSortFonts/model/store/sortStore/sortStore.test.ts +++ b/src/features/FilterAndSortFonts/model/store/sortStore/sortStore.test.ts @@ -1,4 +1,5 @@ import { + afterEach, describe, expect, it, @@ -7,8 +8,9 @@ import { SORT_MAP, SORT_OPTIONS, type SortOption, + __resetSortStore, createSortStore, - sortStore, + getSortStore, } from './sortStore.svelte'; describe('createSortStore', () => { @@ -51,14 +53,24 @@ describe('createSortStore', () => { }); }); -describe('sortStore singleton', () => { +describe('getSortStore singleton', () => { + afterEach(() => { + __resetSortStore(); + }); + + it('returns the same instance across calls', () => { + expect(getSortStore()).toBe(getSortStore()); + }); + it('exposes the same shape as a factory instance', () => { + const sortStore = getSortStore(); expect(typeof sortStore.value).toBe('string'); expect(typeof sortStore.apiValue).toBe('string'); expect(typeof sortStore.set).toBe('function'); }); it('accepts all SORT_OPTIONS as valid set() inputs', () => { + const sortStore = getSortStore(); for (const option of SORT_OPTIONS) { sortStore.set(option); expect(sortStore.value).toBe(option); diff --git a/src/features/FilterAndSortFonts/ui/Filters/Filters.svelte b/src/features/FilterAndSortFonts/ui/Filters/Filters.svelte index b993c48..ec993d9 100644 --- a/src/features/FilterAndSortFonts/ui/Filters/Filters.svelte +++ b/src/features/FilterAndSortFonts/ui/Filters/Filters.svelte @@ -4,10 +4,13 @@ --> -{#each appliedFilterStore.groups as group (group.id)} +{#each groups as group (group.id)} { beforeEach(() => { // Clear groups and mock availableFilterStore to be empty so the auto-sync effect doesn't overwrite us - appliedFilterStore.setGroups([]); - vi.spyOn(availableFilterStore, 'filters', 'get').mockReturnValue([]); + getAppliedFilterStore().setGroups([]); + vi.spyOn(getAvailableFilterStore(), 'filters', 'get').mockReturnValue([]); }); afterEach(() => { @@ -28,7 +28,7 @@ describe('Filters', () => { }); it('renders a label for each filter group', () => { - appliedFilterStore.setGroups([ + getAppliedFilterStore().setGroups([ { id: 'cat', label: 'Categories', properties: [] }, { id: 'prov', label: 'Font Providers', properties: [] }, ]); @@ -38,7 +38,7 @@ describe('Filters', () => { }); it('renders filter properties within groups', () => { - appliedFilterStore.setGroups([ + getAppliedFilterStore().setGroups([ { id: 'cat', label: 'Category', @@ -54,7 +54,7 @@ describe('Filters', () => { }); it('renders multiple groups with their properties', () => { - appliedFilterStore.setGroups([ + getAppliedFilterStore().setGroups([ { id: 'cat', label: 'Category', diff --git a/src/features/FilterAndSortFonts/ui/FiltersControl/FilterControls.svelte b/src/features/FilterAndSortFonts/ui/FiltersControl/FilterControls.svelte index f249f99..08c4fc9 100644 --- a/src/features/FilterAndSortFonts/ui/FiltersControl/FilterControls.svelte +++ b/src/features/FilterAndSortFonts/ui/FiltersControl/FilterControls.svelte @@ -12,8 +12,8 @@ import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw'; import { getContext } from 'svelte'; import { SORT_OPTIONS, - appliedFilterStore, - sortStore, + getAppliedFilterStore, + getSortStore, } from '../../model'; interface Props { @@ -30,6 +30,10 @@ const { const responsive = getContext('responsive'); const isMobileOrTabletPortrait = $derived(responsive.isMobile || responsive.isTabletPortrait); +const appliedFilterStore = getAppliedFilterStore(); +const sortStore = getSortStore(); +const sortValue = $derived(sortStore.value); + function handleReset() { appliedFilterStore.deselectAllGlobal(); } @@ -53,7 +57,7 @@ function handleReset() {