From c90a258f6cdd319aaad0ce252e31a639cf0b287b Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Thu, 28 May 2026 21:36:23 +0300 Subject: [PATCH 1/4] feat(font-list): show empty state when search yields no fonts Adds an `empty` snippet prop to FontVirtualList and supplies it from the sidebar FontList. Settled queries with zero results now render a centered "No typefaces found" label instead of a blank list area. --- .../Font/ui/FontVirtualList/FontVirtualList.svelte | 11 +++++++++++ .../ComparisonView/ui/FontList/FontList.svelte | 6 ++++++ 2 files changed, 17 insertions(+) diff --git a/src/entities/Font/ui/FontVirtualList/FontVirtualList.svelte b/src/entities/Font/ui/FontVirtualList/FontVirtualList.svelte index 957c86d..0d21abe 100644 --- a/src/entities/Font/ui/FontVirtualList/FontVirtualList.svelte +++ b/src/entities/Font/ui/FontVirtualList/FontVirtualList.svelte @@ -40,6 +40,10 @@ interface Props extends * Skeleton snippet */ skeleton?: Snippet; + /** + * Empty-state snippet rendered when the query settled with zero fonts + */ + empty?: Snippet; } let { @@ -47,6 +51,7 @@ let { onVisibleItemsChange, weight, skeleton, + empty, ...rest }: Props = $props(); @@ -59,6 +64,8 @@ let isCatchingUp = $state(false); const showInitialSkeleton = $derived(!!skeleton && isLoading && fontCatalogStore.fonts.length === 0); const showCatchupSkeleton = $derived(!!skeleton && isCatchingUp); +// Settled query with no matches — empty state replaces the (otherwise blank) list. +const showEmpty = $derived(!!empty && !isLoading && !isCatchingUp && fontCatalogStore.fonts.length === 0); function handleInternalVisibleChange(items: UnifiedFont[]) { visibleFonts = items; @@ -163,6 +170,10 @@ function handleNearBottom(_lastVisibleIndex: number) {
{@render skeleton()}
+ {:else if showEmpty && empty} +
+ {@render empty()} +
{:else} + {#snippet empty()} +
+ +
+ {/snippet} + {#snippet skeleton()}
{#each { length: 50 } as _, index (index)} From 7f20f36d0aafa03e9d099622a2e435476e7973c4 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Thu, 28 May 2026 21:37:23 +0300 Subject: [PATCH 2/4] fix(api): mark schema-validation errors as non-retryable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The proxy returned `{fonts: null, total: 0}` for empty results, which fetchProxyFonts surfaced as a generic Error. fontCatalogStore wrapped it as FontNetworkError, and TanStack retried 3× with exponential backoff — pinning the loading skeleton for ~7s before settling on an empty list. Schema mismatches are deterministic; retrying only delays surfacing the contract violation. - shared/api/queryClient: introduce NonRetryableError marker class. The default retry handler short-circuits when it sees this so any store using the shared client gets fail-fast behavior for free. - entities/Font/lib/errors: FontResponseError extends NonRetryableError. - entities/Font/api/proxy/proxyFonts: throw FontResponseError (was a bare Error). Document that ProxyFontsResponse.fonts is always an array. - entities/Font/.../fontCatalogStore.fetchPage: preserve a FontResponseError raised lower in the stack instead of re-wrapping it as FontNetworkError. - features/FilterAndSortFonts/api/filters: throw NonRetryableError on invalid filters payloads and document the array-never-null contract. --- src/entities/Font/api/proxy/proxyFonts.test.ts | 13 +++++++++---- src/entities/Font/api/proxy/proxyFonts.ts | 17 +++++++++++++---- src/entities/Font/lib/errors/errors.ts | 6 +++++- .../fontCatalogStore/fontCatalogStore.svelte.ts | 5 +++++ .../FilterAndSortFonts/api/filters/filters.ts | 15 +++++++++++---- src/shared/api/queryClient.ts | 17 ++++++++++++++++- 6 files changed, 59 insertions(+), 14 deletions(-) diff --git a/src/entities/Font/api/proxy/proxyFonts.test.ts b/src/entities/Font/api/proxy/proxyFonts.test.ts index f2caf00..10bbaa8 100644 --- a/src/entities/Font/api/proxy/proxyFonts.test.ts +++ b/src/entities/Font/api/proxy/proxyFonts.test.ts @@ -21,6 +21,7 @@ vi.mock('$shared/api/api', () => ({ import { api } from '$shared/api/api'; import { queryClient } from '$shared/api/queryClient'; import { fontKeys } from '$shared/api/queryKeys'; +import { FontResponseError } from '../../lib/errors/errors'; import { fetchFontsByIds, fetchProxyFontById, @@ -86,16 +87,20 @@ describe('proxyFonts', () => { expect(calledUrl).toContain('offset=0'); }); - test('should throw on invalid response (missing fonts array)', async () => { + test('should throw FontResponseError on invalid response (missing fonts array)', async () => { mockApiGet({ total: 0 }); - await expect(fetchProxyFonts()).rejects.toThrow('Proxy API returned invalid response'); + await expect(fetchProxyFonts()).rejects.toSatisfy( + e => e instanceof FontResponseError && e.field === 'response.fonts', + ); }); - test('should throw on null response data', async () => { + test('should throw FontResponseError on null response data', async () => { vi.mocked(api.get).mockResolvedValueOnce({ data: null, status: 200 }); - await expect(fetchProxyFonts()).rejects.toThrow('Proxy API returned invalid response'); + await expect(fetchProxyFonts()).rejects.toSatisfy( + e => e instanceof FontResponseError && e.field === 'response', + ); }); }); diff --git a/src/entities/Font/api/proxy/proxyFonts.ts b/src/entities/Font/api/proxy/proxyFonts.ts index de299dc..5b953de 100644 --- a/src/entities/Font/api/proxy/proxyFonts.ts +++ b/src/entities/Font/api/proxy/proxyFonts.ts @@ -15,6 +15,7 @@ import { queryClient } from '$shared/api/queryClient'; import { fontKeys } from '$shared/api/queryKeys'; import { buildQueryString } from '$shared/lib/utils'; import type { QueryParams } from '$shared/lib/utils'; +import { FontResponseError } from '../../lib/errors/errors'; import type { UnifiedFont } from '../../model/types'; /** @@ -96,11 +97,16 @@ export interface ProxyFontsParams extends QueryParams { /** * Proxy API response * - * Includes pagination metadata alongside font data + * Includes pagination metadata alongside font data. + * + * Contract: `fonts` is always an array — never `null` or omitted, even when + * `total === 0`. Returning `null` on the wire is a backend regression and + * surfaces as FontResponseError (non-retryable) on the client. */ export interface ProxyFontsResponse { /** - * List of font objects returned by the proxy + * List of font objects returned by the proxy. + * Always an array; empty when no matches. */ fonts: UnifiedFont[]; @@ -156,8 +162,11 @@ export async function fetchProxyFonts( const response = await api.get(url); - if (!response.data || !Array.isArray(response.data.fonts)) { - throw new Error('Proxy API returned invalid response'); + if (!response.data) { + throw new FontResponseError('response', response.data); + } + if (!Array.isArray(response.data.fonts)) { + throw new FontResponseError('response.fonts', response.data.fonts); } return response.data; diff --git a/src/entities/Font/lib/errors/errors.ts b/src/entities/Font/lib/errors/errors.ts index 4a49f96..4702c8a 100644 --- a/src/entities/Font/lib/errors/errors.ts +++ b/src/entities/Font/lib/errors/errors.ts @@ -1,3 +1,5 @@ +import { NonRetryableError } from '$shared/api/queryClient'; + /** * Thrown when the network request to the proxy API fails. * Wraps the underlying fetch error (timeout, DNS failure, connection refused, etc.). @@ -12,11 +14,13 @@ export class FontNetworkError extends Error { /** * Thrown when the proxy API returns a response with an unexpected shape. + * Extends NonRetryableError because schema mismatches are not transient — + * retrying will produce the same failure and only delay surfacing the bug. * * @property field - The name of the field that failed validation (e.g. `'response'`, `'response.fonts'`). * @property received - The actual value received at that field, for debugging. */ -export class FontResponseError extends Error { +export class FontResponseError extends NonRetryableError { readonly name = 'FontResponseError'; constructor( diff --git a/src/entities/Font/model/store/fontCatalogStore/fontCatalogStore.svelte.ts b/src/entities/Font/model/store/fontCatalogStore/fontCatalogStore.svelte.ts index aea0ed0..c8eae85 100644 --- a/src/entities/Font/model/store/fontCatalogStore/fontCatalogStore.svelte.ts +++ b/src/entities/Font/model/store/fontCatalogStore/fontCatalogStore.svelte.ts @@ -441,6 +441,11 @@ export class FontCatalogStore { try { response = await fetchProxyFonts(params); } catch (cause) { + // Preserve non-retryable validation errors so the query client doesn't + // burn the retry budget on a deterministic schema mismatch. + if (cause instanceof FontResponseError) { + throw cause; + } throw new FontNetworkError(cause); } diff --git a/src/features/FilterAndSortFonts/api/filters/filters.ts b/src/features/FilterAndSortFonts/api/filters/filters.ts index 98f5de2..ef38be4 100644 --- a/src/features/FilterAndSortFonts/api/filters/filters.ts +++ b/src/features/FilterAndSortFonts/api/filters/filters.ts @@ -9,6 +9,7 @@ import { api } from '$shared/api/api'; import { API_ENDPOINTS } from '$shared/api/endpoints'; +import { NonRetryableError } from '$shared/api/queryClient'; const PROXY_API_URL = API_ENDPOINTS.filters; @@ -37,7 +38,8 @@ export interface FilterMetadata { type: 'enum' | 'string' | 'array'; /** - * Available filter options + * Available filter options. + * Always an array; empty when the group has no options. */ options: FilterOption[]; } @@ -68,11 +70,16 @@ export interface FilterOption { } /** - * Proxy filters API response + * Proxy filters API response. + * + * Contract: `filters` (and each nested `options`) is always an array — never + * `null` or omitted. Wire-level `null` here is a backend regression and + * surfaces as a non-retryable error on the client. */ export interface ProxyFiltersResponse { /** - * Array of filter metadata + * Array of filter metadata. + * Always an array; empty when no filter groups are configured. */ filters: FilterMetadata[]; } @@ -99,7 +106,7 @@ export async function fetchProxyFilters(): Promise { const response = await api.get(PROXY_API_URL); if (!response.data || !Array.isArray(response.data)) { - throw new Error('Proxy API returned invalid response'); + throw new NonRetryableError('Proxy API returned invalid filters response'); } return response.data; diff --git a/src/shared/api/queryClient.ts b/src/shared/api/queryClient.ts index 7f9ddba..8e18293 100644 --- a/src/shared/api/queryClient.ts +++ b/src/shared/api/queryClient.ts @@ -1,5 +1,15 @@ import { QueryClient } from '@tanstack/query-core'; +/** + * Marker base class for errors that retrying will never fix — schema-validation + * failures, unauthorized responses, contract violations, etc. + * + * The queryClient retry handler short-circuits when it sees this; without it, + * a non-transient backend bug pins the UI through the full retry budget + * (default 3× exponential backoff ≈ 7s). + */ +export class NonRetryableError extends Error {} + /** * Data remains fresh for this long after fetch. Stores that override * staleness (e.g. filtered queries) can use 0 to bypass. @@ -51,7 +61,12 @@ export const queryClient = new QueryClient({ * Refetch on mount if data is stale */ refetchOnMount: true, - retry: QUERY_RETRY_COUNT, + retry: (failureCount, error) => { + if (error instanceof NonRetryableError) { + return false; + } + return failureCount < QUERY_RETRY_COUNT; + }, /** * Exponential backoff: 1s, 2s, 4s, 8s... capped at 30s */ From 5d72bb7a4cabdcd2c21abe397d980103280760b1 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Thu, 28 May 2026 21:38:17 +0300 Subject: [PATCH 3/4] refactor(fontCatalogStore): single source of truth for query params MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On initial load, two separate $effects in bindings.svelte.ts — one for filters, one for sort — each issued its own setOptions with a different queryKey on the first flush, producing an orphaned `/fonts?limit=50&offset=0` request immediately followed by the real `/fonts?limit=50&sort=popularity&offset=0`. Hardcoding the default sort on the singleton would have papered over the symptom while leaving the sortStore default and the catalog-store default coupled by hand. Make bindings the sole emitter of query params: - features/.../bindings: merge filter + sort effects into one. The effect reads both stores, builds the merged param object, and issues a single setParams. No more interleaved setOptions on mount. - entities/.../fontCatalogStore: gate the observer with `enabled: false` on construction. The first setParams flips `#enabled` on and triggers exactly one fetch with the correct queryKey. Removes the need for a hardcoded default sort on the singleton. - isEmpty is also gated on `#enabled` so the brief pre-config window doesn't render "no results" before bindings configures the query. - The constructor seeds #result from observer.getCurrentResult() because subscribe may not fire synchronously when the observer is disabled. --- .../fontCatalogStore.svelte.spec.ts | 7 ++++--- .../fontCatalogStore.svelte.ts | 21 ++++++++++++++++--- .../model/store/bindings.svelte.ts | 19 ++++++++--------- 3 files changed, 31 insertions(+), 16 deletions(-) diff --git a/src/entities/Font/model/store/fontCatalogStore/fontCatalogStore.svelte.spec.ts b/src/entities/Font/model/store/fontCatalogStore/fontCatalogStore.svelte.spec.ts index f287091..07a4edb 100644 --- a/src/entities/Font/model/store/fontCatalogStore/fontCatalogStore.svelte.spec.ts +++ b/src/entities/Font/model/store/fontCatalogStore/fontCatalogStore.svelte.spec.ts @@ -84,9 +84,10 @@ describe('FontCatalogStore', () => { store.destroy(); }); - it('starts with isEmpty false — initial fetch is in progress', () => { - // The observer starts fetching immediately on construction. - // isEmpty must be false so the UI shows a loader, not "no results". + it('starts with isEmpty false — observer is gated until setParams enables it', () => { + // The observer is disabled on construction (no auto-fetch) — see + // `#enabled` in the store. isEmpty must still be false so the UI + // doesn't flash "no results" before bindings configures the query. const store = makeStore(); expect(store.isEmpty).toBe(false); store.destroy(); diff --git a/src/entities/Font/model/store/fontCatalogStore/fontCatalogStore.svelte.ts b/src/entities/Font/model/store/fontCatalogStore/fontCatalogStore.svelte.ts index c8eae85..c6d7510 100644 --- a/src/entities/Font/model/store/fontCatalogStore/fontCatalogStore.svelte.ts +++ b/src/entities/Font/model/store/fontCatalogStore/fontCatalogStore.svelte.ts @@ -31,6 +31,13 @@ type FontStoreResult = InfiniteQueryObserverResult({ limit: 50 }); + /** + * Gates the initial fetch. The observer starts disabled so the constructor + * cannot race ahead of the bindings module — which is the single source of + * truth for query params. The first setParams flips this on, producing a + * single fetch with the correctly merged queryKey. + */ + #enabled = $state(false); #result = $state({} as FontStoreResult); #observer: InfiniteQueryObserver< ProxyFontsResponse, @@ -45,6 +52,8 @@ export class FontCatalogStore { constructor(params: FontStoreParams = {}) { this.#params = { limit: 50, ...params }; this.#observer = new InfiniteQueryObserver(this.#qc, this.buildOptions()); + // Seed result synchronously; subscribe may not fire on disabled observers. + this.#result = this.#observer.getCurrentResult(); this.#unsubscribe = this.#observer.subscribe(r => { this.#result = r; }); @@ -88,10 +97,13 @@ export class FontCatalogStore { return this.#result.error ?? null; } /** - * True if no fonts were found for the current filter criteria + * True if no fonts were found for the current filter criteria. + * Always false until the observer has been enabled (via setParams) — otherwise + * the UI would briefly render "no results" on mount before bindings configures + * the query. */ get isEmpty(): boolean { - return !this.isLoading && !this.isFetching && this.fonts.length === 0; + return this.#enabled && !this.isLoading && !this.isFetching && this.fonts.length === 0; } /** @@ -129,10 +141,12 @@ export class FontCatalogStore { } /** - * Merge new parameters into existing state and trigger a refetch + * Merge new parameters into existing state and trigger a refetch. + * The first call also enables the observer (see `#enabled`). */ setParams(updates: Partial) { this.#params = { ...this.#params, ...updates }; + this.#enabled = true; this.#observer.setOptions(this.buildOptions()); } /** @@ -431,6 +445,7 @@ export class FontCatalogStore { const next = lastPage.offset + lastPage.limit; return next < lastPage.total ? { offset: next } : undefined; }, + enabled: this.#enabled, staleTime: hasFilters ? 0 : DEFAULT_QUERY_STALE_TIME_MS, gcTime: DEFAULT_QUERY_GC_TIME_MS, }; diff --git a/src/features/FilterAndSortFonts/model/store/bindings.svelte.ts b/src/features/FilterAndSortFonts/model/store/bindings.svelte.ts index ed955fa..417fc3e 100644 --- a/src/features/FilterAndSortFonts/model/store/bindings.svelte.ts +++ b/src/features/FilterAndSortFonts/model/store/bindings.svelte.ts @@ -42,20 +42,19 @@ $effect.root(() => { }); /** - * Mirror filter selections + debounced search query into fontCatalogStore params. + * 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); - untrack(() => fontCatalogStore.setParams(params)); - }); - - /** - * Mirror sort selection into fontCatalogStore. - */ - $effect(() => { - const apiSort = sortStore.apiValue; - untrack(() => fontCatalogStore.setSort(apiSort)); + const sort = sortStore.apiValue; + untrack(() => fontCatalogStore.setParams({ ...params, sort })); }); }); From e698dc6e079f522e5c3918b72ad2c69bca09fd4a Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Thu, 28 May 2026 22:29:02 +0300 Subject: [PATCH 4/4] docs: remove GIT_WORKFLOW.md --- docs/GIT_WORKFLOW.md | 592 ------------------------------------------- 1 file changed, 592 deletions(-) delete mode 100644 docs/GIT_WORKFLOW.md diff --git a/docs/GIT_WORKFLOW.md b/docs/GIT_WORKFLOW.md deleted file mode 100644 index 1db49f9..0000000 --- a/docs/GIT_WORKFLOW.md +++ /dev/null @@ -1,592 +0,0 @@ -# Git Workflow and Branching Strategy - -This document outlines the git workflow, branching strategy, commit conventions, and code review guidelines for the glyphdiff.com project. - -## Table of Contents - -1. [Branching Strategy](#branching-strategy) -2. [Branch Naming Conventions](#branch-naming-conventions) -3. [Commit Message Conventions](#commit-message-conventions) -4. [Code Splitting and Merge Request Guidelines](#code-splitting-and-merge-request-guidelines) -5. [Branch Protection Rules](#branch-protection-rules) -6. [Git Hooks Configuration](#git-hooks-configuration) - ---- - -## Branching Strategy - -We use a Gitflow-inspired branching strategy adapted for our development workflow. This strategy provides a clear structure for feature development, bug fixes, and releases. - -### Branch Types - -#### 1. `main` Branch -- **Purpose**: Production-ready code only -- **Protection**: Highest level of protection -- **Rules**: - - Only merge `release/*` or `hotfix/*` branches into `main` - - No direct commits allowed - - Must pass all tests and code reviews - - Tags are created from this branch for releases (e.g., `v1.0.0`) - -#### 2. `develop` Branch -- **Purpose**: Integration branch for features -- **Protection**: High level of protection -- **Rules**: - - Merge `feature/*` and `fix/*` branches into `develop` - - No direct commits allowed - - Must pass all tests before merging - - Serves as the base for `release/*` branches - -#### 3. `feature/*` Branches -- **Purpose**: Develop new features -- **Naming**: `feature/feature-name` (e.g., `feature/font-catalog`, `feature/comparison-grid`) -- **Base**: Always branch from `develop` -- **Merge**: Merge back into `develop` via Merge Request (MR) -- **Rules**: - - One feature per branch - - Keep branches focused and small - - Delete after merging - -#### 4. `fix/*` Branches -- **Purpose**: Fix bugs discovered during development -- **Naming**: `fix/issue-description` (e.g., `fix/font-loading-error`, `fix/responsive-layout`) -- **Base**: Branch from `develop` -- **Merge**: Merge back into `develop` via MR -- **Rules**: - - One fix per branch - - Include tests that verify the fix - - Delete after merging - -#### 5. `hotfix/*` Branches -- **Purpose**: Critical fixes for production issues -- **Naming**: `hotfix/critical-fix` (e.g., `hotfix/security-patch`, `hotfix-production-crash`) -- **Base**: Branch from `main` -- **Merge**: Merge into both `main` and `develop` -- **Rules**: - - Use only for production emergencies - - Must be thoroughly tested - - Create a release tag after merging to `main` - -#### 6. `release/*` Branches -- **Purpose**: Prepare for a new release -- **Naming**: `release/vX.Y.Z` (e.g., `release/v1.0.0`, `release/v1.1.0`) -- **Base**: Branch from `develop` -- **Merge**: Merge into both `main` and `develop` -- **Rules**: - - Finalize release notes - - Update version numbers - - Perform final testing - - Create release tag after merging to `main` - -### Branch Workflow Diagram - -``` -main (production) - ↑ - │ hotfix/*, release/* - │ -develop (integration) - ↑ - │ feature/*, fix/* - │ -feature branches -``` - ---- - -## Branch Naming Conventions - -### Feature Branches -- Format: `feature/feature-name` -- Examples: - - `feature/font-catalog` - - `feature/comparison-grid` - - `feature/dark-mode` - - `feature/google-fonts-integration` - -### Fix Branches -- Format: `fix/issue-description` -- Examples: - - `fix/font-loading-error` - - `fix/responsive-layout` - - `fix/state-persistence` - - `fix-accessibility-contrast` - -### Hotfix Branches -- Format: `hotfix/critical-fix` -- Examples: - - `hotfix/security-patch` - - `hotfix-production-crash` - - `hotfix-api-rate-limit` - -### Release Branches -- Format: `release/vX.Y.Z` -- Examples: - - `release/v1.0.0` - - `release/v1.1.0` - - `release/v2.0.0` - -### Naming Guidelines -- Use lowercase letters -- Use hyphens to separate words -- Be descriptive but concise -- Avoid special characters (except hyphens) -- Keep names under 50 characters - ---- - -## Commit Message Conventions - -We follow the [Conventional Commits](https://www.conventionalcommits.org/) specification. This format enables automated changelog generation and better commit history readability. - -### Format - -``` -(): - - - -