Chore/architecture refactoring #42
+161
-22
@@ -14,6 +14,13 @@
|
|||||||
--swiss-black: #1a1a1a;
|
--swiss-black: #1a1a1a;
|
||||||
--swiss-white: #ffffff;
|
--swiss-white: #ffffff;
|
||||||
|
|
||||||
|
/* Semantic mode-switching colors. These are redefined inside `.dark`
|
||||||
|
so utilities that reference them auto-adapt without a `dark:` variant. */
|
||||||
|
--color-border-subtle: var(--neutral-300);
|
||||||
|
--color-text-subtle: var(--neutral-500);
|
||||||
|
--color-skeleton: var(--neutral-200);
|
||||||
|
--color-grid-line: rgb(0 0 0 / 0.03);
|
||||||
|
|
||||||
/* Neutral Grays */
|
/* Neutral Grays */
|
||||||
--neutral-50: #fafafa;
|
--neutral-50: #fafafa;
|
||||||
--neutral-100: #f5f5f5;
|
--neutral-100: #f5f5f5;
|
||||||
@@ -80,16 +87,6 @@
|
|||||||
--sidebar-border: oklch(0.922 0 0);
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
--sidebar-ring: oklch(0.708 0 0);
|
--sidebar-ring: oklch(0.708 0 0);
|
||||||
|
|
||||||
/* Spacing Scale (rem-based) */
|
|
||||||
--space-xs: 0.25rem;
|
|
||||||
--space-sm: 0.5rem;
|
|
||||||
--space-md: 0.75rem;
|
|
||||||
--space-lg: 1rem;
|
|
||||||
--space-xl: 1.5rem;
|
|
||||||
--space-2xl: 2rem;
|
|
||||||
--space-3xl: 3rem;
|
|
||||||
--space-4xl: 4rem;
|
|
||||||
|
|
||||||
/* Typography Scale */
|
/* Typography Scale */
|
||||||
--text-xs: 0.75rem;
|
--text-xs: 0.75rem;
|
||||||
--text-sm: 0.875rem;
|
--text-sm: 0.875rem;
|
||||||
@@ -114,6 +111,12 @@
|
|||||||
--color-surface: var(--dark-bg);
|
--color-surface: var(--dark-bg);
|
||||||
--color-paper: var(--dark-card);
|
--color-paper: var(--dark-card);
|
||||||
|
|
||||||
|
/* Dark-mode overrides for the semantic mode-switching colors. */
|
||||||
|
--color-border-subtle: rgb(255 255 255 / 0.1);
|
||||||
|
--color-text-subtle: var(--neutral-400);
|
||||||
|
--color-skeleton: var(--neutral-800);
|
||||||
|
--color-grid-line: rgb(255 255 255 / 0.05);
|
||||||
|
|
||||||
--background: oklch(0.145 0 0);
|
--background: oklch(0.145 0 0);
|
||||||
--foreground: oklch(0.985 0 0);
|
--foreground: oklch(0.985 0 0);
|
||||||
--card: oklch(0.145 0 0);
|
--card: oklch(0.145 0 0);
|
||||||
@@ -212,6 +215,51 @@
|
|||||||
--text-2xs: 0.625rem;
|
--text-2xs: 0.625rem;
|
||||||
/* Monospace label tracking — used in Loader and Footnote */
|
/* Monospace label tracking — used in Loader and Footnote */
|
||||||
--tracking-wider-mono: 0.2em;
|
--tracking-wider-mono: 0.2em;
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
SHADOW TOKENS
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
/* Default resting shadow — equivalent to Tailwind's shadow-sm. Used on
|
||||||
|
buttons, sliders, popover triggers in non-floating state. */
|
||||||
|
--shadow-rest: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||||
|
|
||||||
|
/* Swiss "hard offset" stamp — rests at 2px/2px, lifts to 3px/3px on
|
||||||
|
hover, presses back to 1px/1px on active. Primary button motif. */
|
||||||
|
--shadow-stamp-rest: 0.125rem 0.125rem 0 0 rgb(0 0 0 / 0.1);
|
||||||
|
--shadow-stamp-hover: 0.1875rem 0.1875rem 0 0 rgb(0 0 0 / 0.15);
|
||||||
|
--shadow-stamp-pressed: 0.0625rem 0.0625rem 0 0 rgb(0 0 0 / 0.1);
|
||||||
|
|
||||||
|
/* Card-tier hard-offset stamp — wider, brand-tinted. Used on
|
||||||
|
interactive cards (FontSampler hover). */
|
||||||
|
--shadow-stamp-card: 5px 5px 0 0 var(--color-brand);
|
||||||
|
|
||||||
|
/* Floating popovers (typography menu, combo control list). */
|
||||||
|
--shadow-popover: 0 20px 40px -10px rgb(0 0 0 / 0.15);
|
||||||
|
|
||||||
|
/* Drop-shadow under semi-translucent floating panels like the
|
||||||
|
comparison slider's character row. */
|
||||||
|
--shadow-floating-panel: 0 25px 50px -12px rgb(0 0 0 / 0.05);
|
||||||
|
--shadow-floating-panel-dark: 0 25px 50px -12px rgb(0 0 0 / 0.2);
|
||||||
|
|
||||||
|
/* Drawer / overlay shadow — full-strength shadow-2xl. */
|
||||||
|
--shadow-overlay: 0 25px 50px -12px rgb(0 0 0 / 0.25);
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
MOTION TOKENS
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
--duration-fast: 150ms;
|
||||||
|
--duration-normal: 200ms;
|
||||||
|
--duration-slow: 300ms;
|
||||||
|
--duration-slower: 500ms;
|
||||||
|
|
||||||
|
/* Tailwind's default ease-in-out — symmetric, good for layout shifts. */
|
||||||
|
--ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
/* Decelerating curve — matches Tailwind's ease-out. Dominant in this codebase. */
|
||||||
|
--ease-out-soft: cubic-bezier(0, 0, 0.2, 1);
|
||||||
|
/* Spring overshoot — used in character pop animation. */
|
||||||
|
--ease-spring-overshoot: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
@@ -277,21 +325,112 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer utilities {
|
/* ============================================
|
||||||
/* 21× border-black/5 dark:border-white/10 → single token */
|
DESIGN-SYSTEM UTILITIES
|
||||||
.border-subtle {
|
============================================
|
||||||
@apply border-black/5 dark:border-white/10;
|
Defined via `@utility` (Tailwind v4) so they integrate with the variant
|
||||||
}
|
system (`hover:`, `dark:`, breakpoints) and don't rely on `@apply`
|
||||||
/* Secondary text pair */
|
chains. Colors reference the mode-switching semantic vars defined in
|
||||||
.text-secondary {
|
`:root`/`.dark` above, so most utilities need no `dark:` variant in
|
||||||
@apply text-neutral-500 dark:text-neutral-400;
|
their definition or at call sites. */
|
||||||
}
|
|
||||||
/* Standard focus ring */
|
@utility border-subtle {
|
||||||
.focus-ring {
|
border-color: var(--color-border-subtle);
|
||||||
@apply focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2;
|
}
|
||||||
|
|
||||||
|
/* Same color as border-subtle, applied via background-color — for 1px
|
||||||
|
dividers, inline separator strips, and other hairlines that aren't
|
||||||
|
element borders. */
|
||||||
|
@utility bg-subtle {
|
||||||
|
background-color: var(--color-border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Muted text color — paired with `border-subtle` naming. The previous
|
||||||
|
name `text-secondary` collided with Tailwind v4 auto-generating a
|
||||||
|
utility from `--color-secondary` (the shadcn near-white surface token
|
||||||
|
registered in `@theme`), which made every consumer effectively
|
||||||
|
invisible (near-white text on light backgrounds). */
|
||||||
|
@utility text-subtle {
|
||||||
|
color: var(--color-text-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility focus-ring {
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid transparent;
|
||||||
|
outline-offset: 2px;
|
||||||
|
box-shadow: 0 0 0 2px var(--color-background, white), 0 0 0 4px var(--color-brand);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Surface utilities ────────────────────────────────────────── */
|
||||||
|
|
||||||
|
@utility surface-canvas {
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility surface-card {
|
||||||
|
background-color: var(--color-paper);
|
||||||
|
border: 1px solid var(--color-border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility surface-card-elevated {
|
||||||
|
background-color: var(--color-paper);
|
||||||
|
border: 1px solid var(--color-border-subtle);
|
||||||
|
box-shadow: var(--shadow-rest);
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility surface-popover {
|
||||||
|
background-color: var(--color-paper);
|
||||||
|
border: 1px solid var(--color-border-subtle);
|
||||||
|
box-shadow: var(--shadow-popover);
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility surface-floating {
|
||||||
|
background-color: color-mix(in srgb, var(--color-surface) 80%, transparent);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid var(--color-border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Shape / layout ───────────────────────────────────────────── */
|
||||||
|
|
||||||
|
@utility flex-center {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility skeleton-fill {
|
||||||
|
background-color: color-mix(in srgb, var(--color-skeleton) 70%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Subtle dotted-grid overlay used as a decorative background on the
|
||||||
|
comparison paper surface. Color and intensity auto-switch via
|
||||||
|
--color-grid-line. `bg-grid-sm` uses a tighter cell — typical mobile
|
||||||
|
choice; `bg-grid` is the default desktop cell. Pair with absolute /
|
||||||
|
pointer-events-none on the overlay element. */
|
||||||
|
@utility bg-grid {
|
||||||
|
background-image:
|
||||||
|
linear-gradient(var(--color-grid-line) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, var(--color-grid-line) 1px, transparent 1px);
|
||||||
|
background-size: 20px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility bg-grid-sm {
|
||||||
|
background-image:
|
||||||
|
linear-gradient(var(--color-grid-line) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, var(--color-grid-line) 1px, transparent 1px);
|
||||||
|
background-size: 10px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Typography ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
@utility text-label-mono {
|
||||||
|
font-family: var(--font-primary);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.025em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
/* Global utility - useful across your app */
|
/* Global utility - useful across your app */
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
* {
|
* {
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ onDestroy(() => themeManager.destroy());
|
|||||||
<div
|
<div
|
||||||
id="app-root"
|
id="app-root"
|
||||||
class={cn(
|
class={cn(
|
||||||
'min-h-dvh w-auto flex flex-col bg-surface dark:bg-dark-bg relative',
|
'min-h-dvh w-auto flex flex-col surface-canvas relative',
|
||||||
theme === 'dark' ? 'dark' : '',
|
theme === 'dark' ? 'dark' : '',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -43,8 +43,8 @@ function createButtonText(item: BreadcrumbItem) {
|
|||||||
md:h-16 px-4 md:px-6 lg:px-8
|
md:h-16 px-4 md:px-6 lg:px-8
|
||||||
flex items-center justify-between
|
flex items-center justify-between
|
||||||
z-40
|
z-40
|
||||||
bg-surface/90 dark:bg-dark-bg/90 backdrop-blur-md
|
surface-floating bg-surface/90 dark:bg-dark-bg/90
|
||||||
border-b border-subtle
|
border-x-0 border-t-0
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<div class="max-w-8xl px-4 sm:px-6 h-full w-full flex items-center justify-between gap-2 sm:gap-4">
|
<div class="max-w-8xl px-4 sm:px-6 h-full w-full flex items-center justify-between gap-2 sm:gap-4">
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export {
|
|||||||
fetchFontsByIds,
|
fetchFontsByIds,
|
||||||
fetchProxyFontById,
|
fetchProxyFontById,
|
||||||
fetchProxyFonts,
|
fetchProxyFonts,
|
||||||
|
seedFontCache,
|
||||||
} from './proxy/proxyFonts';
|
} from './proxy/proxyFonts';
|
||||||
export type {
|
export type {
|
||||||
ProxyFontsParams,
|
ProxyFontsParams,
|
||||||
|
|||||||
@@ -29,10 +29,12 @@ export function seedFontCache(fonts: UnifiedFont[]): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import { API_ENDPOINTS } from '$shared/api/endpoints';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Proxy API base URL
|
* Proxy API endpoint for font resources.
|
||||||
*/
|
*/
|
||||||
const PROXY_API_URL = 'https://api.glyphdiff.com/api/v1/fonts' as const;
|
const PROXY_API_URL = API_ENDPOINTS.fonts;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Proxy API parameters
|
* Proxy API parameters
|
||||||
|
|||||||
@@ -667,10 +667,10 @@ export const MOCK_STORES = {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* Create a mock FontStore object
|
* Create a mock FontCatalogStore object
|
||||||
* Matches FontStore's public API for Storybook use
|
* Matches FontCatalogStore's public API for Storybook use
|
||||||
*/
|
*/
|
||||||
fontStore: (config: {
|
fontCatalogStore: (config: {
|
||||||
/**
|
/**
|
||||||
* Preset font list
|
* Preset font list
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,7 +1,19 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import { TextLayoutEngine } from '$shared/lib';
|
|
||||||
import { installCanvasMock } from '$shared/lib/helpers/__mocks__/canvas';
|
import { installCanvasMock } from '$shared/lib/helpers/__mocks__/canvas';
|
||||||
import { clearCache } from '@chenglou/pretext';
|
import {
|
||||||
|
clearCache,
|
||||||
|
layout,
|
||||||
|
} from '@chenglou/pretext';
|
||||||
|
|
||||||
|
// Wrap pretext's `layout` in a spy-able mock so tests can assert call counts.
|
||||||
|
// `vi.mock` is hoisted, so the import above receives the mocked module.
|
||||||
|
vi.mock('@chenglou/pretext', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('@chenglou/pretext')>('@chenglou/pretext');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
layout: vi.fn(actual.layout),
|
||||||
|
};
|
||||||
|
});
|
||||||
import {
|
import {
|
||||||
beforeEach,
|
beforeEach,
|
||||||
describe,
|
describe,
|
||||||
@@ -112,13 +124,13 @@ describe('createFontRowSizeResolver', () => {
|
|||||||
const { resolver } = makeResolver();
|
const { resolver } = makeResolver();
|
||||||
statusMap.set('inter@400', 'loaded');
|
statusMap.set('inter@400', 'loaded');
|
||||||
|
|
||||||
const layoutSpy = vi.spyOn(TextLayoutEngine.prototype, 'layout');
|
const layoutSpy = vi.mocked(layout);
|
||||||
|
layoutSpy.mockClear();
|
||||||
|
|
||||||
resolver(0);
|
resolver(0);
|
||||||
resolver(0);
|
resolver(0);
|
||||||
|
|
||||||
expect(layoutSpy).toHaveBeenCalledTimes(1);
|
expect(layoutSpy).toHaveBeenCalledTimes(1);
|
||||||
layoutSpy.mockRestore();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls layout() again when containerWidth changes (cache miss)', () => {
|
it('calls layout() again when containerWidth changes (cache miss)', () => {
|
||||||
@@ -126,14 +138,14 @@ describe('createFontRowSizeResolver', () => {
|
|||||||
const { resolver } = makeResolver({ getContainerWidth: () => width });
|
const { resolver } = makeResolver({ getContainerWidth: () => width });
|
||||||
statusMap.set('inter@400', 'loaded');
|
statusMap.set('inter@400', 'loaded');
|
||||||
|
|
||||||
const layoutSpy = vi.spyOn(TextLayoutEngine.prototype, 'layout');
|
const layoutSpy = vi.mocked(layout);
|
||||||
|
layoutSpy.mockClear();
|
||||||
|
|
||||||
resolver(0);
|
resolver(0);
|
||||||
width = 100;
|
width = 100;
|
||||||
resolver(0);
|
resolver(0);
|
||||||
|
|
||||||
expect(layoutSpy).toHaveBeenCalledTimes(2);
|
expect(layoutSpy).toHaveBeenCalledTimes(2);
|
||||||
layoutSpy.mockRestore();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns greater height when container narrows (more wrapping)', () => {
|
it('returns greater height when container narrows (more wrapping)', () => {
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { TextLayoutEngine } from '$shared/lib';
|
import {
|
||||||
import { generateFontKey } from '../../model/store/appliedFontsStore/utils/generateFontKey/generateFontKey';
|
layout,
|
||||||
|
prepare,
|
||||||
|
} from '@chenglou/pretext';
|
||||||
|
import { generateFontKey } from '../../model/store/fontLifecycleManager/utils/generateFontKey/generateFontKey';
|
||||||
import type {
|
import type {
|
||||||
FontLoadStatus,
|
FontLoadStatus,
|
||||||
UnifiedFont,
|
UnifiedFont,
|
||||||
@@ -41,7 +44,7 @@ export interface FontRowSizeResolverOptions {
|
|||||||
/**
|
/**
|
||||||
* Returns the font load status for a given font key (`'{id}@{weight}'` or `'{id}@vf'`).
|
* Returns the font load status for a given font key (`'{id}@{weight}'` or `'{id}@vf'`).
|
||||||
*
|
*
|
||||||
* In production: `(key) => appliedFontsManager.statuses.get(key)`.
|
* In production: `(key) => fontLifecycleManager.statuses.get(key)`.
|
||||||
* Injected for testability — avoids a module-level singleton dependency in tests.
|
* Injected for testability — avoids a module-level singleton dependency in tests.
|
||||||
* The call to `.get()` on a `SvelteMap` must happen inside a `$derived.by` context
|
* The call to `.get()` on a `SvelteMap` must happen inside a `$derived.by` context
|
||||||
* for reactivity to work. This is satisfied when `itemHeight` is called by
|
* for reactivity to work. This is satisfied when `itemHeight` is called by
|
||||||
@@ -79,14 +82,13 @@ export interface FontRowSizeResolverOptions {
|
|||||||
* no DOM snap occurs.
|
* no DOM snap occurs.
|
||||||
*
|
*
|
||||||
* **Caching:** A `Map` keyed by `fontCssString|text|contentWidth|lineHeightPx`
|
* **Caching:** A `Map` keyed by `fontCssString|text|contentWidth|lineHeightPx`
|
||||||
* prevents redundant `TextLayoutEngine.layout()` calls. The cache is invalidated
|
* prevents redundant `pretext.layout()` calls. The cache is invalidated
|
||||||
* naturally because a change in any input produces a different cache key.
|
* naturally because a change in any input produces a different cache key.
|
||||||
*
|
*
|
||||||
* @param options - Configuration and getter functions (all injected for testability).
|
* @param options - Configuration and getter functions (all injected for testability).
|
||||||
* @returns A function `(rowIndex: number) => number` for use as `VirtualList.itemHeight`.
|
* @returns A function `(rowIndex: number) => number` for use as `VirtualList.itemHeight`.
|
||||||
*/
|
*/
|
||||||
export function createFontRowSizeResolver(options: FontRowSizeResolverOptions): (rowIndex: number) => number {
|
export function createFontRowSizeResolver(options: FontRowSizeResolverOptions): (rowIndex: number) => number {
|
||||||
const engine = new TextLayoutEngine();
|
|
||||||
// Key: `${fontCssString}|${text}|${contentWidth}|${lineHeightPx}`
|
// Key: `${fontCssString}|${text}|${contentWidth}|${lineHeightPx}`
|
||||||
const cache = new Map<string, number>();
|
const cache = new Map<string, number>();
|
||||||
|
|
||||||
@@ -108,7 +110,7 @@ export function createFontRowSizeResolver(options: FontRowSizeResolverOptions):
|
|||||||
// generateFontKey: '{id}@{weight}' for static fonts, '{id}@vf' for variable fonts.
|
// generateFontKey: '{id}@{weight}' for static fonts, '{id}@vf' for variable fonts.
|
||||||
const fontKey = generateFontKey({ id: font.id, weight, isVariable: font.features?.isVariable });
|
const fontKey = generateFontKey({ id: font.id, weight, isVariable: font.features?.isVariable });
|
||||||
|
|
||||||
// Reading via getStatus() allows the caller to pass appliedFontsManager.statuses.get(),
|
// Reading via getStatus() allows the caller to pass fontLifecycleManager.statuses.get(),
|
||||||
// which creates a Svelte 5 reactive dependency when called inside $derived.by.
|
// which creates a Svelte 5 reactive dependency when called inside $derived.by.
|
||||||
const status = options.getStatus(fontKey);
|
const status = options.getStatus(fontKey);
|
||||||
if (status !== 'loaded') {
|
if (status !== 'loaded') {
|
||||||
@@ -126,7 +128,11 @@ export function createFontRowSizeResolver(options: FontRowSizeResolverOptions):
|
|||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { totalHeight } = engine.layout(previewText, fontCssString, contentWidth, lineHeightPx);
|
// Pretext docs recommend `layout()` (not `layoutWithLines`) for the
|
||||||
|
// resize hot path — pure arithmetic on cached segment widths, no canvas
|
||||||
|
// calls, no string allocations.
|
||||||
|
const prepared = prepare(previewText, fontCssString);
|
||||||
|
const { height: totalHeight } = layout(prepared, contentWidth, lineHeightPx);
|
||||||
const result = totalHeight + options.chromeHeight;
|
const result = totalHeight + options.chromeHeight;
|
||||||
cache.set(cacheKey, result);
|
cache.set(cacheKey, result);
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
+18
-14
@@ -17,13 +17,17 @@ import {
|
|||||||
generateMockFonts,
|
generateMockFonts,
|
||||||
} from '../../../lib/mocks/fonts.mock';
|
} from '../../../lib/mocks/fonts.mock';
|
||||||
import type { UnifiedFont } from '../../types';
|
import type { UnifiedFont } from '../../types';
|
||||||
import { FontStore } from './fontStore.svelte';
|
import { FontCatalogStore } from './fontCatalogStore.svelte';
|
||||||
|
|
||||||
vi.mock('$shared/api/queryClient', () => ({
|
vi.mock('$shared/api/queryClient', async importOriginal => {
|
||||||
queryClient: new QueryClient({
|
const actual = await importOriginal<typeof import('$shared/api/queryClient')>();
|
||||||
defaultOptions: { queries: { retry: 0, gcTime: 0 } },
|
return {
|
||||||
}),
|
...actual,
|
||||||
}));
|
queryClient: new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: 0, gcTime: 0 } },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
vi.mock('../../../api', () => ({ fetchProxyFonts: vi.fn() }));
|
vi.mock('../../../api', () => ({ fetchProxyFonts: vi.fn() }));
|
||||||
|
|
||||||
import { queryClient } from '$shared/api/queryClient';
|
import { queryClient } from '$shared/api/queryClient';
|
||||||
@@ -44,7 +48,7 @@ const makeResponse = (
|
|||||||
});
|
});
|
||||||
|
|
||||||
function makeStore(params = {}) {
|
function makeStore(params = {}) {
|
||||||
return new FontStore({ limit: 10, ...params });
|
return new FontCatalogStore({ limit: 10, ...params });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchedStore(params = {}, fonts = generateMockFonts(5), meta: Parameters<typeof makeResponse>[1] = {}) {
|
async function fetchedStore(params = {}, fonts = generateMockFonts(5), meta: Parameters<typeof makeResponse>[1] = {}) {
|
||||||
@@ -55,7 +59,7 @@ async function fetchedStore(params = {}, fonts = generateMockFonts(5), meta: Par
|
|||||||
return store;
|
return store;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('FontStore', () => {
|
describe('FontCatalogStore', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
queryClient.clear();
|
queryClient.clear();
|
||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
@@ -69,7 +73,7 @@ describe('FontStore', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('defaults limit to 50 when not provided', () => {
|
it('defaults limit to 50 when not provided', () => {
|
||||||
const store = new FontStore();
|
const store = new FontCatalogStore();
|
||||||
expect(store.params.limit).toBe(50);
|
expect(store.params.limit).toBe(50);
|
||||||
store.destroy();
|
store.destroy();
|
||||||
});
|
});
|
||||||
@@ -390,11 +394,11 @@ describe('FontStore', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('nextPage', () => {
|
describe('nextPage', () => {
|
||||||
let store: FontStore;
|
let store: FontCatalogStore;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
fetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 30, limit: 10, offset: 0 }));
|
fetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 30, limit: 10, offset: 0 }));
|
||||||
store = new FontStore({ limit: 10 });
|
store = new FontCatalogStore({ limit: 10 });
|
||||||
await store.refetch();
|
await store.refetch();
|
||||||
flushSync();
|
flushSync();
|
||||||
});
|
});
|
||||||
@@ -415,7 +419,7 @@ describe('FontStore', () => {
|
|||||||
// Set up a store where all fonts fit in one page (hasMore = false)
|
// Set up a store where all fonts fit in one page (hasMore = false)
|
||||||
queryClient.clear();
|
queryClient.clear();
|
||||||
fetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 10, limit: 10, offset: 0 }));
|
fetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 10, limit: 10, offset: 0 }));
|
||||||
store = new FontStore({ limit: 10 });
|
store = new FontCatalogStore({ limit: 10 });
|
||||||
await store.refetch();
|
await store.refetch();
|
||||||
flushSync();
|
flushSync();
|
||||||
|
|
||||||
@@ -454,7 +458,7 @@ describe('FontStore', () => {
|
|||||||
describe('getCachedData / setQueryData', () => {
|
describe('getCachedData / setQueryData', () => {
|
||||||
it('getCachedData returns undefined before any fetch', () => {
|
it('getCachedData returns undefined before any fetch', () => {
|
||||||
queryClient.clear();
|
queryClient.clear();
|
||||||
const store = new FontStore({ limit: 10 });
|
const store = new FontCatalogStore({ limit: 10 });
|
||||||
expect(store.getCachedData()).toBeUndefined();
|
expect(store.getCachedData()).toBeUndefined();
|
||||||
store.destroy();
|
store.destroy();
|
||||||
});
|
});
|
||||||
@@ -502,7 +506,7 @@ describe('FontStore', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('filter shortcut methods', () => {
|
describe('filter shortcut methods', () => {
|
||||||
let store: FontStore;
|
let store: FontCatalogStore;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
store = makeStore();
|
store = makeStore();
|
||||||
+11
-7
@@ -1,4 +1,8 @@
|
|||||||
import { queryClient } from '$shared/api/queryClient';
|
import {
|
||||||
|
DEFAULT_QUERY_GC_TIME_MS,
|
||||||
|
DEFAULT_QUERY_STALE_TIME_MS,
|
||||||
|
queryClient,
|
||||||
|
} from '$shared/api/queryClient';
|
||||||
import {
|
import {
|
||||||
type InfiniteData,
|
type InfiniteData,
|
||||||
InfiniteQueryObserver,
|
InfiniteQueryObserver,
|
||||||
@@ -25,7 +29,7 @@ type FontStoreParams = Omit<ProxyFontsParams, 'offset'>;
|
|||||||
|
|
||||||
type FontStoreResult = InfiniteQueryObserverResult<InfiniteData<ProxyFontsResponse, PageParam>, Error>;
|
type FontStoreResult = InfiniteQueryObserverResult<InfiniteData<ProxyFontsResponse, PageParam>, Error>;
|
||||||
|
|
||||||
export class FontStore {
|
export class FontCatalogStore {
|
||||||
#params = $state<FontStoreParams>({ limit: 50 });
|
#params = $state<FontStoreParams>({ limit: 50 });
|
||||||
#result = $state<FontStoreResult>({} as FontStoreResult);
|
#result = $state<FontStoreResult>({} as FontStoreResult);
|
||||||
#observer: InfiniteQueryObserver<
|
#observer: InfiniteQueryObserver<
|
||||||
@@ -427,8 +431,8 @@ export class FontStore {
|
|||||||
const next = lastPage.offset + lastPage.limit;
|
const next = lastPage.offset + lastPage.limit;
|
||||||
return next < lastPage.total ? { offset: next } : undefined;
|
return next < lastPage.total ? { offset: next } : undefined;
|
||||||
},
|
},
|
||||||
staleTime: hasFilters ? 0 : 5 * 60 * 1000,
|
staleTime: hasFilters ? 0 : DEFAULT_QUERY_STALE_TIME_MS,
|
||||||
gcTime: 10 * 60 * 1000,
|
gcTime: DEFAULT_QUERY_GC_TIME_MS,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -459,8 +463,8 @@ export class FontStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createFontStore(params: FontStoreParams = {}): FontStore {
|
export function createFontCatalogStore(params: FontStoreParams = {}): FontCatalogStore {
|
||||||
return new FontStore(params);
|
return new FontCatalogStore(params);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const fontStore = new FontStore({ limit: 50 });
|
export const fontCatalogStore = new FontCatalogStore({ limit: 50 });
|
||||||
+38
-12
@@ -17,7 +17,36 @@ import { FontBufferCache } from './utils/fontBufferCache/FontBufferCache';
|
|||||||
import { FontEvictionPolicy } from './utils/fontEvictionPolicy/FontEvictionPolicy';
|
import { FontEvictionPolicy } from './utils/fontEvictionPolicy/FontEvictionPolicy';
|
||||||
import { FontLoadQueue } from './utils/fontLoadQueue/FontLoadQueue';
|
import { FontLoadQueue } from './utils/fontLoadQueue/FontLoadQueue';
|
||||||
|
|
||||||
interface AppliedFontsManagerDeps {
|
/**
|
||||||
|
* How often the periodic eviction sweep runs.
|
||||||
|
*/
|
||||||
|
const PURGE_INTERVAL_MS = 60000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Timeout for `requestIdleCallback`. After this elapses, the callback is
|
||||||
|
* forced to run regardless of whether the browser is idle.
|
||||||
|
*/
|
||||||
|
const IDLE_CALLBACK_TIMEOUT_MS = 150;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* setTimeout fallback delay when `requestIdleCallback` is unavailable.
|
||||||
|
* ~16ms ≈ one frame at 60fps.
|
||||||
|
*/
|
||||||
|
const SCHEDULE_FALLBACK_MS = 16;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How often the parse loop yields back to the main thread when the browser
|
||||||
|
* does not provide `isInputPending` (non-Chromium fallback).
|
||||||
|
*/
|
||||||
|
const YIELD_INTERVAL_MS = 8;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Font weights treated as "critical" in data-saver mode. Other weights are
|
||||||
|
* skipped to reduce network usage; variable fonts bypass this filter.
|
||||||
|
*/
|
||||||
|
const CRITICAL_FONT_WEIGHTS = [400, 700];
|
||||||
|
|
||||||
|
interface FontLifecycleManagerDeps {
|
||||||
cache?: FontBufferCache;
|
cache?: FontBufferCache;
|
||||||
eviction?: FontEvictionPolicy;
|
eviction?: FontEvictionPolicy;
|
||||||
queue?: FontLoadQueue;
|
queue?: FontLoadQueue;
|
||||||
@@ -46,7 +75,7 @@ interface AppliedFontsManagerDeps {
|
|||||||
*
|
*
|
||||||
* **Browser APIs Used:** `scheduler.yield()`, `isInputPending()`, `requestIdleCallback`, Cache API, Network Information API
|
* **Browser APIs Used:** `scheduler.yield()`, `isInputPending()`, `requestIdleCallback`, Cache API, Network Information API
|
||||||
*/
|
*/
|
||||||
export class AppliedFontsManager {
|
export class FontLifecycleManager {
|
||||||
// Injected collaborators - each handles one concern for better testability
|
// Injected collaborators - each handles one concern for better testability
|
||||||
readonly #cache: FontBufferCache;
|
readonly #cache: FontBufferCache;
|
||||||
readonly #eviction: FontEvictionPolicy;
|
readonly #eviction: FontEvictionPolicy;
|
||||||
@@ -70,22 +99,20 @@ export class AppliedFontsManager {
|
|||||||
// Tracks which callback type is pending ('idle' | 'timeout' | null) for proper cancellation
|
// Tracks which callback type is pending ('idle' | 'timeout' | null) for proper cancellation
|
||||||
#pendingType: 'idle' | 'timeout' | null = null;
|
#pendingType: 'idle' | 'timeout' | null = null;
|
||||||
|
|
||||||
readonly #PURGE_INTERVAL = 60000;
|
|
||||||
|
|
||||||
// Reactive status map for Svelte components to track font states
|
// Reactive status map for Svelte components to track font states
|
||||||
statuses = new SvelteMap<string, FontLoadStatus>();
|
statuses = new SvelteMap<string, FontLoadStatus>();
|
||||||
|
|
||||||
// Starts periodic cleanup timer (browser-only).
|
// Starts periodic cleanup timer (browser-only).
|
||||||
constructor(
|
constructor(
|
||||||
{ cache = new FontBufferCache(), eviction = new FontEvictionPolicy(), queue = new FontLoadQueue() }:
|
{ cache = new FontBufferCache(), eviction = new FontEvictionPolicy(), queue = new FontLoadQueue() }:
|
||||||
AppliedFontsManagerDeps = {},
|
FontLifecycleManagerDeps = {},
|
||||||
) {
|
) {
|
||||||
// Inject collaborators - defaults provided for production, fakes for testing
|
// Inject collaborators - defaults provided for production, fakes for testing
|
||||||
this.#cache = cache;
|
this.#cache = cache;
|
||||||
this.#eviction = eviction;
|
this.#eviction = eviction;
|
||||||
this.#queue = queue;
|
this.#queue = queue;
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
this.#intervalId = setInterval(() => this.#purgeUnused(), this.#PURGE_INTERVAL);
|
this.#intervalId = setInterval(() => this.#purgeUnused(), PURGE_INTERVAL_MS);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,11 +174,11 @@ export class AppliedFontsManager {
|
|||||||
if (typeof requestIdleCallback !== 'undefined') {
|
if (typeof requestIdleCallback !== 'undefined') {
|
||||||
this.#timeoutId = requestIdleCallback(
|
this.#timeoutId = requestIdleCallback(
|
||||||
() => this.#processQueue(),
|
() => this.#processQueue(),
|
||||||
{ timeout: 150 },
|
{ timeout: IDLE_CALLBACK_TIMEOUT_MS },
|
||||||
) as unknown as ReturnType<typeof setTimeout>;
|
) as unknown as ReturnType<typeof setTimeout>;
|
||||||
this.#pendingType = 'idle';
|
this.#pendingType = 'idle';
|
||||||
} else {
|
} else {
|
||||||
this.#timeoutId = setTimeout(() => this.#processQueue(), 16);
|
this.#timeoutId = setTimeout(() => this.#processQueue(), SCHEDULE_FALLBACK_MS);
|
||||||
this.#pendingType = 'timeout';
|
this.#pendingType = 'timeout';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -183,7 +210,7 @@ export class AppliedFontsManager {
|
|||||||
|
|
||||||
// In data-saver mode, only load variable fonts and common weights (400, 700)
|
// In data-saver mode, only load variable fonts and common weights (400, 700)
|
||||||
if (this.#shouldDeferNonCritical()) {
|
if (this.#shouldDeferNonCritical()) {
|
||||||
entries = entries.filter(([, c]) => c.isVariable || [400, 700].includes(c.weight));
|
entries = entries.filter(([, c]) => c.isVariable || CRITICAL_FONT_WEIGHTS.includes(c.weight));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine optimal concurrent fetches based on network speed (1-4)
|
// Determine optimal concurrent fetches based on network speed (1-4)
|
||||||
@@ -198,7 +225,6 @@ export class AppliedFontsManager {
|
|||||||
// Parse buffers one at a time with periodic yields to avoid blocking UI
|
// Parse buffers one at a time with periodic yields to avoid blocking UI
|
||||||
const hasInputPending = !!(navigator as any).scheduling?.isInputPending;
|
const hasInputPending = !!(navigator as any).scheduling?.isInputPending;
|
||||||
let lastYield = performance.now();
|
let lastYield = performance.now();
|
||||||
const YIELD_INTERVAL = 8;
|
|
||||||
|
|
||||||
for (const [key, config] of entries) {
|
for (const [key, config] of entries) {
|
||||||
const buffer = buffers.get(key);
|
const buffer = buffers.get(key);
|
||||||
@@ -214,7 +240,7 @@ export class AppliedFontsManager {
|
|||||||
// Others: yield every 8ms as fallback
|
// Others: yield every 8ms as fallback
|
||||||
const shouldYield = hasInputPending
|
const shouldYield = hasInputPending
|
||||||
? (navigator as any).scheduling.isInputPending({ includeContinuous: true })
|
? (navigator as any).scheduling.isInputPending({ includeContinuous: true })
|
||||||
: performance.now() - lastYield > YIELD_INTERVAL;
|
: performance.now() - lastYield > YIELD_INTERVAL_MS;
|
||||||
|
|
||||||
if (shouldYield) {
|
if (shouldYield) {
|
||||||
await yieldToMainThread();
|
await yieldToMainThread();
|
||||||
@@ -396,4 +422,4 @@ 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();
|
export const fontLifecycleManager = new FontLifecycleManager();
|
||||||
+8
-8
@@ -1,8 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* @vitest-environment jsdom
|
* @vitest-environment jsdom
|
||||||
*/
|
*/
|
||||||
import { AppliedFontsManager } from './appliedFontsStore.svelte';
|
|
||||||
import { FontFetchError } from './errors';
|
import { FontFetchError } from './errors';
|
||||||
|
import { FontLifecycleManager } from './fontLifecycleManager.svelte';
|
||||||
import { FontEvictionPolicy } from './utils/fontEvictionPolicy/FontEvictionPolicy';
|
import { FontEvictionPolicy } from './utils/fontEvictionPolicy/FontEvictionPolicy';
|
||||||
|
|
||||||
class FakeBufferCache {
|
class FakeBufferCache {
|
||||||
@@ -32,8 +32,8 @@ const makeConfig = (id: string, overrides: Partial<{ weight: number; isVariable:
|
|||||||
...overrides,
|
...overrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('AppliedFontsManager', () => {
|
describe('FontLifecycleManager', () => {
|
||||||
let manager: AppliedFontsManager;
|
let manager: FontLifecycleManager;
|
||||||
let eviction: FontEvictionPolicy;
|
let eviction: FontEvictionPolicy;
|
||||||
let mockFontFaceSet: { add: ReturnType<typeof vi.fn>; delete: ReturnType<typeof vi.fn> };
|
let mockFontFaceSet: { add: ReturnType<typeof vi.fn>; delete: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
@@ -55,7 +55,7 @@ describe('AppliedFontsManager', () => {
|
|||||||
});
|
});
|
||||||
vi.stubGlobal('FontFace', MockFontFace);
|
vi.stubGlobal('FontFace', MockFontFace);
|
||||||
|
|
||||||
manager = new AppliedFontsManager({ cache: new FakeBufferCache() as any, eviction });
|
manager = new FontLifecycleManager({ cache: new FakeBufferCache() as any, eviction });
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -101,7 +101,7 @@ describe('AppliedFontsManager', () => {
|
|||||||
|
|
||||||
it('skips fonts that have exhausted retries', async () => {
|
it('skips fonts that have exhausted retries', async () => {
|
||||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
const failManager = new AppliedFontsManager({ cache: new FailingBufferCache() as any, eviction });
|
const failManager = new FontLifecycleManager({ cache: new FailingBufferCache() as any, eviction });
|
||||||
|
|
||||||
// exhaust all 3 retries
|
// exhaust all 3 retries
|
||||||
for (let i = 0; i < 3; i++) {
|
for (let i = 0; i < 3; i++) {
|
||||||
@@ -160,7 +160,7 @@ describe('AppliedFontsManager', () => {
|
|||||||
describe('Phase 1 — fetch', () => {
|
describe('Phase 1 — fetch', () => {
|
||||||
it('sets status to error on fetch failure', async () => {
|
it('sets status to error on fetch failure', async () => {
|
||||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
const failManager = new AppliedFontsManager({ cache: new FailingBufferCache() as any, eviction });
|
const failManager = new FontLifecycleManager({ cache: new FailingBufferCache() as any, eviction });
|
||||||
|
|
||||||
failManager.touch([makeConfig('broken')]);
|
failManager.touch([makeConfig('broken')]);
|
||||||
await vi.advanceTimersByTimeAsync(50);
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
@@ -171,7 +171,7 @@ describe('AppliedFontsManager', () => {
|
|||||||
|
|
||||||
it('logs a console error on fetch failure', async () => {
|
it('logs a console error on fetch failure', async () => {
|
||||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
const failManager = new AppliedFontsManager({ cache: new FailingBufferCache() as any, eviction });
|
const failManager = new FontLifecycleManager({ cache: new FailingBufferCache() as any, eviction });
|
||||||
|
|
||||||
failManager.touch([makeConfig('broken')]);
|
failManager.touch([makeConfig('broken')]);
|
||||||
await vi.advanceTimersByTimeAsync(50);
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
@@ -189,7 +189,7 @@ describe('AppliedFontsManager', () => {
|
|||||||
evict() {},
|
evict() {},
|
||||||
clear() {},
|
clear() {},
|
||||||
};
|
};
|
||||||
const abortManager = new AppliedFontsManager({ cache: abortingCache as any, eviction });
|
const abortManager = new FontLifecycleManager({ cache: abortingCache as any, eviction });
|
||||||
|
|
||||||
abortManager.touch([makeConfig('aborted')]);
|
abortManager.touch([makeConfig('aborted')]);
|
||||||
await vi.advanceTimersByTimeAsync(50);
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
+7
-2
@@ -1,6 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* Default TTL after which an unpinned font is eligible for eviction.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_FONT_TTL_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
interface FontEvictionPolicyOptions {
|
interface FontEvictionPolicyOptions {
|
||||||
/**
|
/**
|
||||||
* TTL in milliseconds. Defaults to 5 minutes.
|
* TTL in milliseconds. Defaults to {@link DEFAULT_FONT_TTL_MS}.
|
||||||
*/
|
*/
|
||||||
ttl?: number;
|
ttl?: number;
|
||||||
}
|
}
|
||||||
@@ -17,7 +22,7 @@ export class FontEvictionPolicy {
|
|||||||
|
|
||||||
readonly #TTL: number;
|
readonly #TTL: number;
|
||||||
|
|
||||||
constructor({ ttl = 5 * 60 * 1000 }: FontEvictionPolicyOptions = {}) {
|
constructor({ ttl = DEFAULT_FONT_TTL_MS }: FontEvictionPolicyOptions = {}) {
|
||||||
this.#TTL = ttl;
|
this.#TTL = ttl;
|
||||||
}
|
}
|
||||||
|
|
||||||
+7
-3
@@ -1,5 +1,11 @@
|
|||||||
import type { FontLoadRequestConfig } from '../../../../types';
|
import type { FontLoadRequestConfig } from '../../../../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum number of times a single font key will be retried before it is
|
||||||
|
* considered permanently failed.
|
||||||
|
*/
|
||||||
|
export const FONT_LOAD_MAX_RETRIES = 3;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages the font load queue and per-font retry counts.
|
* Manages the font load queue and per-font retry counts.
|
||||||
*
|
*
|
||||||
@@ -10,8 +16,6 @@ export class FontLoadQueue {
|
|||||||
#queue = new Map<string, FontLoadRequestConfig>();
|
#queue = new Map<string, FontLoadRequestConfig>();
|
||||||
#retryCounts = new Map<string, number>();
|
#retryCounts = new Map<string, number>();
|
||||||
|
|
||||||
readonly #MAX_RETRIES = 3;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds a font to the queue.
|
* Adds a font to the queue.
|
||||||
* @returns `true` if the key was newly enqueued, `false` if it was already present.
|
* @returns `true` if the key was newly enqueued, `false` if it was already present.
|
||||||
@@ -52,7 +56,7 @@ export class FontLoadQueue {
|
|||||||
* 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 {
|
isMaxRetriesReached(key: string): boolean {
|
||||||
return (this.#retryCounts.get(key) ?? 0) >= this.#MAX_RETRIES;
|
return (this.#retryCounts.get(key) ?? 0) >= FONT_LOAD_MAX_RETRIES;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1,12 +1,9 @@
|
|||||||
// Applied fonts manager
|
// Font lifecycle manager (browser-side load + cache + eviction)
|
||||||
export * from './appliedFontsStore/appliedFontsStore.svelte';
|
export * from './fontLifecycleManager/fontLifecycleManager.svelte';
|
||||||
|
|
||||||
// Batch font store
|
// Paginated catalog
|
||||||
export { BatchFontStore } from './batchFontStore.svelte';
|
|
||||||
|
|
||||||
// Single FontStore
|
|
||||||
export {
|
export {
|
||||||
createFontStore,
|
createFontCatalogStore,
|
||||||
FontStore,
|
FontCatalogStore,
|
||||||
fontStore,
|
fontCatalogStore,
|
||||||
} from './fontStore/fontStore.svelte';
|
} from './fontCatalogStore/fontCatalogStore.svelte';
|
||||||
|
|||||||
@@ -23,5 +23,5 @@ export type {
|
|||||||
FontCollectionState,
|
FontCollectionState,
|
||||||
} from './store';
|
} from './store';
|
||||||
|
|
||||||
export * from './store/appliedFonts';
|
export * from './store/fontLifecycle';
|
||||||
export * from './typography';
|
export * from './typography';
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ const fontArialBold = mockUnifiedFont({ id: 'arial-bold', name: 'Arial' });
|
|||||||
docs: {
|
docs: {
|
||||||
description: {
|
description: {
|
||||||
story:
|
story:
|
||||||
'Font that has never been loaded by appliedFontsManager. The component renders in its pending state: blurred, scaled down, and semi-transparent.',
|
'Font that has never been loaded by fontLifecycleManager. The component renders in its pending state: blurred, scaled down, and semi-transparent.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
@@ -58,7 +58,7 @@ const fontArialBold = mockUnifiedFont({ id: 'arial-bold', name: 'Arial' });
|
|||||||
docs: {
|
docs: {
|
||||||
description: {
|
description: {
|
||||||
story:
|
story:
|
||||||
'Uses Arial, a system font available in all browsers. Because appliedFontsManager has not loaded it via FontFace, the manager status may remain pending — meaning the blur/scale state may still show. In a real app the manager would load the font and transition to the revealed state.',
|
'Uses Arial, a system font available in all browsers. Because fontLifecycleManager has not loaded it via FontFace, the manager status may remain pending — meaning the blur/scale state may still show. In a real app the manager would load the font and transition to the revealed state.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
@@ -77,7 +77,7 @@ const fontArialBold = mockUnifiedFont({ id: 'arial-bold', name: 'Arial' });
|
|||||||
docs: {
|
docs: {
|
||||||
description: {
|
description: {
|
||||||
story:
|
story:
|
||||||
'Demonstrates passing a custom weight (700). The weight is forwarded to appliedFontsManager for font resolution; visually identical to the loaded state story until the manager confirms the font.',
|
'Demonstrates passing a custom weight (700). The weight is forwarded to fontLifecycleManager for font resolution; visually identical to the loaded state story until the manager confirms the font.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import type { Snippet } from 'svelte';
|
|||||||
import {
|
import {
|
||||||
DEFAULT_FONT_WEIGHT,
|
DEFAULT_FONT_WEIGHT,
|
||||||
type UnifiedFont,
|
type UnifiedFont,
|
||||||
appliedFontsManager,
|
fontLifecycleManager,
|
||||||
} from '../../model';
|
} from '../../model';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -46,7 +46,7 @@ let {
|
|||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const status = $derived(
|
const status = $derived(
|
||||||
appliedFontsManager.getFontStatus(
|
fontLifecycleManager.getFontStatus(
|
||||||
font.id,
|
font.id,
|
||||||
weight,
|
weight,
|
||||||
font.features?.isVariable,
|
font.features?.isVariable,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const { Story } = defineMeta({
|
|||||||
docs: {
|
docs: {
|
||||||
description: {
|
description: {
|
||||||
component:
|
component:
|
||||||
'Virtualized font list backed by the `fontStore` singleton. Handles font loading registration (pin/touch) for visible items and triggers infinite scroll pagination via `fontStore.nextPage()`. Because the component reads directly from the `fontStore` singleton, stories render against a live (but empty/loading) store — no font data will appear unless the API is reachable from the Storybook host.',
|
'Virtualized font list backed by the `fontCatalogStore` singleton. Handles font loading registration (pin/touch) for visible items and triggers infinite scroll pagination via `fontCatalogStore.nextPage()`. Because the component reads directly from the `fontCatalogStore` singleton, stories render against a live (but empty/loading) store — no font data will appear unless the API is reachable from the Storybook host.',
|
||||||
},
|
},
|
||||||
story: { inline: false },
|
story: { inline: false },
|
||||||
},
|
},
|
||||||
@@ -33,7 +33,7 @@ import type { ComponentProps } from 'svelte';
|
|||||||
docs: {
|
docs: {
|
||||||
description: {
|
description: {
|
||||||
story:
|
story:
|
||||||
'Skeleton state shown while `fontStore.fonts` is empty and `fontStore.isLoading` is true. In a real session the skeleton fades out once the first page loads.',
|
'Skeleton state shown while `fontCatalogStore.fonts` is empty and `fontCatalogStore.isLoading` is true. In a real session the skeleton fades out once the first page loads.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
@@ -63,7 +63,7 @@ import type { ComponentProps } from 'svelte';
|
|||||||
docs: {
|
docs: {
|
||||||
description: {
|
description: {
|
||||||
story:
|
story:
|
||||||
'No `skeleton` snippet provided. When `fontStore.fonts` is empty the underlying VirtualList renders its empty state directly.',
|
'No `skeleton` snippet provided. When `fontCatalogStore.fonts` is empty the underlying VirtualList renders its empty state directly.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
@@ -86,7 +86,7 @@ import type { ComponentProps } from 'svelte';
|
|||||||
docs: {
|
docs: {
|
||||||
description: {
|
description: {
|
||||||
story:
|
story:
|
||||||
'Demonstrates how to configure a `children` snippet for item rendering. The list will be empty because `fontStore` is not populated in Storybook, but the template shows the expected slot shape: `{ item: UnifiedFont }`.',
|
'Demonstrates how to configure a `children` snippet for item rendering. The list will be empty because `fontCatalogStore` is not populated in Storybook, but the template shows the expected slot shape: `{ item: UnifiedFont }`.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ import { getFontUrl } from '../../lib';
|
|||||||
import {
|
import {
|
||||||
type FontLoadRequestConfig,
|
type FontLoadRequestConfig,
|
||||||
type UnifiedFont,
|
type UnifiedFont,
|
||||||
appliedFontsManager,
|
fontCatalogStore,
|
||||||
fontStore,
|
fontLifecycleManager,
|
||||||
} from '../../model';
|
} from '../../model';
|
||||||
|
|
||||||
interface Props extends
|
interface Props extends
|
||||||
@@ -51,13 +51,13 @@ let {
|
|||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const isLoading = $derived(
|
const isLoading = $derived(
|
||||||
fontStore.isFetching || fontStore.isLoading,
|
fontCatalogStore.isFetching || fontCatalogStore.isLoading,
|
||||||
);
|
);
|
||||||
|
|
||||||
let visibleFonts = $state<UnifiedFont[]>([]);
|
let visibleFonts = $state<UnifiedFont[]>([]);
|
||||||
let isCatchingUp = $state(false);
|
let isCatchingUp = $state(false);
|
||||||
|
|
||||||
const showInitialSkeleton = $derived(!!skeleton && isLoading && fontStore.fonts.length === 0);
|
const showInitialSkeleton = $derived(!!skeleton && isLoading && fontCatalogStore.fonts.length === 0);
|
||||||
const showCatchupSkeleton = $derived(!!skeleton && isCatchingUp);
|
const showCatchupSkeleton = $derived(!!skeleton && isCatchingUp);
|
||||||
|
|
||||||
function handleInternalVisibleChange(items: UnifiedFont[]) {
|
function handleInternalVisibleChange(items: UnifiedFont[]) {
|
||||||
@@ -68,24 +68,30 @@ function handleInternalVisibleChange(items: UnifiedFont[]) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle jump scroll — batch-load all missing pages then re-enable font loading.
|
* Handle jump scroll — batch-load all missing pages then re-enable font loading.
|
||||||
* Suppresses appliedFontsManager.touch() during catch-up to avoid loading
|
* Suppresses fontLifecycleManager.touch() during catch-up to avoid loading
|
||||||
* font files for thousands of intermediate fonts.
|
* font files for thousands of intermediate fonts.
|
||||||
*/
|
*/
|
||||||
async function handleJump(targetIndex: number) {
|
async function handleJump(targetIndex: number) {
|
||||||
if (isCatchingUp || !fontStore.pagination.hasMore) {
|
if (isCatchingUp || !fontCatalogStore.pagination.hasMore) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
isCatchingUp = true;
|
isCatchingUp = true;
|
||||||
try {
|
try {
|
||||||
await fontStore.fetchAllPagesTo(targetIndex);
|
await fontCatalogStore.fetchAllPagesTo(targetIndex);
|
||||||
} finally {
|
} finally {
|
||||||
isCatchingUp = false;
|
isCatchingUp = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debounce wait before asking the font lifecycle manager to load fonts
|
||||||
|
* for the current visible window. Coalesces rapid scroll into one batch.
|
||||||
|
*/
|
||||||
|
const TOUCH_DEBOUNCE_MS = 150;
|
||||||
|
|
||||||
const debouncedTouch = debounce((configs: FontLoadRequestConfig[]) => {
|
const debouncedTouch = debounce((configs: FontLoadRequestConfig[]) => {
|
||||||
appliedFontsManager.touch(configs);
|
fontLifecycleManager.touch(configs);
|
||||||
}, 150);
|
}, TOUCH_DEBOUNCE_MS);
|
||||||
|
|
||||||
// Re-touch whenever visible set or weight changes — fixes weight-change gap
|
// Re-touch whenever visible set or weight changes — fixes weight-change gap
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -111,11 +117,11 @@ $effect(() => {
|
|||||||
const w = weight;
|
const w = weight;
|
||||||
const fonts = visibleFonts;
|
const fonts = visibleFonts;
|
||||||
for (const f of fonts) {
|
for (const f of fonts) {
|
||||||
appliedFontsManager.pin(f.id, w, f.features?.isVariable);
|
fontLifecycleManager.pin(f.id, w, f.features?.isVariable);
|
||||||
}
|
}
|
||||||
return () => {
|
return () => {
|
||||||
for (const f of fonts) {
|
for (const f of fonts) {
|
||||||
appliedFontsManager.unpin(f.id, w, f.features?.isVariable);
|
fontLifecycleManager.unpin(f.id, w, f.features?.isVariable);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -125,12 +131,12 @@ $effect(() => {
|
|||||||
*/
|
*/
|
||||||
function loadMore() {
|
function loadMore() {
|
||||||
if (
|
if (
|
||||||
!fontStore.pagination.hasMore
|
!fontCatalogStore.pagination.hasMore
|
||||||
|| fontStore.isFetching
|
|| fontCatalogStore.isFetching
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
fontStore.nextPage();
|
fontCatalogStore.nextPage();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -140,12 +146,12 @@ function loadMore() {
|
|||||||
* of the loaded items. Only fetches if there are more pages available.
|
* of the loaded items. Only fetches if there are more pages available.
|
||||||
*/
|
*/
|
||||||
function handleNearBottom(_lastVisibleIndex: number) {
|
function handleNearBottom(_lastVisibleIndex: number) {
|
||||||
const { hasMore } = fontStore.pagination;
|
const { hasMore } = fontCatalogStore.pagination;
|
||||||
|
|
||||||
// VirtualList already checks if we're near the bottom of loaded items.
|
// VirtualList already checks if we're near the bottom of loaded items.
|
||||||
// Guard isCatchingUp: fetchAllPagesTo bypasses TQ so isFetching stays false
|
// Guard isCatchingUp: fetchAllPagesTo bypasses TQ so isFetching stays false
|
||||||
// during batch catch-up, which would otherwise let nextPage() race with it.
|
// during batch catch-up, which would otherwise let nextPage() race with it.
|
||||||
if (hasMore && !fontStore.isFetching && !isCatchingUp) {
|
if (hasMore && !fontCatalogStore.isFetching && !isCatchingUp) {
|
||||||
loadMore();
|
loadMore();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -160,8 +166,8 @@ function handleNearBottom(_lastVisibleIndex: number) {
|
|||||||
{:else}
|
{:else}
|
||||||
<!-- VirtualList persists during pagination - no destruction/recreation -->
|
<!-- VirtualList persists during pagination - no destruction/recreation -->
|
||||||
<VirtualList
|
<VirtualList
|
||||||
items={fontStore.fonts}
|
items={fontCatalogStore.fonts}
|
||||||
total={fontStore.pagination.total}
|
total={fontCatalogStore.pagination.total}
|
||||||
isLoading={isLoading || isCatchingUp}
|
isLoading={isLoading || isCatchingUp}
|
||||||
onVisibleItemsChange={handleInternalVisibleChange}
|
onVisibleItemsChange={handleInternalVisibleChange}
|
||||||
onNearBottom={handleNearBottom}
|
onNearBottom={handleNearBottom}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export {
|
||||||
|
createTypographySettingsStore,
|
||||||
|
type TypographySettingsStore,
|
||||||
|
typographySettingsStore,
|
||||||
|
} from './model';
|
||||||
|
export { TypographyMenu } from './ui';
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export {
|
||||||
|
createTypographySettingsStore,
|
||||||
|
type TypographySettingsStore,
|
||||||
|
typographySettingsStore,
|
||||||
|
} from './store/typographySettingsStore/typographySettingsStore.svelte';
|
||||||
+32
-5
@@ -16,6 +16,7 @@ import {
|
|||||||
DEFAULT_FONT_WEIGHT,
|
DEFAULT_FONT_WEIGHT,
|
||||||
DEFAULT_LETTER_SPACING,
|
DEFAULT_LETTER_SPACING,
|
||||||
DEFAULT_LINE_HEIGHT,
|
DEFAULT_LINE_HEIGHT,
|
||||||
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
} from '$entities/Font';
|
} from '$entities/Font';
|
||||||
import {
|
import {
|
||||||
type ControlDataModel,
|
type ControlDataModel,
|
||||||
@@ -27,6 +28,14 @@ import {
|
|||||||
} from '$shared/lib';
|
} from '$shared/lib';
|
||||||
import { SvelteMap } from 'svelte/reactivity';
|
import { SvelteMap } from 'svelte/reactivity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Epsilon for detecting "significant" base-size changes when reconciling
|
||||||
|
* the multiplier-derived display value back to the underlying baseSize.
|
||||||
|
* Differences below this threshold are treated as rounding jitter and
|
||||||
|
* skipped to avoid spurious storage writes.
|
||||||
|
*/
|
||||||
|
const BASE_SIZE_EPSILON = 0.01;
|
||||||
|
|
||||||
type ControlOnlyFields<T extends string = string> = Omit<ControlModel<T>, keyof ControlDataModel>;
|
type ControlOnlyFields<T extends string = string> = Omit<ControlModel<T>, keyof ControlDataModel>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -67,7 +76,7 @@ export interface TypographySettings {
|
|||||||
* Manages multiple typography controls with persistent storage and
|
* Manages multiple typography controls with persistent storage and
|
||||||
* responsive scaling support for font size.
|
* responsive scaling support for font size.
|
||||||
*/
|
*/
|
||||||
export class TypographySettingsManager {
|
export class TypographySettingsStore {
|
||||||
/**
|
/**
|
||||||
* Internal map of reactive controls keyed by their identifier
|
* Internal map of reactive controls keyed by their identifier
|
||||||
*/
|
*/
|
||||||
@@ -138,7 +147,7 @@ export class TypographySettingsManager {
|
|||||||
const calculatedBase = currentDisplayValue / this.#multiplier;
|
const calculatedBase = currentDisplayValue / this.#multiplier;
|
||||||
|
|
||||||
// Only update if the difference is significant (prevents rounding jitter)
|
// Only update if the difference is significant (prevents rounding jitter)
|
||||||
if (Math.abs(this.#baseSize - calculatedBase) > 0.01) {
|
if (Math.abs(this.#baseSize - calculatedBase) > BASE_SIZE_EPSILON) {
|
||||||
this.#baseSize = calculatedBase;
|
this.#baseSize = calculatedBase;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -296,6 +305,16 @@ export class TypographySettingsManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default factory storage key — used when a caller doesn't pass one.
|
||||||
|
*/
|
||||||
|
const DEFAULT_STORAGE_KEY = 'glyphdiff:typography';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Storage key used by the app-wide singleton (scoped to comparison view).
|
||||||
|
*/
|
||||||
|
const COMPARISON_STORAGE_KEY = 'glyphdiff:comparison:typography';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a typography control manager
|
* Creates a typography control manager
|
||||||
*
|
*
|
||||||
@@ -303,9 +322,9 @@ export class TypographySettingsManager {
|
|||||||
* @param storageId - Persistent storage identifier
|
* @param storageId - Persistent storage identifier
|
||||||
* @returns Typography control manager instance
|
* @returns Typography control manager instance
|
||||||
*/
|
*/
|
||||||
export function createTypographySettingsManager(
|
export function createTypographySettingsStore(
|
||||||
configs: ControlModel<ControlId>[],
|
configs: ControlModel<ControlId>[],
|
||||||
storageId: string = 'glyphdiff:typography',
|
storageId: string = DEFAULT_STORAGE_KEY,
|
||||||
) {
|
) {
|
||||||
const storage = createPersistentStore<TypographySettings>(storageId, {
|
const storage = createPersistentStore<TypographySettings>(storageId, {
|
||||||
fontSize: DEFAULT_FONT_SIZE,
|
fontSize: DEFAULT_FONT_SIZE,
|
||||||
@@ -313,5 +332,13 @@ export function createTypographySettingsManager(
|
|||||||
lineHeight: DEFAULT_LINE_HEIGHT,
|
lineHeight: DEFAULT_LINE_HEIGHT,
|
||||||
letterSpacing: DEFAULT_LETTER_SPACING,
|
letterSpacing: DEFAULT_LETTER_SPACING,
|
||||||
});
|
});
|
||||||
return new TypographySettingsManager(configs, storage);
|
return new TypographySettingsStore(configs, storage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App-wide typography settings singleton, keyed for the comparison view.
|
||||||
|
*/
|
||||||
|
export const typographySettingsStore = createTypographySettingsStore(
|
||||||
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
|
COMPARISON_STORAGE_KEY,
|
||||||
|
);
|
||||||
+45
-45
@@ -17,13 +17,13 @@ import {
|
|||||||
} from 'vitest';
|
} from 'vitest';
|
||||||
import {
|
import {
|
||||||
type TypographySettings,
|
type TypographySettings,
|
||||||
TypographySettingsManager,
|
TypographySettingsStore,
|
||||||
} from './settingsManager.svelte';
|
} from './typographySettingsStore.svelte';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Test Strategy for TypographySettingsManager
|
* Test Strategy for TypographySettingsStore
|
||||||
*
|
*
|
||||||
* This test suite validates the TypographySettingsManager state management logic.
|
* This test suite validates the TypographySettingsStore state management logic.
|
||||||
* These are unit tests for the manager logic, separate from component rendering.
|
* These are unit tests for the manager logic, separate from component rendering.
|
||||||
*
|
*
|
||||||
* NOTE: Svelte 5's $effect runs in microtasks, so we need to flush effects
|
* NOTE: Svelte 5's $effect runs in microtasks, so we need to flush effects
|
||||||
@@ -46,7 +46,7 @@ async function flushEffects() {
|
|||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('TypographySettingsManager - Unit Tests', () => {
|
describe('TypographySettingsStore - Unit Tests', () => {
|
||||||
let mockStorage: TypographySettings;
|
let mockStorage: TypographySettings;
|
||||||
let mockPersistentStore: {
|
let mockPersistentStore: {
|
||||||
value: TypographySettings;
|
value: TypographySettings;
|
||||||
@@ -86,7 +86,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
|
|
||||||
describe('Initialization', () => {
|
describe('Initialization', () => {
|
||||||
it('creates manager with default values from storage', () => {
|
it('creates manager with default values from storage', () => {
|
||||||
const manager = new TypographySettingsManager(
|
const manager = new TypographySettingsStore(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -106,7 +106,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
};
|
};
|
||||||
mockPersistentStore = createMockPersistentStore(mockStorage);
|
mockPersistentStore = createMockPersistentStore(mockStorage);
|
||||||
|
|
||||||
const manager = new TypographySettingsManager(
|
const manager = new TypographySettingsStore(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -118,7 +118,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('initializes font size control with base size multiplied by current multiplier (1)', () => {
|
it('initializes font size control with base size multiplied by current multiplier (1)', () => {
|
||||||
const manager = new TypographySettingsManager(
|
const manager = new TypographySettingsStore(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -127,7 +127,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns all controls via controls getter', () => {
|
it('returns all controls via controls getter', () => {
|
||||||
const manager = new TypographySettingsManager(
|
const manager = new TypographySettingsStore(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -143,7 +143,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns individual controls via specific getters', () => {
|
it('returns individual controls via specific getters', () => {
|
||||||
const manager = new TypographySettingsManager(
|
const manager = new TypographySettingsStore(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -161,7 +161,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('control instances have expected interface', () => {
|
it('control instances have expected interface', () => {
|
||||||
const manager = new TypographySettingsManager(
|
const manager = new TypographySettingsStore(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -180,7 +180,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
|
|
||||||
describe('Multiplier System', () => {
|
describe('Multiplier System', () => {
|
||||||
it('has default multiplier of 1', () => {
|
it('has default multiplier of 1', () => {
|
||||||
const manager = new TypographySettingsManager(
|
const manager = new TypographySettingsStore(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -189,7 +189,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('updates multiplier when set', () => {
|
it('updates multiplier when set', () => {
|
||||||
const manager = new TypographySettingsManager(
|
const manager = new TypographySettingsStore(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -202,7 +202,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('does not update multiplier if set to same value', () => {
|
it('does not update multiplier if set to same value', () => {
|
||||||
const manager = new TypographySettingsManager(
|
const manager = new TypographySettingsStore(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -218,7 +218,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
mockStorage = { fontSize: 48, fontWeight: 400, lineHeight: 1.5, letterSpacing: 0 };
|
mockStorage = { fontSize: 48, fontWeight: 400, lineHeight: 1.5, letterSpacing: 0 };
|
||||||
mockPersistentStore = createMockPersistentStore(mockStorage);
|
mockPersistentStore = createMockPersistentStore(mockStorage);
|
||||||
|
|
||||||
const manager = new TypographySettingsManager(
|
const manager = new TypographySettingsStore(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -242,7 +242,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('updates font size control display value when multiplier increases', () => {
|
it('updates font size control display value when multiplier increases', () => {
|
||||||
const manager = new TypographySettingsManager(
|
const manager = new TypographySettingsStore(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -263,7 +263,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
|
|
||||||
describe('Base Size Setter', () => {
|
describe('Base Size Setter', () => {
|
||||||
it('updates baseSize when set directly', () => {
|
it('updates baseSize when set directly', () => {
|
||||||
const manager = new TypographySettingsManager(
|
const manager = new TypographySettingsStore(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -274,7 +274,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('updates size control value when baseSize is set', () => {
|
it('updates size control value when baseSize is set', () => {
|
||||||
const manager = new TypographySettingsManager(
|
const manager = new TypographySettingsStore(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -285,7 +285,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('applies multiplier to size control when baseSize is set', () => {
|
it('applies multiplier to size control when baseSize is set', () => {
|
||||||
const manager = new TypographySettingsManager(
|
const manager = new TypographySettingsStore(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -299,7 +299,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
|
|
||||||
describe('Rendered Size Calculation', () => {
|
describe('Rendered Size Calculation', () => {
|
||||||
it('calculates renderedSize as baseSize * multiplier', () => {
|
it('calculates renderedSize as baseSize * multiplier', () => {
|
||||||
const manager = new TypographySettingsManager(
|
const manager = new TypographySettingsStore(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -308,7 +308,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('updates renderedSize when multiplier changes', () => {
|
it('updates renderedSize when multiplier changes', () => {
|
||||||
const manager = new TypographySettingsManager(
|
const manager = new TypographySettingsStore(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -321,7 +321,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('updates renderedSize when baseSize changes', () => {
|
it('updates renderedSize when baseSize changes', () => {
|
||||||
const manager = new TypographySettingsManager(
|
const manager = new TypographySettingsStore(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -341,7 +341,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
// proxy effect behavior should be tested in E2E tests.
|
// proxy effect behavior should be tested in E2E tests.
|
||||||
|
|
||||||
it('does NOT immediately update baseSize from control change (effect is async)', () => {
|
it('does NOT immediately update baseSize from control change (effect is async)', () => {
|
||||||
const manager = new TypographySettingsManager(
|
const manager = new TypographySettingsStore(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -356,7 +356,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('updates baseSize via direct setter (synchronous)', () => {
|
it('updates baseSize via direct setter (synchronous)', () => {
|
||||||
const manager = new TypographySettingsManager(
|
const manager = new TypographySettingsStore(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -381,7 +381,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
};
|
};
|
||||||
mockPersistentStore = createMockPersistentStore(mockStorage);
|
mockPersistentStore = createMockPersistentStore(mockStorage);
|
||||||
|
|
||||||
const manager = new TypographySettingsManager(
|
const manager = new TypographySettingsStore(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -394,7 +394,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('syncs to storage after effect flush (async)', async () => {
|
it('syncs to storage after effect flush (async)', async () => {
|
||||||
const manager = new TypographySettingsManager(
|
const manager = new TypographySettingsStore(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -410,7 +410,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('syncs control changes to storage after effect flush (async)', async () => {
|
it('syncs control changes to storage after effect flush (async)', async () => {
|
||||||
const manager = new TypographySettingsManager(
|
const manager = new TypographySettingsStore(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -423,7 +423,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('syncs height control changes to storage after effect flush (async)', async () => {
|
it('syncs height control changes to storage after effect flush (async)', async () => {
|
||||||
const manager = new TypographySettingsManager(
|
const manager = new TypographySettingsStore(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -435,7 +435,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('syncs spacing control changes to storage after effect flush (async)', async () => {
|
it('syncs spacing control changes to storage after effect flush (async)', async () => {
|
||||||
const manager = new TypographySettingsManager(
|
const manager = new TypographySettingsStore(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -449,7 +449,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
|
|
||||||
describe('Control Value Getters', () => {
|
describe('Control Value Getters', () => {
|
||||||
it('returns current weight value', () => {
|
it('returns current weight value', () => {
|
||||||
const manager = new TypographySettingsManager(
|
const manager = new TypographySettingsStore(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -461,7 +461,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns current height value', () => {
|
it('returns current height value', () => {
|
||||||
const manager = new TypographySettingsManager(
|
const manager = new TypographySettingsStore(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -473,7 +473,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns current spacing value', () => {
|
it('returns current spacing value', () => {
|
||||||
const manager = new TypographySettingsManager(
|
const manager = new TypographySettingsStore(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -486,7 +486,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
|
|
||||||
it('returns default value when control is not found', () => {
|
it('returns default value when control is not found', () => {
|
||||||
// Create a manager with empty configs (no controls)
|
// Create a manager with empty configs (no controls)
|
||||||
const manager = new TypographySettingsManager([], mockPersistentStore);
|
const manager = new TypographySettingsStore([], mockPersistentStore);
|
||||||
|
|
||||||
expect(manager.weight).toBe(DEFAULT_FONT_WEIGHT);
|
expect(manager.weight).toBe(DEFAULT_FONT_WEIGHT);
|
||||||
expect(manager.height).toBe(DEFAULT_LINE_HEIGHT);
|
expect(manager.height).toBe(DEFAULT_LINE_HEIGHT);
|
||||||
@@ -504,7 +504,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
};
|
};
|
||||||
mockPersistentStore = createMockPersistentStore(mockStorage);
|
mockPersistentStore = createMockPersistentStore(mockStorage);
|
||||||
|
|
||||||
const manager = new TypographySettingsManager(
|
const manager = new TypographySettingsStore(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -537,7 +537,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
clear: clearSpy,
|
clear: clearSpy,
|
||||||
};
|
};
|
||||||
|
|
||||||
const manager = new TypographySettingsManager(
|
const manager = new TypographySettingsStore(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -548,7 +548,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('respects multiplier when resetting font size control', () => {
|
it('respects multiplier when resetting font size control', () => {
|
||||||
const manager = new TypographySettingsManager(
|
const manager = new TypographySettingsStore(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -566,7 +566,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
|
|
||||||
describe('Complex Scenarios', () => {
|
describe('Complex Scenarios', () => {
|
||||||
it('handles changing multiplier then modifying baseSize', () => {
|
it('handles changing multiplier then modifying baseSize', () => {
|
||||||
const manager = new TypographySettingsManager(
|
const manager = new TypographySettingsStore(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -587,7 +587,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('maintains correct renderedSize throughout changes', () => {
|
it('maintains correct renderedSize throughout changes', () => {
|
||||||
const manager = new TypographySettingsManager(
|
const manager = new TypographySettingsStore(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -609,7 +609,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('handles multiple control changes in sequence', async () => {
|
it('handles multiple control changes in sequence', async () => {
|
||||||
const manager = new TypographySettingsManager(
|
const manager = new TypographySettingsStore(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -634,7 +634,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
mockStorage = { fontSize: 48, fontWeight: 400, lineHeight: 1.5, letterSpacing: 0 };
|
mockStorage = { fontSize: 48, fontWeight: 400, lineHeight: 1.5, letterSpacing: 0 };
|
||||||
mockPersistentStore = createMockPersistentStore(mockStorage);
|
mockPersistentStore = createMockPersistentStore(mockStorage);
|
||||||
|
|
||||||
const manager = new TypographySettingsManager(
|
const manager = new TypographySettingsStore(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -646,7 +646,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('handles very small multiplier', () => {
|
it('handles very small multiplier', () => {
|
||||||
const manager = new TypographySettingsManager(
|
const manager = new TypographySettingsStore(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -659,7 +659,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('handles large base size with multiplier', () => {
|
it('handles large base size with multiplier', () => {
|
||||||
const manager = new TypographySettingsManager(
|
const manager = new TypographySettingsStore(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -672,7 +672,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('handles floating point precision in multiplier', () => {
|
it('handles floating point precision in multiplier', () => {
|
||||||
const manager = new TypographySettingsManager(
|
const manager = new TypographySettingsStore(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -691,7 +691,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('handles control methods (increase/decrease)', () => {
|
it('handles control methods (increase/decrease)', () => {
|
||||||
const manager = new TypographySettingsManager(
|
const manager = new TypographySettingsStore(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
@@ -705,7 +705,7 @@ describe('TypographySettingsManager - Unit Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('handles control boundary conditions', () => {
|
it('handles control boundary conditions', () => {
|
||||||
const manager = new TypographySettingsManager(
|
const manager = new TypographySettingsStore(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
mockPersistentStore,
|
mockPersistentStore,
|
||||||
);
|
);
|
||||||
+8
-14
@@ -90,11 +90,8 @@ $effect(() => {
|
|||||||
align="end"
|
align="end"
|
||||||
sideOffset={8}
|
sideOffset={8}
|
||||||
class={cn(
|
class={cn(
|
||||||
'z-50 w-72',
|
'z-50 w-72 p-4 rounded-none',
|
||||||
'bg-surface dark:bg-dark-card',
|
'surface-popover',
|
||||||
'border border-subtle',
|
|
||||||
'shadow-[0_20px_40px_-10px_rgba(0,0,0,0.15)]',
|
|
||||||
'rounded-none p-4',
|
|
||||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||||
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||||
@@ -118,7 +115,7 @@ $effect(() => {
|
|||||||
{#snippet child({ props })}
|
{#snippet child({ props })}
|
||||||
<button
|
<button
|
||||||
{...props}
|
{...props}
|
||||||
class="inline-flex items-center justify-center size-6 rounded-none hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
|
class="flex-center size-6 rounded-none hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
|
||||||
aria-label="Close controls"
|
aria-label="Close controls"
|
||||||
>
|
>
|
||||||
<XIcon class="size-3.5 text-neutral-500" />
|
<XIcon class="size-3.5 text-neutral-500" />
|
||||||
@@ -150,14 +147,13 @@ $effect(() => {
|
|||||||
<div
|
<div
|
||||||
class={cn(
|
class={cn(
|
||||||
'flex items-center gap-1 md:gap-2 p-1.5 md:p-2',
|
'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',
|
'surface-floating bg-surface/95 dark:bg-dark-bg/95 backdrop-blur-xl',
|
||||||
'border border-subtle',
|
'shadow-popover rounded-none',
|
||||||
'shadow-[0_20px_40px_-10px_rgba(0,0,0,0.1)]',
|
'ring-1 ring-black/5 dark:ring-white/5',
|
||||||
'rounded-none ring-1 ring-black/5 dark:ring-white/5',
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<!-- Header: icon + label -->
|
<!-- Header: icon + label -->
|
||||||
<div class="px-2 md:px-3 flex items-center gap-1.5 md:gap-2 border-r border-subtle mr-1 text-swiss-black dark:text-neutral-200 shrink-0">
|
<div class="px-2 md:px-3 flex items-center gap-1.5 md:gap-2 mr-1 text-swiss-black dark:text-neutral-200 shrink-0">
|
||||||
<Settings2Icon
|
<Settings2Icon
|
||||||
size={14}
|
size={14}
|
||||||
class="text-swiss-red"
|
class="text-swiss-red"
|
||||||
@@ -171,9 +167,7 @@ $effect(() => {
|
|||||||
|
|
||||||
<!-- Controls with dividers between each -->
|
<!-- Controls with dividers between each -->
|
||||||
{#each typographySettingsStore.controls as control, i (control.id)}
|
{#each typographySettingsStore.controls as control, i (control.id)}
|
||||||
{#if i > 0}
|
<div class="w-px h-4 md:h-6 bg-subtle mx-0.5 md:mx-1 shrink-0"></div>
|
||||||
<div class="w-px h-6 md:h-8 bg-black/5 dark:bg-white/10 mx-0.5 md:mx-1 shrink-0"></div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<ComboControl
|
<ComboControl
|
||||||
control={control.instance}
|
control={control.instance}
|
||||||
@@ -30,6 +30,8 @@
|
|||||||
|
|
||||||
import { createPersistentStore } from '$shared/lib';
|
import { createPersistentStore } from '$shared/lib';
|
||||||
|
|
||||||
|
export const STORAGE_KEY = 'glyphdiff:theme';
|
||||||
|
|
||||||
type Theme = 'light' | 'dark';
|
type Theme = 'light' | 'dark';
|
||||||
type ThemeSource = 'system' | 'user';
|
type ThemeSource = 'system' | 'user';
|
||||||
|
|
||||||
@@ -56,7 +58,7 @@ class ThemeManager {
|
|||||||
/**
|
/**
|
||||||
* Persistent storage for user's theme preference
|
* Persistent storage for user's theme preference
|
||||||
*/
|
*/
|
||||||
#store = createPersistentStore<Theme | null>('glyphdiff:theme', null);
|
#store = createPersistentStore<Theme | null>(STORAGE_KEY, null);
|
||||||
/**
|
/**
|
||||||
* Bound handler for system theme change events
|
* Bound handler for system theme change events
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -40,8 +40,7 @@ import { ThemeManager } from './ThemeManager.svelte';
|
|||||||
* - MediaQueryList listener management
|
* - MediaQueryList listener management
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Storage key used by ThemeManager
|
import { STORAGE_KEY } from './ThemeManager.svelte';
|
||||||
const STORAGE_KEY = 'glyphdiff:theme';
|
|
||||||
|
|
||||||
// Helper type for MediaQueryList event handler
|
// Helper type for MediaQueryList event handler
|
||||||
type MediaQueryListCallback = (this: MediaQueryList, ev: MediaQueryListEvent) => void;
|
type MediaQueryListCallback = (this: MediaQueryList, ev: MediaQueryListEvent) => void;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
FontApplicator,
|
FontApplicator,
|
||||||
type UnifiedFont,
|
type UnifiedFont,
|
||||||
} from '$entities/Font';
|
} from '$entities/Font';
|
||||||
import { typographySettingsStore } from '$features/SetupFont/model';
|
import { typographySettingsStore } from '$features/AdjustTypography/model';
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
ContentEditable,
|
ContentEditable,
|
||||||
@@ -58,12 +58,10 @@ const stats = $derived([
|
|||||||
class="
|
class="
|
||||||
group relative
|
group relative
|
||||||
w-full h-full
|
w-full h-full
|
||||||
bg-paper dark:bg-dark-card
|
surface-card
|
||||||
border border-subtle
|
|
||||||
hover:border-brand dark:hover:border-brand
|
hover:border-brand dark:hover:border-brand
|
||||||
hover:shadow-brand/10
|
hover:shadow-stamp-card
|
||||||
hover:shadow-[5px_5px_0px_0px]
|
transition-all duration-normal
|
||||||
transition-all duration-200
|
|
||||||
overflow-hidden
|
overflow-hidden
|
||||||
flex flex-col
|
flex flex-col
|
||||||
min-h-60
|
min-h-60
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { FontsByIdsStore } from './model';
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { FontsByIdsStore } from './store/fontsByIdsStore/fontsByIdsStore.svelte';
|
||||||
+8
-9
@@ -1,14 +1,14 @@
|
|||||||
import { fontKeys } from '$shared/api/queryKeys';
|
|
||||||
import { BaseQueryStore } from '$shared/lib/helpers/BaseQueryStore.svelte';
|
|
||||||
import {
|
import {
|
||||||
fetchFontsByIds,
|
fetchFontsByIds,
|
||||||
seedFontCache,
|
seedFontCache,
|
||||||
} from '../../api/proxy/proxyFonts';
|
} from '$entities/Font/api/proxy/proxyFonts';
|
||||||
import {
|
import {
|
||||||
FontNetworkError,
|
FontNetworkError,
|
||||||
FontResponseError,
|
FontResponseError,
|
||||||
} from '../../lib/errors/errors';
|
} from '$entities/Font/lib/errors/errors';
|
||||||
import type { UnifiedFont } from '../../model/types';
|
import type { UnifiedFont } from '$entities/Font/model/types';
|
||||||
|
import { fontKeys } from '$shared/api/queryKeys';
|
||||||
|
import { BaseQueryStore } from '$shared/lib/helpers/BaseQueryStore.svelte';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Internal fetcher that seeds the cache and handles error wrapping.
|
* Internal fetcher that seeds the cache and handles error wrapping.
|
||||||
@@ -35,11 +35,10 @@ async function fetchAndSeed(ids: string[]): Promise<UnifiedFont[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reactive store for fetching and caching batches of fonts by ID.
|
* Reactive store for fetching specific fonts by ID via the proxy batch endpoint.
|
||||||
* Integrates with TanStack Query via BaseQueryStore and handles
|
* Wraps TanStack Query and seeds the detail cache for sibling consumers.
|
||||||
* normalized cache seeding.
|
|
||||||
*/
|
*/
|
||||||
export class BatchFontStore extends BaseQueryStore<UnifiedFont[]> {
|
export class FontsByIdsStore extends BaseQueryStore<UnifiedFont[]> {
|
||||||
constructor(initialIds: string[] = []) {
|
constructor(initialIds: string[] = []) {
|
||||||
super({
|
super({
|
||||||
queryKey: fontKeys.batch(initialIds),
|
queryKey: fontKeys.batch(initialIds),
|
||||||
+15
-15
@@ -1,3 +1,8 @@
|
|||||||
|
import * as api from '$entities/Font/api/proxy/proxyFonts';
|
||||||
|
import {
|
||||||
|
FontNetworkError,
|
||||||
|
FontResponseError,
|
||||||
|
} from '$entities/Font/lib/errors/errors';
|
||||||
import { queryClient } from '$shared/api/queryClient';
|
import { queryClient } from '$shared/api/queryClient';
|
||||||
import { fontKeys } from '$shared/api/queryKeys';
|
import { fontKeys } from '$shared/api/queryKeys';
|
||||||
import {
|
import {
|
||||||
@@ -7,14 +12,9 @@ import {
|
|||||||
it,
|
it,
|
||||||
vi,
|
vi,
|
||||||
} from 'vitest';
|
} from 'vitest';
|
||||||
import * as api from '../../api/proxy/proxyFonts';
|
import { FontsByIdsStore } from './fontsByIdsStore.svelte';
|
||||||
import {
|
|
||||||
FontNetworkError,
|
|
||||||
FontResponseError,
|
|
||||||
} from '../../lib/errors/errors';
|
|
||||||
import { BatchFontStore } from './batchFontStore.svelte';
|
|
||||||
|
|
||||||
describe('BatchFontStore', () => {
|
describe('FontsByIdsStore', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
queryClient.clear();
|
queryClient.clear();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -23,7 +23,7 @@ describe('BatchFontStore', () => {
|
|||||||
describe('Fetch Behavior', () => {
|
describe('Fetch Behavior', () => {
|
||||||
it('should skip fetch when initialized with empty IDs', async () => {
|
it('should skip fetch when initialized with empty IDs', async () => {
|
||||||
const spy = vi.spyOn(api, 'fetchFontsByIds');
|
const spy = vi.spyOn(api, 'fetchFontsByIds');
|
||||||
const store = new BatchFontStore([]);
|
const store = new FontsByIdsStore([]);
|
||||||
expect(spy).not.toHaveBeenCalled();
|
expect(spy).not.toHaveBeenCalled();
|
||||||
expect(store.fonts).toEqual([]);
|
expect(store.fonts).toEqual([]);
|
||||||
});
|
});
|
||||||
@@ -31,7 +31,7 @@ describe('BatchFontStore', () => {
|
|||||||
it('should fetch and seed cache for valid IDs', async () => {
|
it('should fetch and seed cache for valid IDs', async () => {
|
||||||
const fonts = [{ id: 'a', name: 'A' }] as any[];
|
const fonts = [{ id: 'a', name: 'A' }] as any[];
|
||||||
vi.spyOn(api, 'fetchFontsByIds').mockResolvedValue(fonts);
|
vi.spyOn(api, 'fetchFontsByIds').mockResolvedValue(fonts);
|
||||||
const store = new BatchFontStore(['a']);
|
const store = new FontsByIdsStore(['a']);
|
||||||
await vi.waitFor(() => expect(store.fonts).toEqual(fonts), { timeout: 1000 });
|
await vi.waitFor(() => expect(store.fonts).toEqual(fonts), { timeout: 1000 });
|
||||||
expect(queryClient.getQueryData(fontKeys.detail('a'))).toEqual(fonts[0]);
|
expect(queryClient.getQueryData(fontKeys.detail('a'))).toEqual(fonts[0]);
|
||||||
});
|
});
|
||||||
@@ -42,7 +42,7 @@ describe('BatchFontStore', () => {
|
|||||||
vi.spyOn(api, 'fetchFontsByIds').mockImplementation(() =>
|
vi.spyOn(api, 'fetchFontsByIds').mockImplementation(() =>
|
||||||
new Promise(r => setTimeout(() => r([{ id: 'a' }] as any), 50))
|
new Promise(r => setTimeout(() => r([{ id: 'a' }] as any), 50))
|
||||||
);
|
);
|
||||||
const store = new BatchFontStore(['a']);
|
const store = new FontsByIdsStore(['a']);
|
||||||
expect(store.isLoading).toBe(true);
|
expect(store.isLoading).toBe(true);
|
||||||
await vi.waitFor(() => expect(store.isLoading).toBe(false), { timeout: 1000 });
|
await vi.waitFor(() => expect(store.isLoading).toBe(false), { timeout: 1000 });
|
||||||
});
|
});
|
||||||
@@ -51,7 +51,7 @@ describe('BatchFontStore', () => {
|
|||||||
describe('Error Handling', () => {
|
describe('Error Handling', () => {
|
||||||
it('should wrap network failures in FontNetworkError', async () => {
|
it('should wrap network failures in FontNetworkError', async () => {
|
||||||
vi.spyOn(api, 'fetchFontsByIds').mockRejectedValue(new Error('Network fail'));
|
vi.spyOn(api, 'fetchFontsByIds').mockRejectedValue(new Error('Network fail'));
|
||||||
const store = new BatchFontStore(['a']);
|
const store = new FontsByIdsStore(['a']);
|
||||||
await vi.waitFor(() => expect(store.isError).toBe(true), { timeout: 1000 });
|
await vi.waitFor(() => expect(store.isError).toBe(true), { timeout: 1000 });
|
||||||
expect(store.error).toBeInstanceOf(FontNetworkError);
|
expect(store.error).toBeInstanceOf(FontNetworkError);
|
||||||
});
|
});
|
||||||
@@ -59,7 +59,7 @@ describe('BatchFontStore', () => {
|
|||||||
it('should handle malformed API responses with FontResponseError', async () => {
|
it('should handle malformed API responses with FontResponseError', async () => {
|
||||||
// Mocking a malformed response that the store should validate
|
// Mocking a malformed response that the store should validate
|
||||||
vi.spyOn(api, 'fetchFontsByIds').mockResolvedValue(null as any);
|
vi.spyOn(api, 'fetchFontsByIds').mockResolvedValue(null as any);
|
||||||
const store = new BatchFontStore(['a']);
|
const store = new FontsByIdsStore(['a']);
|
||||||
await vi.waitFor(() => expect(store.isError).toBe(true), { timeout: 1000 });
|
await vi.waitFor(() => expect(store.isError).toBe(true), { timeout: 1000 });
|
||||||
expect(store.error).toBeInstanceOf(FontResponseError);
|
expect(store.error).toBeInstanceOf(FontResponseError);
|
||||||
});
|
});
|
||||||
@@ -67,7 +67,7 @@ describe('BatchFontStore', () => {
|
|||||||
it('should have null error in success state', async () => {
|
it('should have null error in success state', async () => {
|
||||||
const fonts = [{ id: 'a' }] as any[];
|
const fonts = [{ id: 'a' }] as any[];
|
||||||
vi.spyOn(api, 'fetchFontsByIds').mockResolvedValue(fonts);
|
vi.spyOn(api, 'fetchFontsByIds').mockResolvedValue(fonts);
|
||||||
const store = new BatchFontStore(['a']);
|
const store = new FontsByIdsStore(['a']);
|
||||||
await vi.waitFor(() => expect(store.fonts).toEqual(fonts), { timeout: 1000 });
|
await vi.waitFor(() => expect(store.fonts).toEqual(fonts), { timeout: 1000 });
|
||||||
expect(store.error).toBeNull();
|
expect(store.error).toBeNull();
|
||||||
});
|
});
|
||||||
@@ -78,7 +78,7 @@ describe('BatchFontStore', () => {
|
|||||||
const fonts1 = [{ id: 'a' }] as any[];
|
const fonts1 = [{ id: 'a' }] as any[];
|
||||||
const spy = vi.spyOn(api, 'fetchFontsByIds').mockResolvedValueOnce(fonts1);
|
const spy = vi.spyOn(api, 'fetchFontsByIds').mockResolvedValueOnce(fonts1);
|
||||||
|
|
||||||
const store = new BatchFontStore(['a']);
|
const store = new FontsByIdsStore(['a']);
|
||||||
await vi.waitFor(() => expect(store.fonts).toEqual(fonts1), { timeout: 1000 });
|
await vi.waitFor(() => expect(store.fonts).toEqual(fonts1), { timeout: 1000 });
|
||||||
|
|
||||||
spy.mockClear();
|
spy.mockClear();
|
||||||
@@ -97,7 +97,7 @@ describe('BatchFontStore', () => {
|
|||||||
.mockResolvedValueOnce(fonts1)
|
.mockResolvedValueOnce(fonts1)
|
||||||
.mockResolvedValueOnce(fonts2);
|
.mockResolvedValueOnce(fonts2);
|
||||||
|
|
||||||
const store = new BatchFontStore(['a']);
|
const store = new FontsByIdsStore(['a']);
|
||||||
await vi.waitFor(() => expect(store.fonts).toEqual(fonts1), { timeout: 1000 });
|
await vi.waitFor(() => expect(store.fonts).toEqual(fonts1), { timeout: 1000 });
|
||||||
|
|
||||||
store.setIds(['b']);
|
store.setIds(['b']);
|
||||||
+2
-1
@@ -8,8 +8,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { api } from '$shared/api/api';
|
import { api } from '$shared/api/api';
|
||||||
|
import { API_ENDPOINTS } from '$shared/api/endpoints';
|
||||||
|
|
||||||
const PROXY_API_URL = 'https://api.glyphdiff.com/api/v1/filters' as const;
|
const PROXY_API_URL = API_ENDPOINTS.filters;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filter metadata type from backend
|
* Filter metadata type from backend
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
export { mapAppliedFiltersToParams } from './lib';
|
||||||
|
|
||||||
|
export {
|
||||||
|
type AppliedFilterStore,
|
||||||
|
appliedFilterStore,
|
||||||
|
/**
|
||||||
|
* Filter Store
|
||||||
|
*/
|
||||||
|
availableFilterStore,
|
||||||
|
/**
|
||||||
|
* Filter Manager
|
||||||
|
*/
|
||||||
|
createAppliedFilterStore,
|
||||||
|
/**
|
||||||
|
* Sort Store
|
||||||
|
*/
|
||||||
|
SORT_MAP,
|
||||||
|
SORT_OPTIONS,
|
||||||
|
type SortApiValue,
|
||||||
|
type SortOption,
|
||||||
|
sortStore,
|
||||||
|
} from './model';
|
||||||
|
|
||||||
|
export {
|
||||||
|
FilterControls,
|
||||||
|
Filters,
|
||||||
|
} from './ui';
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { mapAppliedFiltersToParams } from './mapper/mapAppliedFiltersToParams';
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import type { Property } from '$shared/lib';
|
||||||
|
import {
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
} from 'vitest';
|
||||||
|
import { createAppliedFilterStore } from '../../model/store/appliedFilterStore/appliedFilterStore.svelte';
|
||||||
|
import { mapAppliedFiltersToParams } from './mapAppliedFiltersToParams';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a Property with explicit selection state.
|
||||||
|
*/
|
||||||
|
function prop(value: string, selected = false): Property<string> {
|
||||||
|
return { id: value, name: value, value, selected };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a filter group with a known id and a list of (value, selected) entries.
|
||||||
|
*/
|
||||||
|
function group(id: string, props: Array<[string, boolean]>) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
label: id,
|
||||||
|
properties: props.map(([value, selected]) => prop(value, selected)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('mapAppliedFiltersToParams', () => {
|
||||||
|
describe('search query', () => {
|
||||||
|
it('omits q when query is empty', () => {
|
||||||
|
const manager = createAppliedFilterStore({ queryValue: '', groups: [] });
|
||||||
|
expect(mapAppliedFiltersToParams(manager).q).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes the debounced query through as q', () => {
|
||||||
|
// Constructor seeds both immediate and debounced synchronously.
|
||||||
|
const manager = createAppliedFilterStore({ queryValue: 'roboto', groups: [] });
|
||||||
|
expect(mapAppliedFiltersToParams(manager).q).toBe('roboto');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('group selections', () => {
|
||||||
|
it('omits a group entirely when no group with that id exists', () => {
|
||||||
|
const manager = createAppliedFilterStore({ queryValue: '', groups: [] });
|
||||||
|
const params = mapAppliedFiltersToParams(manager);
|
||||||
|
expect(params.providers).toBeUndefined();
|
||||||
|
expect(params.categories).toBeUndefined();
|
||||||
|
expect(params.subsets).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits a group when it exists but has no selections', () => {
|
||||||
|
const manager = createAppliedFilterStore({
|
||||||
|
queryValue: '',
|
||||||
|
groups: [group('providers', [['google', false], ['fontshare', false]])],
|
||||||
|
});
|
||||||
|
expect(mapAppliedFiltersToParams(manager).providers).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the selected values for a single group', () => {
|
||||||
|
const manager = createAppliedFilterStore({
|
||||||
|
queryValue: '',
|
||||||
|
groups: [group('providers', [['google', true], ['fontshare', false]])],
|
||||||
|
});
|
||||||
|
expect(mapAppliedFiltersToParams(manager).providers).toEqual(['google']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns multiple selected values in selection order', () => {
|
||||||
|
const manager = createAppliedFilterStore({
|
||||||
|
queryValue: '',
|
||||||
|
groups: [
|
||||||
|
group('categories', [
|
||||||
|
['serif', true],
|
||||||
|
['sans-serif', false],
|
||||||
|
['display', true],
|
||||||
|
['monospace', true],
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(mapAppliedFiltersToParams(manager).categories).toEqual(['serif', 'display', 'monospace']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps each of the three recognized group ids independently', () => {
|
||||||
|
const manager = createAppliedFilterStore({
|
||||||
|
queryValue: '',
|
||||||
|
groups: [
|
||||||
|
group('providers', [['google', true]]),
|
||||||
|
group('categories', [['serif', true], ['sans-serif', true]]),
|
||||||
|
group('subsets', [['latin', true]]),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const params = mapAppliedFiltersToParams(manager);
|
||||||
|
expect(params.providers).toEqual(['google']);
|
||||||
|
expect(params.categories).toEqual(['serif', 'sans-serif']);
|
||||||
|
expect(params.subsets).toEqual(['latin']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores groups whose id does not match providers/categories/subsets', () => {
|
||||||
|
const manager = createAppliedFilterStore({
|
||||||
|
queryValue: '',
|
||||||
|
groups: [group('weights', [['400', true], ['700', true]])],
|
||||||
|
});
|
||||||
|
const params = mapAppliedFiltersToParams(manager);
|
||||||
|
expect(params.providers).toBeUndefined();
|
||||||
|
expect(params.categories).toBeUndefined();
|
||||||
|
expect(params.subsets).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('combined output', () => {
|
||||||
|
it('produces a complete param object when query and selections coexist', () => {
|
||||||
|
const manager = createAppliedFilterStore({
|
||||||
|
queryValue: 'inter',
|
||||||
|
groups: [
|
||||||
|
group('providers', [['google', true]]),
|
||||||
|
group('categories', [['sans-serif', true]]),
|
||||||
|
group('subsets', [['latin', false]]),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(mapAppliedFiltersToParams(manager)).toEqual({
|
||||||
|
q: 'inter',
|
||||||
|
providers: ['google'],
|
||||||
|
categories: ['sans-serif'],
|
||||||
|
subsets: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import type { ProxyFontsParams } from '$entities/Font/api';
|
||||||
|
import type { AppliedFilterStore } from '../../model';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps filter manager to proxy API parameters.
|
||||||
|
*
|
||||||
|
* Updated to support multiple filter values (arrays)
|
||||||
|
*
|
||||||
|
* @param manager - Filter manager instance with reactive state
|
||||||
|
* @returns - Partial proxy API parameters ready for API call
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* // Example filter manager state:
|
||||||
|
* // {
|
||||||
|
* // queryValue: 'roboto',
|
||||||
|
* // providers: ['google', 'fontshare'],
|
||||||
|
* // categories: ['sans-serif', 'serif'],
|
||||||
|
* // subsets: ['latin']
|
||||||
|
* // }
|
||||||
|
*
|
||||||
|
* const params = mapAppliedFiltersToParams(manager);
|
||||||
|
* // Returns: {
|
||||||
|
* // providers: ['google', 'fontshare'],
|
||||||
|
* // categories: ['sans-serif', 'serif'],
|
||||||
|
* // subsets: ['latin'],
|
||||||
|
* // q: 'roboto'
|
||||||
|
* // }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function mapAppliedFiltersToParams(manager: AppliedFilterStore): Partial<ProxyFontsParams> {
|
||||||
|
/**
|
||||||
|
* Return the list of selected values for a group, or undefined when
|
||||||
|
* the group is missing or has no selection — matches the API's
|
||||||
|
* "omit empty filters" contract.
|
||||||
|
*/
|
||||||
|
const selectedIn = (id: string): string[] | undefined => {
|
||||||
|
const values = manager.getGroup(id)?.instance.selectedProperties.map(p => p.value);
|
||||||
|
return values && values.length > 0 ? values : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
q: manager.debouncedQueryValue || undefined,
|
||||||
|
providers: selectedIn('providers'),
|
||||||
|
categories: selectedIn('categories'),
|
||||||
|
subsets: selectedIn('subsets'),
|
||||||
|
};
|
||||||
|
}
|
||||||
+19
-5
@@ -16,18 +16,32 @@ export {
|
|||||||
/**
|
/**
|
||||||
* Low-level property selection store
|
* Low-level property selection store
|
||||||
*/
|
*/
|
||||||
filtersStore,
|
availableFilterStore,
|
||||||
} from './state/filters.svelte';
|
} from './store/availableFilterStore/availableFilterStore.svelte';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main filter controller
|
* Main filter controller
|
||||||
*/
|
*/
|
||||||
export {
|
export {
|
||||||
|
/**
|
||||||
|
* Reactive interface returned by `createAppliedFilterStore`
|
||||||
|
*/
|
||||||
|
type AppliedFilterStore,
|
||||||
/**
|
/**
|
||||||
* High-level manager for syncing search and filters
|
* High-level manager for syncing search and filters
|
||||||
*/
|
*/
|
||||||
filterManager,
|
appliedFilterStore,
|
||||||
} from './state/manager.svelte';
|
/**
|
||||||
|
* Factory for constructing a filter manager instance
|
||||||
|
*/
|
||||||
|
createAppliedFilterStore,
|
||||||
|
} 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';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sorting logic
|
* Sorting logic
|
||||||
@@ -53,4 +67,4 @@ export {
|
|||||||
* Reactive store for the current sort selection
|
* Reactive store for the current sort selection
|
||||||
*/
|
*/
|
||||||
sortStore,
|
sortStore,
|
||||||
} from './store/sortStore.svelte';
|
} from './store/sortStore/sortStore.svelte';
|
||||||
+23
-8
@@ -1,13 +1,16 @@
|
|||||||
/**
|
/**
|
||||||
* Filter manager for font filtering
|
* Filter manager factory and singleton.
|
||||||
*
|
*
|
||||||
* Manages multiple filter groups (providers, categories, subsets)
|
* Owns multiple filter groups (providers, categories, subsets) plus a
|
||||||
* with debounced search input. Provides reactive state for filter
|
* debounced search input. Provides reactive state for filter selections
|
||||||
* selections and convenience methods for bulk operations.
|
* and convenience methods for bulk operations.
|
||||||
|
*
|
||||||
|
* The factory (`createAppliedFilterStore`) is exported for tests; the app
|
||||||
|
* consumes the `appliedFilterStore` singleton at the bottom of this file.
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```ts
|
* ```ts
|
||||||
* const manager = createFilterManager({
|
* const manager = createAppliedFilterStore({
|
||||||
* queryValue: '',
|
* queryValue: '',
|
||||||
* groups: [
|
* groups: [
|
||||||
* { id: 'providers', label: 'Provider', properties: [...] },
|
* { id: 'providers', label: 'Provider', properties: [...] },
|
||||||
@@ -25,7 +28,7 @@ import { createDebouncedState } from '$shared/lib/helpers';
|
|||||||
import type {
|
import type {
|
||||||
FilterConfig,
|
FilterConfig,
|
||||||
FilterGroupConfig,
|
FilterGroupConfig,
|
||||||
} from '../../model';
|
} from '../../types/filter';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a filter manager instance
|
* Creates a filter manager instance
|
||||||
@@ -36,7 +39,7 @@ import type {
|
|||||||
* @param config - Configuration with query value and filter groups
|
* @param config - Configuration with query value and filter groups
|
||||||
* @returns Filter manager instance with reactive state and methods
|
* @returns Filter manager instance with reactive state and methods
|
||||||
*/
|
*/
|
||||||
export function createFilterManager<TValue extends string>(config: FilterConfig<TValue>) {
|
export function createAppliedFilterStore<TValue extends string>(config: FilterConfig<TValue>) {
|
||||||
const search = createDebouncedState(config.queryValue ?? '');
|
const search = createDebouncedState(config.queryValue ?? '');
|
||||||
|
|
||||||
// Create filter instances upfront
|
// Create filter instances upfront
|
||||||
@@ -122,4 +125,16 @@ export function createFilterManager<TValue extends string>(config: FilterConfig<
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FilterManager = ReturnType<typeof createFilterManager>;
|
export type AppliedFilterStore = ReturnType<typeof createAppliedFilterStore>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App-wide filter manager singleton.
|
||||||
|
*
|
||||||
|
* 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: [],
|
||||||
|
});
|
||||||
+54
-54
@@ -7,10 +7,10 @@ import {
|
|||||||
it,
|
it,
|
||||||
vi,
|
vi,
|
||||||
} from 'vitest';
|
} from 'vitest';
|
||||||
import { createFilterManager } from './filterManager.svelte';
|
import { createAppliedFilterStore } from './appliedFilterStore.svelte';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Test Suite for createFilterManager Helper Function
|
* Test Suite for createAppliedFilterStore Helper Function
|
||||||
*
|
*
|
||||||
* This suite tests the filter manager logic including:
|
* This suite tests the filter manager logic including:
|
||||||
* - Debounced query state (immediate vs delayed)
|
* - Debounced query state (immediate vs delayed)
|
||||||
@@ -54,9 +54,9 @@ function createTestGroups(count: number, propertiesPerGroup = 3) {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('createFilterManager - Initialization', () => {
|
describe('createAppliedFilterStore - Initialization', () => {
|
||||||
it('creates manager with empty query value', () => {
|
it('creates manager with empty query value', () => {
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: '',
|
queryValue: '',
|
||||||
groups: createTestGroups(2),
|
groups: createTestGroups(2),
|
||||||
});
|
});
|
||||||
@@ -66,7 +66,7 @@ describe('createFilterManager - Initialization', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('creates manager with initial query value', () => {
|
it('creates manager with initial query value', () => {
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: 'search term',
|
queryValue: 'search term',
|
||||||
groups: createTestGroups(1),
|
groups: createTestGroups(1),
|
||||||
});
|
});
|
||||||
@@ -76,7 +76,7 @@ describe('createFilterManager - Initialization', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('creates manager with undefined query value (defaults to empty string)', () => {
|
it('creates manager with undefined query value (defaults to empty string)', () => {
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
groups: createTestGroups(1),
|
groups: createTestGroups(1),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -86,7 +86,7 @@ describe('createFilterManager - Initialization', () => {
|
|||||||
|
|
||||||
it('creates filter groups for each config group', () => {
|
it('creates filter groups for each config group', () => {
|
||||||
const groups = createTestGroups(3);
|
const groups = createTestGroups(3);
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: '',
|
queryValue: '',
|
||||||
groups,
|
groups,
|
||||||
});
|
});
|
||||||
@@ -99,7 +99,7 @@ describe('createFilterManager - Initialization', () => {
|
|||||||
|
|
||||||
it('creates filter instances for each group', () => {
|
it('creates filter instances for each group', () => {
|
||||||
const groups = createTestGroups(2, 5);
|
const groups = createTestGroups(2, 5);
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: '',
|
queryValue: '',
|
||||||
groups,
|
groups,
|
||||||
});
|
});
|
||||||
@@ -118,7 +118,7 @@ describe('createFilterManager - Initialization', () => {
|
|||||||
{ id: 'providers', label: 'Providers', properties: createTestProperties(2) },
|
{ id: 'providers', label: 'Providers', properties: createTestProperties(2) },
|
||||||
{ id: 'categories', label: 'Categories', properties: createTestProperties(3) },
|
{ id: 'categories', label: 'Categories', properties: createTestProperties(3) },
|
||||||
];
|
];
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: '',
|
queryValue: '',
|
||||||
groups,
|
groups,
|
||||||
});
|
});
|
||||||
@@ -129,7 +129,7 @@ describe('createFilterManager - Initialization', () => {
|
|||||||
|
|
||||||
it('handles single group', () => {
|
it('handles single group', () => {
|
||||||
const groups = createTestGroups(1);
|
const groups = createTestGroups(1);
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: '',
|
queryValue: '',
|
||||||
groups,
|
groups,
|
||||||
});
|
});
|
||||||
@@ -139,7 +139,7 @@ describe('createFilterManager - Initialization', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('createFilterManager - Debounced Query', () => {
|
describe('createAppliedFilterStore - Debounced Query', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
});
|
});
|
||||||
@@ -149,7 +149,7 @@ describe('createFilterManager - Debounced Query', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('immediate query value updates instantly', () => {
|
it('immediate query value updates instantly', () => {
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: '',
|
queryValue: '',
|
||||||
groups: createTestGroups(1),
|
groups: createTestGroups(1),
|
||||||
});
|
});
|
||||||
@@ -161,7 +161,7 @@ describe('createFilterManager - Debounced Query', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('debounced query value updates after default delay (300ms)', () => {
|
it('debounced query value updates after default delay (300ms)', () => {
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: '',
|
queryValue: '',
|
||||||
groups: createTestGroups(1),
|
groups: createTestGroups(1),
|
||||||
});
|
});
|
||||||
@@ -178,7 +178,7 @@ describe('createFilterManager - Debounced Query', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('rapid query changes reset the debounce timer', () => {
|
it('rapid query changes reset the debounce timer', () => {
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: '',
|
queryValue: '',
|
||||||
groups: createTestGroups(1),
|
groups: createTestGroups(1),
|
||||||
});
|
});
|
||||||
@@ -200,7 +200,7 @@ describe('createFilterManager - Debounced Query', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('handles empty string in query', () => {
|
it('handles empty string in query', () => {
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: 'initial',
|
queryValue: 'initial',
|
||||||
groups: createTestGroups(1),
|
groups: createTestGroups(1),
|
||||||
});
|
});
|
||||||
@@ -213,7 +213,7 @@ describe('createFilterManager - Debounced Query', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('preserves initial query value until changed', () => {
|
it('preserves initial query value until changed', () => {
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: 'initial search',
|
queryValue: 'initial search',
|
||||||
groups: createTestGroups(1),
|
groups: createTestGroups(1),
|
||||||
});
|
});
|
||||||
@@ -228,9 +228,9 @@ describe('createFilterManager - Debounced Query', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('createFilterManager - hasAnySelection Derived State', () => {
|
describe('createAppliedFilterStore - hasAnySelection Derived State', () => {
|
||||||
it('returns false when no filters are selected', () => {
|
it('returns false when no filters are selected', () => {
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: '',
|
queryValue: '',
|
||||||
groups: createTestGroups(3, 3),
|
groups: createTestGroups(3, 3),
|
||||||
});
|
});
|
||||||
@@ -240,7 +240,7 @@ describe('createFilterManager - hasAnySelection Derived State', () => {
|
|||||||
|
|
||||||
it('returns true when one filter in one group is selected', () => {
|
it('returns true when one filter in one group is selected', () => {
|
||||||
const groups = createTestGroups(2, 3);
|
const groups = createTestGroups(2, 3);
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: '',
|
queryValue: '',
|
||||||
groups,
|
groups,
|
||||||
});
|
});
|
||||||
@@ -255,7 +255,7 @@ describe('createFilterManager - hasAnySelection Derived State', () => {
|
|||||||
|
|
||||||
it('returns true when multiple filters across groups are selected', () => {
|
it('returns true when multiple filters across groups are selected', () => {
|
||||||
const groups = createTestGroups(3, 3);
|
const groups = createTestGroups(3, 3);
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: '',
|
queryValue: '',
|
||||||
groups,
|
groups,
|
||||||
});
|
});
|
||||||
@@ -272,7 +272,7 @@ describe('createFilterManager - hasAnySelection Derived State', () => {
|
|||||||
|
|
||||||
it('returns false after deselecting all filters', () => {
|
it('returns false after deselecting all filters', () => {
|
||||||
const groups = createTestGroups(2, 3);
|
const groups = createTestGroups(2, 3);
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: '',
|
queryValue: '',
|
||||||
groups,
|
groups,
|
||||||
});
|
});
|
||||||
@@ -286,7 +286,7 @@ describe('createFilterManager - hasAnySelection Derived State', () => {
|
|||||||
|
|
||||||
it('reacts to selection changes in individual groups', () => {
|
it('reacts to selection changes in individual groups', () => {
|
||||||
const groups = createTestGroups(2, 3);
|
const groups = createTestGroups(2, 3);
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: '',
|
queryValue: '',
|
||||||
groups,
|
groups,
|
||||||
});
|
});
|
||||||
@@ -318,7 +318,7 @@ describe('createFilterManager - hasAnySelection Derived State', () => {
|
|||||||
properties: createTestProperties(3, []),
|
properties: createTestProperties(3, []),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: '',
|
queryValue: '',
|
||||||
groups,
|
groups,
|
||||||
});
|
});
|
||||||
@@ -331,7 +331,7 @@ describe('createFilterManager - hasAnySelection Derived State', () => {
|
|||||||
{ id: 'group-0', label: 'Group 0', properties: [] },
|
{ id: 'group-0', label: 'Group 0', properties: [] },
|
||||||
{ id: 'group-1', label: 'Group 1', properties: [] },
|
{ id: 'group-1', label: 'Group 1', properties: [] },
|
||||||
];
|
];
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: '',
|
queryValue: '',
|
||||||
groups,
|
groups,
|
||||||
});
|
});
|
||||||
@@ -340,10 +340,10 @@ describe('createFilterManager - hasAnySelection Derived State', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('createFilterManager - getGroup() Method', () => {
|
describe('createAppliedFilterStore - getGroup() Method', () => {
|
||||||
it('returns the correct group by ID', () => {
|
it('returns the correct group by ID', () => {
|
||||||
const groups = createTestGroups(3);
|
const groups = createTestGroups(3);
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: '',
|
queryValue: '',
|
||||||
groups,
|
groups,
|
||||||
});
|
});
|
||||||
@@ -357,7 +357,7 @@ describe('createFilterManager - getGroup() Method', () => {
|
|||||||
|
|
||||||
it('returns undefined for non-existent group ID', () => {
|
it('returns undefined for non-existent group ID', () => {
|
||||||
const groups = createTestGroups(2);
|
const groups = createTestGroups(2);
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: '',
|
queryValue: '',
|
||||||
groups,
|
groups,
|
||||||
});
|
});
|
||||||
@@ -369,7 +369,7 @@ describe('createFilterManager - getGroup() Method', () => {
|
|||||||
|
|
||||||
it('returns group with accessible filter instance', () => {
|
it('returns group with accessible filter instance', () => {
|
||||||
const groups = createTestGroups(2, 3);
|
const groups = createTestGroups(2, 3);
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: '',
|
queryValue: '',
|
||||||
groups,
|
groups,
|
||||||
});
|
});
|
||||||
@@ -385,7 +385,7 @@ describe('createFilterManager - getGroup() Method', () => {
|
|||||||
|
|
||||||
it('returns first group when requested', () => {
|
it('returns first group when requested', () => {
|
||||||
const groups = createTestGroups(3);
|
const groups = createTestGroups(3);
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: '',
|
queryValue: '',
|
||||||
groups,
|
groups,
|
||||||
});
|
});
|
||||||
@@ -398,7 +398,7 @@ describe('createFilterManager - getGroup() Method', () => {
|
|||||||
|
|
||||||
it('returns last group when requested', () => {
|
it('returns last group when requested', () => {
|
||||||
const groups = createTestGroups(5);
|
const groups = createTestGroups(5);
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: '',
|
queryValue: '',
|
||||||
groups,
|
groups,
|
||||||
});
|
});
|
||||||
@@ -410,10 +410,10 @@ describe('createFilterManager - getGroup() Method', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('createFilterManager - deselectAllGlobal() Method', () => {
|
describe('createAppliedFilterStore - deselectAllGlobal() Method', () => {
|
||||||
it('deselects all filters across all groups', () => {
|
it('deselects all filters across all groups', () => {
|
||||||
const groups = createTestGroups(3, 3);
|
const groups = createTestGroups(3, 3);
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: '',
|
queryValue: '',
|
||||||
groups,
|
groups,
|
||||||
});
|
});
|
||||||
@@ -436,7 +436,7 @@ describe('createFilterManager - deselectAllGlobal() Method', () => {
|
|||||||
|
|
||||||
it('handles deselecting when nothing is selected', () => {
|
it('handles deselecting when nothing is selected', () => {
|
||||||
const groups = createTestGroups(2, 3);
|
const groups = createTestGroups(2, 3);
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: '',
|
queryValue: '',
|
||||||
groups,
|
groups,
|
||||||
});
|
});
|
||||||
@@ -453,7 +453,7 @@ describe('createFilterManager - deselectAllGlobal() Method', () => {
|
|||||||
{ id: 'group-0', label: 'Group 0', properties: [] },
|
{ id: 'group-0', label: 'Group 0', properties: [] },
|
||||||
{ id: 'group-1', label: 'Group 1', properties: [] },
|
{ id: 'group-1', label: 'Group 1', properties: [] },
|
||||||
];
|
];
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: '',
|
queryValue: '',
|
||||||
groups,
|
groups,
|
||||||
});
|
});
|
||||||
@@ -464,7 +464,7 @@ describe('createFilterManager - deselectAllGlobal() Method', () => {
|
|||||||
|
|
||||||
it('can select filters after global deselect', () => {
|
it('can select filters after global deselect', () => {
|
||||||
const groups = createTestGroups(2, 3);
|
const groups = createTestGroups(2, 3);
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: '',
|
queryValue: '',
|
||||||
groups,
|
groups,
|
||||||
});
|
});
|
||||||
@@ -482,7 +482,7 @@ describe('createFilterManager - deselectAllGlobal() Method', () => {
|
|||||||
|
|
||||||
it('handles partially selected groups', () => {
|
it('handles partially selected groups', () => {
|
||||||
const groups = createTestGroups(3, 5);
|
const groups = createTestGroups(3, 5);
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: '',
|
queryValue: '',
|
||||||
groups,
|
groups,
|
||||||
});
|
});
|
||||||
@@ -505,7 +505,7 @@ describe('createFilterManager - deselectAllGlobal() Method', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('createFilterManager - Complex Scenarios', () => {
|
describe('createAppliedFilterStore - Complex Scenarios', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
});
|
});
|
||||||
@@ -516,7 +516,7 @@ describe('createFilterManager - Complex Scenarios', () => {
|
|||||||
|
|
||||||
it('handles query changes and filter selections together', () => {
|
it('handles query changes and filter selections together', () => {
|
||||||
const groups = createTestGroups(2, 3);
|
const groups = createTestGroups(2, 3);
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: '',
|
queryValue: '',
|
||||||
groups,
|
groups,
|
||||||
});
|
});
|
||||||
@@ -553,7 +553,7 @@ describe('createFilterManager - Complex Scenarios', () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: '',
|
queryValue: '',
|
||||||
groups,
|
groups,
|
||||||
});
|
});
|
||||||
@@ -582,7 +582,7 @@ describe('createFilterManager - Complex Scenarios', () => {
|
|||||||
|
|
||||||
it('manages multiple independent filter groups correctly', () => {
|
it('manages multiple independent filter groups correctly', () => {
|
||||||
const groups = createTestGroups(4, 5);
|
const groups = createTestGroups(4, 5);
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: '',
|
queryValue: '',
|
||||||
groups,
|
groups,
|
||||||
});
|
});
|
||||||
@@ -607,7 +607,7 @@ describe('createFilterManager - Complex Scenarios', () => {
|
|||||||
|
|
||||||
it('handles toggle operations via getGroup', () => {
|
it('handles toggle operations via getGroup', () => {
|
||||||
const groups = createTestGroups(2, 3);
|
const groups = createTestGroups(2, 3);
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: '',
|
queryValue: '',
|
||||||
groups,
|
groups,
|
||||||
});
|
});
|
||||||
@@ -623,9 +623,9 @@ describe('createFilterManager - Complex Scenarios', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('createFilterManager - Interface Compliance', () => {
|
describe('createAppliedFilterStore - Interface Compliance', () => {
|
||||||
it('exposes queryValue getter', () => {
|
it('exposes queryValue getter', () => {
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: 'test',
|
queryValue: 'test',
|
||||||
groups: createTestGroups(1),
|
groups: createTestGroups(1),
|
||||||
});
|
});
|
||||||
@@ -636,7 +636,7 @@ describe('createFilterManager - Interface Compliance', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('exposes queryValue setter', () => {
|
it('exposes queryValue setter', () => {
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: 'test',
|
queryValue: 'test',
|
||||||
groups: createTestGroups(1),
|
groups: createTestGroups(1),
|
||||||
});
|
});
|
||||||
@@ -647,7 +647,7 @@ describe('createFilterManager - Interface Compliance', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('exposes debouncedQueryValue getter', () => {
|
it('exposes debouncedQueryValue getter', () => {
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: 'test',
|
queryValue: 'test',
|
||||||
groups: createTestGroups(1),
|
groups: createTestGroups(1),
|
||||||
});
|
});
|
||||||
@@ -658,7 +658,7 @@ describe('createFilterManager - Interface Compliance', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('exposes groups getter', () => {
|
it('exposes groups getter', () => {
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: '',
|
queryValue: '',
|
||||||
groups: createTestGroups(1),
|
groups: createTestGroups(1),
|
||||||
});
|
});
|
||||||
@@ -669,7 +669,7 @@ describe('createFilterManager - Interface Compliance', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('exposes hasAnySelection getter', () => {
|
it('exposes hasAnySelection getter', () => {
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: '',
|
queryValue: '',
|
||||||
groups: createTestGroups(1),
|
groups: createTestGroups(1),
|
||||||
});
|
});
|
||||||
@@ -680,7 +680,7 @@ describe('createFilterManager - Interface Compliance', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('exposes getGroup method', () => {
|
it('exposes getGroup method', () => {
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: '',
|
queryValue: '',
|
||||||
groups: createTestGroups(1),
|
groups: createTestGroups(1),
|
||||||
});
|
});
|
||||||
@@ -689,7 +689,7 @@ describe('createFilterManager - Interface Compliance', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('exposes deselectAllGlobal method', () => {
|
it('exposes deselectAllGlobal method', () => {
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: '',
|
queryValue: '',
|
||||||
groups: createTestGroups(1),
|
groups: createTestGroups(1),
|
||||||
});
|
});
|
||||||
@@ -698,7 +698,7 @@ describe('createFilterManager - Interface Compliance', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('does not expose debouncedQueryValue setter', () => {
|
it('does not expose debouncedQueryValue setter', () => {
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: '',
|
queryValue: '',
|
||||||
groups: createTestGroups(1),
|
groups: createTestGroups(1),
|
||||||
});
|
});
|
||||||
@@ -708,7 +708,7 @@ describe('createFilterManager - Interface Compliance', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('createFilterManager - Edge Cases', () => {
|
describe('createAppliedFilterStore - Edge Cases', () => {
|
||||||
it('handles single property groups', () => {
|
it('handles single property groups', () => {
|
||||||
const groups: Array<{
|
const groups: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
@@ -722,7 +722,7 @@ describe('createFilterManager - Edge Cases', () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: '',
|
queryValue: '',
|
||||||
groups,
|
groups,
|
||||||
});
|
});
|
||||||
@@ -749,7 +749,7 @@ describe('createFilterManager - Edge Cases', () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: '',
|
queryValue: '',
|
||||||
groups,
|
groups,
|
||||||
});
|
});
|
||||||
@@ -773,7 +773,7 @@ describe('createFilterManager - Edge Cases', () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const manager = createFilterManager({
|
const manager = createAppliedFilterStore({
|
||||||
queryValue: '',
|
queryValue: '',
|
||||||
groups,
|
groups,
|
||||||
});
|
});
|
||||||
+15
-11
@@ -6,18 +6,22 @@
|
|||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```ts
|
* ```ts
|
||||||
* import { filtersStore } from '$features/GetFonts';
|
* import { availableFilterStore } from '$features/FilterAndSortFonts';
|
||||||
*
|
*
|
||||||
* // Access filters (reactive)
|
* // Access filters (reactive)
|
||||||
* $: filters = filtersStore.filters;
|
* $: filters = availableFilterStore.filters;
|
||||||
* $: isLoading = filtersStore.isLoading;
|
* $: isLoading = availableFilterStore.isLoading;
|
||||||
* $: error = filtersStore.error;
|
* $: error = availableFilterStore.error;
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { fetchProxyFilters } from '$features/GetFonts/api/filters/filters';
|
import { fetchProxyFilters } from '$features/FilterAndSortFonts/api/filters/filters';
|
||||||
import type { FilterMetadata } from '$features/GetFonts/api/filters/filters';
|
import type { FilterMetadata } from '$features/FilterAndSortFonts/api/filters/filters';
|
||||||
import { queryClient } from '$shared/api/queryClient';
|
import {
|
||||||
|
DEFAULT_QUERY_GC_TIME_MS,
|
||||||
|
DEFAULT_QUERY_STALE_TIME_MS,
|
||||||
|
queryClient,
|
||||||
|
} from '$shared/api/queryClient';
|
||||||
import {
|
import {
|
||||||
type QueryKey,
|
type QueryKey,
|
||||||
QueryObserver,
|
QueryObserver,
|
||||||
@@ -31,7 +35,7 @@ import {
|
|||||||
* Fetches and caches filter metadata using fetchProxyFilters()
|
* Fetches and caches filter metadata using fetchProxyFilters()
|
||||||
* Provides reactive access to filter data
|
* Provides reactive access to filter data
|
||||||
*/
|
*/
|
||||||
class FiltersStore {
|
export class AvailableFilterStore {
|
||||||
/**
|
/**
|
||||||
* TanStack Query result state
|
* TanStack Query result state
|
||||||
*/
|
*/
|
||||||
@@ -81,8 +85,8 @@ class FiltersStore {
|
|||||||
return {
|
return {
|
||||||
queryKey: this.getQueryKey(),
|
queryKey: this.getQueryKey(),
|
||||||
queryFn: () => this.fetchFn(),
|
queryFn: () => this.fetchFn(),
|
||||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
staleTime: DEFAULT_QUERY_STALE_TIME_MS,
|
||||||
gcTime: 10 * 60 * 1000, // 10 minutes
|
gcTime: DEFAULT_QUERY_GC_TIME_MS,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,4 +129,4 @@ class FiltersStore {
|
|||||||
/**
|
/**
|
||||||
* Singleton instance
|
* Singleton instance
|
||||||
*/
|
*/
|
||||||
export const filtersStore = new FiltersStore();
|
export const availableFilterStore = new AvailableFilterStore();
|
||||||
+116
@@ -0,0 +1,116 @@
|
|||||||
|
import { queryClient } from '$shared/api/queryClient';
|
||||||
|
import {
|
||||||
|
afterEach,
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
vi,
|
||||||
|
} from 'vitest';
|
||||||
|
import * as filtersApi from '../../../api/filters/filters';
|
||||||
|
import type { FilterMetadata } from '../../../api/filters/filters';
|
||||||
|
import { AvailableFilterStore } from './availableFilterStore.svelte';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a minimal FilterMetadata fixture for tests.
|
||||||
|
*/
|
||||||
|
function metadata(id: string, optionValues: string[] = []): FilterMetadata {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: id,
|
||||||
|
description: '',
|
||||||
|
type: 'enum',
|
||||||
|
options: optionValues.map(value => ({
|
||||||
|
id: value,
|
||||||
|
name: value,
|
||||||
|
value,
|
||||||
|
count: 1,
|
||||||
|
})),
|
||||||
|
} as FilterMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AvailableFilterStore', () => {
|
||||||
|
let store: AvailableFilterStore;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
queryClient.clear();
|
||||||
|
// TanStack defaults retry=3 with exponential backoff, which would
|
||||||
|
// make the error-path test wait >5s. Disable for deterministic timing.
|
||||||
|
queryClient.setDefaultOptions({ queries: { retry: false } });
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
store?.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('initial state', () => {
|
||||||
|
it('starts with an empty filter list', () => {
|
||||||
|
vi.spyOn(filtersApi, 'fetchProxyFilters').mockResolvedValue([]);
|
||||||
|
store = new AvailableFilterStore();
|
||||||
|
expect(store.filters).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports null error before any failure', () => {
|
||||||
|
vi.spyOn(filtersApi, 'fetchProxyFilters').mockResolvedValue([]);
|
||||||
|
store = new AvailableFilterStore();
|
||||||
|
expect(store.error).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('successful fetch', () => {
|
||||||
|
it('populates filters with the fetched metadata', async () => {
|
||||||
|
const data = [
|
||||||
|
metadata('providers', ['google', 'fontshare']),
|
||||||
|
metadata('categories', ['serif', 'sans-serif']),
|
||||||
|
];
|
||||||
|
vi.spyOn(filtersApi, 'fetchProxyFilters').mockResolvedValue(data);
|
||||||
|
|
||||||
|
store = new AvailableFilterStore();
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(store.filters).toEqual(data), { timeout: 1000 });
|
||||||
|
expect(store.isError).toBe(false);
|
||||||
|
expect(store.error).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls fetchProxyFilters exactly once for the initial load', async () => {
|
||||||
|
const spy = vi.spyOn(filtersApi, 'fetchProxyFilters').mockResolvedValue([]);
|
||||||
|
store = new AvailableFilterStore();
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(spy).toHaveBeenCalledTimes(1), { timeout: 1000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('error handling', () => {
|
||||||
|
it('flips isError and exposes the error message on fetch failure', async () => {
|
||||||
|
vi.spyOn(filtersApi, 'fetchProxyFilters').mockRejectedValue(new Error('boom'));
|
||||||
|
store = new AvailableFilterStore();
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(store.isError).toBe(true), { timeout: 1000 });
|
||||||
|
expect(store.error).toBe('boom');
|
||||||
|
expect(store.filters).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('caching', () => {
|
||||||
|
it('does not trigger a second fetch when another instance shares the query key', async () => {
|
||||||
|
const data = [metadata('providers', ['google'])];
|
||||||
|
const spy = vi.spyOn(filtersApi, 'fetchProxyFilters').mockResolvedValue(data);
|
||||||
|
|
||||||
|
store = new AvailableFilterStore();
|
||||||
|
await vi.waitFor(() => expect(store.filters).toEqual(data), { timeout: 1000 });
|
||||||
|
expect(spy).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// A second observer on the same query key should reuse the cached
|
||||||
|
// result rather than triggering a new request.
|
||||||
|
const second = new AvailableFilterStore();
|
||||||
|
try {
|
||||||
|
// Give the new observer a tick to potentially refetch (it shouldn't).
|
||||||
|
await new Promise(r => setTimeout(r, 50));
|
||||||
|
expect(spy).toHaveBeenCalledTimes(1);
|
||||||
|
} finally {
|
||||||
|
second.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* Bridges feature-level UI state (appliedFilterStore + sortStore) to the
|
||||||
|
* entity-level fontCatalogStore query params.
|
||||||
|
*
|
||||||
|
* Centralizing this here means consumers (Search, FontSearch,
|
||||||
|
* FilterControls, etc.) bind to the manager/store directly without
|
||||||
|
* each repeating the same mapping effect. The bridge is a singleton
|
||||||
|
* concern — it tracks singleton state and writes to a singleton query
|
||||||
|
* observer, so it lives at module scope, not in any individual widget.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { fontCatalogStore } 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';
|
||||||
|
|
||||||
|
$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;
|
||||||
|
|
||||||
|
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,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirror filter selections + debounced search query into fontCatalogStore params.
|
||||||
|
* 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));
|
||||||
|
});
|
||||||
|
});
|
||||||
+1
-1
@@ -17,7 +17,7 @@ export const SORT_MAP: Record<SortOption, 'name' | 'popularity' | 'lastModified'
|
|||||||
|
|
||||||
export type SortApiValue = (typeof SORT_MAP)[SortOption];
|
export type SortApiValue = (typeof SORT_MAP)[SortOption];
|
||||||
|
|
||||||
function createSortStore(initial: SortOption = 'Popularity') {
|
export function createSortStore(initial: SortOption = 'Popularity') {
|
||||||
let current = $state<SortOption>(initial);
|
let current = $state<SortOption>(initial);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import {
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
} from 'vitest';
|
||||||
|
import {
|
||||||
|
SORT_MAP,
|
||||||
|
SORT_OPTIONS,
|
||||||
|
type SortOption,
|
||||||
|
createSortStore,
|
||||||
|
sortStore,
|
||||||
|
} from './sortStore.svelte';
|
||||||
|
|
||||||
|
describe('createSortStore', () => {
|
||||||
|
describe('initialization', () => {
|
||||||
|
it('defaults to Popularity when no initial value is provided', () => {
|
||||||
|
const store = createSortStore();
|
||||||
|
expect(store.value).toBe('Popularity');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts an explicit initial value', () => {
|
||||||
|
const store = createSortStore('Newest');
|
||||||
|
expect(store.value).toBe('Newest');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('apiValue mapping', () => {
|
||||||
|
it.each<[SortOption, (typeof SORT_MAP)[SortOption]]>([
|
||||||
|
['Name', 'name'],
|
||||||
|
['Popularity', 'popularity'],
|
||||||
|
['Newest', 'lastModified'],
|
||||||
|
])('maps %s to %s', (display, api) => {
|
||||||
|
const store = createSortStore(display);
|
||||||
|
expect(store.apiValue).toBe(api);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('set()', () => {
|
||||||
|
it('updates both value and apiValue together', () => {
|
||||||
|
const store = createSortStore('Name');
|
||||||
|
store.set('Newest');
|
||||||
|
expect(store.value).toBe('Newest');
|
||||||
|
expect(store.apiValue).toBe('lastModified');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is idempotent — setting the current value keeps state consistent', () => {
|
||||||
|
const store = createSortStore('Popularity');
|
||||||
|
store.set('Popularity');
|
||||||
|
expect(store.value).toBe('Popularity');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sortStore singleton', () => {
|
||||||
|
it('exposes the same shape as a factory instance', () => {
|
||||||
|
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', () => {
|
||||||
|
for (const option of SORT_OPTIONS) {
|
||||||
|
sortStore.set(option);
|
||||||
|
expect(sortStore.value).toBe(option);
|
||||||
|
expect(sortStore.apiValue).toBe(SORT_MAP[option]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
+1
-1
@@ -9,7 +9,7 @@ const { Story } = defineMeta({
|
|||||||
docs: {
|
docs: {
|
||||||
description: {
|
description: {
|
||||||
component:
|
component:
|
||||||
'Renders the full list of filter groups managed by filterManager. Each group maps to a collapsible FilterGroup with checkboxes. No props — reads directly from the filterManager singleton.',
|
'Renders the full list of filter groups managed by appliedFilterStore. Each group maps to a collapsible FilterGroup with checkboxes. No props — reads directly from the appliedFilterStore singleton.',
|
||||||
},
|
},
|
||||||
story: { inline: false },
|
story: { inline: false },
|
||||||
},
|
},
|
||||||
+2
-2
@@ -4,10 +4,10 @@
|
|||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { FilterGroup } from '$shared/ui';
|
import { FilterGroup } from '$shared/ui';
|
||||||
import { filterManager } from '../../model';
|
import { appliedFilterStore } from '../../model';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#each filterManager.groups as group (group.id)}
|
{#each appliedFilterStore.groups as group (group.id)}
|
||||||
<FilterGroup
|
<FilterGroup
|
||||||
displayedLabel={group.label}
|
displayedLabel={group.label}
|
||||||
filter={group.instance}
|
filter={group.instance}
|
||||||
+9
-9
@@ -1,7 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
filterManager,
|
appliedFilterStore,
|
||||||
filtersStore,
|
availableFilterStore,
|
||||||
} from '$features/GetFonts';
|
} from '$features/FilterAndSortFonts';
|
||||||
import {
|
import {
|
||||||
render,
|
render,
|
||||||
screen,
|
screen,
|
||||||
@@ -11,9 +11,9 @@ import Filters from './Filters.svelte';
|
|||||||
|
|
||||||
describe('Filters', () => {
|
describe('Filters', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Clear groups and mock filtersStore to be empty so the auto-sync effect doesn't overwrite us
|
// Clear groups and mock availableFilterStore to be empty so the auto-sync effect doesn't overwrite us
|
||||||
filterManager.setGroups([]);
|
appliedFilterStore.setGroups([]);
|
||||||
vi.spyOn(filtersStore, 'filters', 'get').mockReturnValue([]);
|
vi.spyOn(availableFilterStore, 'filters', 'get').mockReturnValue([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -28,7 +28,7 @@ describe('Filters', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('renders a label for each filter group', () => {
|
it('renders a label for each filter group', () => {
|
||||||
filterManager.setGroups([
|
appliedFilterStore.setGroups([
|
||||||
{ id: 'cat', label: 'Categories', properties: [] },
|
{ id: 'cat', label: 'Categories', properties: [] },
|
||||||
{ id: 'prov', label: 'Font Providers', properties: [] },
|
{ id: 'prov', label: 'Font Providers', properties: [] },
|
||||||
]);
|
]);
|
||||||
@@ -38,7 +38,7 @@ describe('Filters', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('renders filter properties within groups', () => {
|
it('renders filter properties within groups', () => {
|
||||||
filterManager.setGroups([
|
appliedFilterStore.setGroups([
|
||||||
{
|
{
|
||||||
id: 'cat',
|
id: 'cat',
|
||||||
label: 'Category',
|
label: 'Category',
|
||||||
@@ -54,7 +54,7 @@ describe('Filters', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('renders multiple groups with their properties', () => {
|
it('renders multiple groups with their properties', () => {
|
||||||
filterManager.setGroups([
|
appliedFilterStore.setGroups([
|
||||||
{
|
{
|
||||||
id: 'cat',
|
id: 'cat',
|
||||||
label: 'Category',
|
label: 'Category',
|
||||||
+1
-1
@@ -10,7 +10,7 @@ const { Story } = defineMeta({
|
|||||||
docs: {
|
docs: {
|
||||||
description: {
|
description: {
|
||||||
component:
|
component:
|
||||||
'Sort options and Reset_Filters button rendered below the filter list. Reads sort state from sortStore and dispatches resets via filterManager. Requires responsive context — wrap with Providers.',
|
'Sort options and Reset_Filters button rendered below the filter list. Reads sort state from sortStore and dispatches resets via appliedFilterStore. Requires responsive context — wrap with Providers.',
|
||||||
},
|
},
|
||||||
story: { inline: false },
|
story: { inline: false },
|
||||||
},
|
},
|
||||||
+3
-12
@@ -4,19 +4,15 @@
|
|||||||
Sits below the filter list, separated by a top border.
|
Sits below the filter list, separated by a top border.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { fontStore } from '$entities/Font';
|
|
||||||
import type { ResponsiveManager } from '$shared/lib';
|
import type { ResponsiveManager } from '$shared/lib';
|
||||||
import { cn } from '$shared/lib';
|
import { cn } from '$shared/lib';
|
||||||
import { Button } from '$shared/ui';
|
import { Button } from '$shared/ui';
|
||||||
import { Label } from '$shared/ui';
|
import { Label } from '$shared/ui';
|
||||||
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
||||||
import {
|
import { getContext } from 'svelte';
|
||||||
getContext,
|
|
||||||
untrack,
|
|
||||||
} from 'svelte';
|
|
||||||
import {
|
import {
|
||||||
SORT_OPTIONS,
|
SORT_OPTIONS,
|
||||||
filterManager,
|
appliedFilterStore,
|
||||||
sortStore,
|
sortStore,
|
||||||
} from '../../model';
|
} from '../../model';
|
||||||
|
|
||||||
@@ -31,16 +27,11 @@ const {
|
|||||||
class: className,
|
class: className,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
const apiSort = sortStore.apiValue;
|
|
||||||
untrack(() => fontStore.setSort(apiSort));
|
|
||||||
});
|
|
||||||
|
|
||||||
const responsive = getContext<ResponsiveManager>('responsive');
|
const responsive = getContext<ResponsiveManager>('responsive');
|
||||||
const isMobileOrTabletPortrait = $derived(responsive.isMobile || responsive.isTabletPortrait);
|
const isMobileOrTabletPortrait = $derived(responsive.isMobile || responsive.isTabletPortrait);
|
||||||
|
|
||||||
function handleReset() {
|
function handleReset() {
|
||||||
filterManager.deselectAllGlobal();
|
appliedFilterStore.deselectAllGlobal();
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
export {
|
|
||||||
createFilterManager,
|
|
||||||
type FilterManager,
|
|
||||||
mapManagerToParams,
|
|
||||||
} from './lib';
|
|
||||||
|
|
||||||
export { filtersStore } from './model/state/filters.svelte';
|
|
||||||
export { filterManager } from './model/state/manager.svelte';
|
|
||||||
|
|
||||||
export {
|
|
||||||
SORT_MAP,
|
|
||||||
SORT_OPTIONS,
|
|
||||||
type SortApiValue,
|
|
||||||
type SortOption,
|
|
||||||
sortStore,
|
|
||||||
} from './model/store/sortStore.svelte';
|
|
||||||
|
|
||||||
export {
|
|
||||||
FilterControls,
|
|
||||||
Filters,
|
|
||||||
} from './ui';
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
export {
|
|
||||||
createFilterManager,
|
|
||||||
type FilterManager,
|
|
||||||
} from './filterManager/filterManager.svelte';
|
|
||||||
|
|
||||||
export { mapManagerToParams } from './mapper/mapManagerToParams';
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import type { ProxyFontsParams } from '$entities/Font/api';
|
|
||||||
import type { FilterManager } from '../filterManager/filterManager.svelte';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Maps filter manager to proxy API parameters.
|
|
||||||
*
|
|
||||||
* Updated to support multiple filter values (arrays)
|
|
||||||
*
|
|
||||||
* @param manager - Filter manager instance with reactive state
|
|
||||||
* @returns - Partial proxy API parameters ready for API call
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```ts
|
|
||||||
* // Example filter manager state:
|
|
||||||
* // {
|
|
||||||
* // queryValue: 'roboto',
|
|
||||||
* // providers: ['google', 'fontshare'],
|
|
||||||
* // categories: ['sans-serif', 'serif'],
|
|
||||||
* // subsets: ['latin']
|
|
||||||
* // }
|
|
||||||
*
|
|
||||||
* const params = mapManagerToParams(manager);
|
|
||||||
* // Returns: {
|
|
||||||
* // providers: ['google', 'fontshare'],
|
|
||||||
* // categories: ['sans-serif', 'serif'],
|
|
||||||
* // subsets: ['latin'],
|
|
||||||
* // q: 'roboto'
|
|
||||||
* // }
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function mapManagerToParams(manager: FilterManager): Partial<ProxyFontsParams> {
|
|
||||||
const providers = manager.getGroup('providers')?.instance.selectedProperties.map(p => p.value);
|
|
||||||
const categories = manager.getGroup('categories')?.instance.selectedProperties.map(p => p.value);
|
|
||||||
const subsets = manager.getGroup('subsets')?.instance.selectedProperties.map(p => p.value);
|
|
||||||
|
|
||||||
return {
|
|
||||||
// Search query (debounced)
|
|
||||||
q: manager.debouncedQueryValue || undefined,
|
|
||||||
|
|
||||||
// NEW: Support arrays - send all selected values
|
|
||||||
providers: providers && providers.length > 0
|
|
||||||
? providers as string[]
|
|
||||||
: undefined,
|
|
||||||
|
|
||||||
categories: categories && categories.length > 0
|
|
||||||
? categories as string[]
|
|
||||||
: undefined,
|
|
||||||
|
|
||||||
subsets: subsets && subsets.length > 0
|
|
||||||
? subsets as string[]
|
|
||||||
: undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
/**
|
|
||||||
* Filter manager singleton
|
|
||||||
*
|
|
||||||
* Creates filterManager with empty groups initially, then reactively
|
|
||||||
* populates groups when filtersStore loads data from backend.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { createFilterManager } from '../../lib/filterManager/filterManager.svelte';
|
|
||||||
import { filtersStore } from './filters.svelte';
|
|
||||||
|
|
||||||
export const filterManager = createFilterManager({
|
|
||||||
queryValue: '',
|
|
||||||
groups: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reactively sync backend filter metadata into filterManager groups.
|
|
||||||
* When filtersStore.filters resolves, setGroups replaces the empty groups.
|
|
||||||
*/
|
|
||||||
$effect.root(() => {
|
|
||||||
$effect(() => {
|
|
||||||
const dynamicFilters = filtersStore.filters;
|
|
||||||
|
|
||||||
if (dynamicFilters.length > 0) {
|
|
||||||
filterManager.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,
|
|
||||||
})),
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
export {
|
|
||||||
createTypographySettingsManager,
|
|
||||||
type TypographySettingsManager,
|
|
||||||
} from './lib';
|
|
||||||
export { typographySettingsStore } from './model';
|
|
||||||
export { TypographyMenu } from './ui';
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
export {
|
|
||||||
createTypographySettingsManager,
|
|
||||||
type TypographySettingsManager,
|
|
||||||
} from './settingsManager/settingsManager.svelte';
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { typographySettingsStore } from './state/typographySettingsStore';
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { DEFAULT_TYPOGRAPHY_CONTROLS_DATA } from '$entities/Font';
|
|
||||||
import { createTypographySettingsManager } from '../../lib';
|
|
||||||
|
|
||||||
export const typographySettingsStore = createTypographySettingsManager(
|
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
|
||||||
'glyphdiff:comparison:typography',
|
|
||||||
);
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* Centralized backend endpoint definitions.
|
||||||
|
*
|
||||||
|
* One source of truth for the proxy API base URL — individual resource
|
||||||
|
* modules (proxyFonts, filters) append their own paths.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const API_BASE_URL = 'https://api.glyphdiff.com/api/v1' as const;
|
||||||
|
|
||||||
|
export const API_ENDPOINTS = {
|
||||||
|
/**
|
||||||
|
* Font catalog + per-id detail + batch lookup
|
||||||
|
*/
|
||||||
|
fonts: `${API_BASE_URL}/fonts`,
|
||||||
|
/**
|
||||||
|
* Filter metadata (providers, categories, subsets)
|
||||||
|
*/
|
||||||
|
filters: `${API_BASE_URL}/filters`,
|
||||||
|
} as const;
|
||||||
@@ -1,5 +1,31 @@
|
|||||||
import { QueryClient } from '@tanstack/query-core';
|
import { QueryClient } from '@tanstack/query-core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data remains fresh for this long after fetch. Stores that override
|
||||||
|
* staleness (e.g. filtered queries) can use 0 to bypass.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_QUERY_STALE_TIME_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unused cache entries are garbage collected after this long.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_QUERY_GC_TIME_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many times a failed query is retried before surfacing the error.
|
||||||
|
*/
|
||||||
|
export const QUERY_RETRY_COUNT = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base delay for exponential retry backoff.
|
||||||
|
*/
|
||||||
|
export const QUERY_RETRY_BASE_DELAY_MS = 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upper bound on retry delay regardless of attempt index.
|
||||||
|
*/
|
||||||
|
export const QUERY_RETRY_MAX_DELAY_MS = 30000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TanStack Query client instance
|
* TanStack Query client instance
|
||||||
*
|
*
|
||||||
@@ -15,14 +41,8 @@ import { QueryClient } from '@tanstack/query-core';
|
|||||||
export const queryClient = new QueryClient({
|
export const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
/**
|
staleTime: DEFAULT_QUERY_STALE_TIME_MS,
|
||||||
* Data remains fresh for 5 minutes after fetch
|
gcTime: DEFAULT_QUERY_GC_TIME_MS,
|
||||||
*/
|
|
||||||
staleTime: 5 * 60 * 1000,
|
|
||||||
/**
|
|
||||||
* 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
|
||||||
*/
|
*/
|
||||||
@@ -31,15 +51,12 @@ export const queryClient = new QueryClient({
|
|||||||
* Refetch on mount if data is stale
|
* Refetch on mount if data is stale
|
||||||
*/
|
*/
|
||||||
refetchOnMount: true,
|
refetchOnMount: true,
|
||||||
|
retry: QUERY_RETRY_COUNT,
|
||||||
/**
|
/**
|
||||||
* Retry failed requests up to 3 times
|
* Exponential backoff: 1s, 2s, 4s, 8s... capped at 30s
|
||||||
*/
|
*/
|
||||||
retry: 3,
|
retryDelay: attemptIndex =>
|
||||||
/**
|
Math.min(QUERY_RETRY_BASE_DELAY_MS * 2 ** attemptIndex, QUERY_RETRY_MAX_DELAY_MS),
|
||||||
* Exponential backoff for retries
|
|
||||||
* 1s, 2s, 4s, 8s... capped at 30s
|
|
||||||
*/
|
|
||||||
retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000),
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
+17
-2
@@ -4,6 +4,20 @@ import {
|
|||||||
prepareWithSegments,
|
prepareWithSegments,
|
||||||
} from '@chenglou/pretext';
|
} from '@chenglou/pretext';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Width of the character morph "halo" around the slider thumb, in percent
|
||||||
|
* of container width. Characters within this window get partial blending
|
||||||
|
* instead of a hard A→B flip.
|
||||||
|
*/
|
||||||
|
const CHAR_PROXIMITY_RANGE_PCT = 5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default render size in px when callers omit the `size` arg on `layout()`.
|
||||||
|
* Kept as a local constant to avoid pulling `$entities/Font` into
|
||||||
|
* `$shared/lib` (would create an FSD-illegal upward import cycle).
|
||||||
|
*/
|
||||||
|
const DEFAULT_RENDER_SIZE_PX = 16;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A single laid-out line produced by dual-font comparison layout.
|
* A single laid-out line produced by dual-font comparison layout.
|
||||||
*
|
*
|
||||||
@@ -129,7 +143,7 @@ export class CharacterComparisonEngine {
|
|||||||
width: number,
|
width: number,
|
||||||
lineHeight: number,
|
lineHeight: number,
|
||||||
spacing: number = 0,
|
spacing: number = 0,
|
||||||
size: number = 16,
|
size: number = DEFAULT_RENDER_SIZE_PX,
|
||||||
): ComparisonResult {
|
): ComparisonResult {
|
||||||
if (!text) {
|
if (!text) {
|
||||||
return { lines: [], totalHeight: 0 };
|
return { lines: [], totalHeight: 0 };
|
||||||
@@ -260,7 +274,7 @@ export class CharacterComparisonEngine {
|
|||||||
const chars = line.chars;
|
const chars = line.chars;
|
||||||
const n = chars.length;
|
const n = chars.length;
|
||||||
const sliderX = (sliderPos / 100) * containerWidth;
|
const sliderX = (sliderPos / 100) * containerWidth;
|
||||||
const range = 5;
|
const range = CHAR_PROXIMITY_RANGE_PCT;
|
||||||
// Prefix sums of widthA (left chars will be past → use widthA).
|
// Prefix sums of widthA (left chars will be past → use widthA).
|
||||||
// Suffix sums of widthB (right chars will not be past → use widthB).
|
// 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
|
// This lets us compute, for each char i, what the total line width and
|
||||||
@@ -291,6 +305,7 @@ export class CharacterComparisonEngine {
|
|||||||
const totalRendered = chars.reduce((s, c, i) => s + (isPastArr[i] ? c.widthA : c.widthB), 0);
|
const totalRendered = chars.reduce((s, c, i) => s + (isPastArr[i] ? c.widthA : c.widthB), 0);
|
||||||
const xOffset = (containerWidth - totalRendered) / 2;
|
const xOffset = (containerWidth - totalRendered) / 2;
|
||||||
let currentX = xOffset;
|
let currentX = xOffset;
|
||||||
|
|
||||||
return chars.map((char, i) => {
|
return chars.map((char, i) => {
|
||||||
const isPast = isPastArr[i] === 1;
|
const isPast = isPastArr[i] === 1;
|
||||||
const charWidth = isPast ? char.widthA : char.widthB;
|
const charWidth = isPast ? char.widthA : char.widthB;
|
||||||
|
|||||||
@@ -70,6 +70,14 @@ export interface LayoutResult {
|
|||||||
* **Canvas requirement:** pretext calls `document.createElement('canvas').getContext('2d')` on
|
* **Canvas requirement:** pretext calls `document.createElement('canvas').getContext('2d')` on
|
||||||
* first use and caches the context for the process lifetime. Tests must install a canvas mock
|
* first use and caches the context for the process lifetime. Tests must install a canvas mock
|
||||||
* (see `__mocks__/canvas.ts`) before the first `layout()` call.
|
* (see `__mocks__/canvas.ts`) before the first `layout()` call.
|
||||||
|
*
|
||||||
|
* @deprecated No live consumers remain — the only previous caller
|
||||||
|
* (`createFontRowSizeResolver`) now invokes pretext's `prepare` + `layout`
|
||||||
|
* directly (per pretext's "hot-path resize function" guidance). If you need
|
||||||
|
* single-font height-only measurement, use `prepare` + `layout` from
|
||||||
|
* `@chenglou/pretext` directly. If you need per-grapheme x/width data, see
|
||||||
|
* `CharacterComparisonEngine` (dual-font) or revive a slimmer wrapper.
|
||||||
|
* Slated for removal once it has been absent from `main` for a release cycle.
|
||||||
*/
|
*/
|
||||||
export class TextLayoutEngine {
|
export class TextLayoutEngine {
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { debounce } from '$shared/lib/utils';
|
import { debounce } from '$shared/lib/utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default debounce delay used when no wait is provided. Picked to feel
|
||||||
|
* snappy for typing while still coalescing API-bound side effects.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_DEBOUNCE_MS = 300;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates reactive state with immediate and debounced values.
|
* Creates reactive state with immediate and debounced values.
|
||||||
*
|
*
|
||||||
@@ -23,7 +29,7 @@ import { debounce } from '$shared/lib/utils';
|
|||||||
* <p>Searching: {search.debounced}</p>
|
* <p>Searching: {search.debounced}</p>
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export function createDebouncedState<T>(initialValue: T, wait: number = 300) {
|
export function createDebouncedState<T>(initialValue: T, wait: number = DEFAULT_DEBOUNCE_MS) {
|
||||||
let immediate = $state(initialValue);
|
let immediate = $state(initialValue);
|
||||||
let debounced = $state(initialValue);
|
let debounced = $state(initialValue);
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,19 @@
|
|||||||
|
|
||||||
import { Spring } from 'svelte/motion';
|
import { Spring } from 'svelte/motion';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spring tuning for the perspective animation. Lower stiffness = slower
|
||||||
|
* easing into back/front state; higher damping = less overshoot.
|
||||||
|
*/
|
||||||
|
const PERSPECTIVE_SPRING_CONFIG = { stiffness: 0.2, damping: 0.8 } as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Halfway threshold on the 0→1 spring value. Above flips `isBack`,
|
||||||
|
* below flips `isFront`. Picking 0.5 means both states flip at the
|
||||||
|
* exact midpoint of the animation.
|
||||||
|
*/
|
||||||
|
const PERSPECTIVE_TOGGLE_THRESHOLD = 0.5;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configuration options for perspective effects
|
* Configuration options for perspective effects
|
||||||
*/
|
*/
|
||||||
@@ -93,10 +106,7 @@ export class PerspectiveManager {
|
|||||||
* Spring animation state
|
* Spring animation state
|
||||||
* Animates between 0 (front) and 1 (back) with configurable physics
|
* Animates between 0 (front) and 1 (back) with configurable physics
|
||||||
*/
|
*/
|
||||||
spring = new Spring(0, {
|
spring = new Spring(0, PERSPECTIVE_SPRING_CONFIG);
|
||||||
stiffness: 0.2,
|
|
||||||
damping: 0.8,
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reactive state: true when in back position
|
* Reactive state: true when in back position
|
||||||
@@ -104,7 +114,7 @@ export class PerspectiveManager {
|
|||||||
* Content should appear blurred, scaled down, and less interactive
|
* Content should appear blurred, scaled down, and less interactive
|
||||||
* when this is true. Derived from spring value > 0.5.
|
* when this is true. Derived from spring value > 0.5.
|
||||||
*/
|
*/
|
||||||
isBack = $derived(this.spring.current > 0.5);
|
isBack = $derived(this.spring.current > PERSPECTIVE_TOGGLE_THRESHOLD);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reactive state: true when in front position
|
* Reactive state: true when in front position
|
||||||
@@ -112,7 +122,7 @@ export class PerspectiveManager {
|
|||||||
* Content should be fully visible, sharp, and interactive
|
* Content should be fully visible, sharp, and interactive
|
||||||
* when this is true. Derived from spring value < 0.5.
|
* when this is true. Derived from spring value < 0.5.
|
||||||
*/
|
*/
|
||||||
isFront = $derived(this.spring.current < 0.5);
|
isFront = $derived(this.spring.current < PERSPECTIVE_TOGGLE_THRESHOLD);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Internal configuration with defaults applied
|
* Internal configuration with defaults applied
|
||||||
|
|||||||
@@ -4,6 +4,12 @@
|
|||||||
* Used to render visible items with absolute positioning based on computed offsets.
|
* Used to render visible items with absolute positioning based on computed offsets.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimum height delta (in px) required to commit a re-measured row height.
|
||||||
|
* Sub-pixel diffs are treated as measurement noise to avoid spurious re-flows.
|
||||||
|
*/
|
||||||
|
const MEASUREMENT_EPSILON_PX = 0.5;
|
||||||
|
|
||||||
export interface VirtualItem {
|
export interface VirtualItem {
|
||||||
/**
|
/**
|
||||||
* Index of the item in the data array
|
* Index of the item in the data array
|
||||||
@@ -58,7 +64,7 @@ export interface VirtualizerOptions {
|
|||||||
* when those values change, `offsets` and `totalSize` recompute instantly.
|
* when those values change, `offsets` and `totalSize` recompute instantly.
|
||||||
*
|
*
|
||||||
* For font preview rows, pass a closure that reads
|
* For font preview rows, pass a closure that reads
|
||||||
* `appliedFontsManager.statuses` so the virtualizer recalculates heights
|
* `fontLifecycleManager.statuses` so the virtualizer recalculates heights
|
||||||
* as fonts finish loading, eliminating the DOM-measurement snap on load.
|
* as fonts finish loading, eliminating the DOM-measurement snap on load.
|
||||||
*/
|
*/
|
||||||
estimateSize: (index: number) => number;
|
estimateSize: (index: number) => number;
|
||||||
@@ -381,8 +387,8 @@ export function createVirtualizer<T>(
|
|||||||
if (!isNaN(index)) {
|
if (!isNaN(index)) {
|
||||||
const oldHeight = measuredSizes[index];
|
const oldHeight = measuredSizes[index];
|
||||||
|
|
||||||
// Only update if the height difference is significant (> 0.5px)
|
// Only update if the height difference is significant
|
||||||
if (oldHeight === undefined || Math.abs(oldHeight - height) > 0.5) {
|
if (oldHeight === undefined || Math.abs(oldHeight - height) > MEASUREMENT_EPSILON_PX) {
|
||||||
measurementBuffer[index] = height;
|
measurementBuffer[index] = height;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { cn } from '$shared/lib';
|
|||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import type { HTMLButtonAttributes } from 'svelte/elements';
|
import type { HTMLButtonAttributes } from 'svelte/elements';
|
||||||
import type {
|
import type {
|
||||||
|
ButtonLayout,
|
||||||
ButtonSize,
|
ButtonSize,
|
||||||
ButtonVariant,
|
ButtonVariant,
|
||||||
IconPosition,
|
IconPosition,
|
||||||
@@ -23,6 +24,14 @@ interface Props extends HTMLButtonAttributes {
|
|||||||
* @default 'md'
|
* @default 'md'
|
||||||
*/
|
*/
|
||||||
size?: ButtonSize;
|
size?: ButtonSize;
|
||||||
|
/**
|
||||||
|
* Layout shape
|
||||||
|
* - `inline`: default — content-sized, centered.
|
||||||
|
* - `block-list-row`: full-width row with the content left-aligned and any
|
||||||
|
* trailing icon pushed to the right (used for filter-group rows, etc).
|
||||||
|
* @default 'inline'
|
||||||
|
*/
|
||||||
|
layout?: ButtonLayout;
|
||||||
/**
|
/**
|
||||||
* Icon snippet
|
* Icon snippet
|
||||||
*/
|
*/
|
||||||
@@ -56,6 +65,7 @@ interface Props extends HTMLButtonAttributes {
|
|||||||
let {
|
let {
|
||||||
variant = 'secondary',
|
variant = 'secondary',
|
||||||
size = 'md',
|
size = 'md',
|
||||||
|
layout = 'inline',
|
||||||
icon,
|
icon,
|
||||||
iconPosition = 'left',
|
iconPosition = 'left',
|
||||||
active = false,
|
active = false,
|
||||||
@@ -76,10 +86,10 @@ const variantStyles: Record<ButtonVariant, string> = {
|
|||||||
'hover:bg-swiss-red/90',
|
'hover:bg-swiss-red/90',
|
||||||
'active:bg-swiss-red/80',
|
'active:bg-swiss-red/80',
|
||||||
'border border-swiss-red',
|
'border border-swiss-red',
|
||||||
'shadow-[0.125rem_0.125rem_0_0_rgba(0,0,0,0.1)]',
|
'shadow-stamp-rest',
|
||||||
'hover:shadow-[0.1875rem_0.1875rem_0_0_rgba(0,0,0,0.15)]',
|
'hover:shadow-stamp-hover',
|
||||||
'active:shadow-[0.0625rem_0.0625rem_0_0_rgba(0,0,0,0.08)]',
|
'active:shadow-stamp-pressed',
|
||||||
'active:translate-x-[0.0625rem] active:translate-y-[0.0625rem]',
|
'active:translate-x-px active:translate-y-px',
|
||||||
'disabled:bg-neutral-300 dark:disabled:bg-neutral-700',
|
'disabled:bg-neutral-300 dark:disabled:bg-neutral-700',
|
||||||
'disabled:text-neutral-500 dark:disabled:text-neutral-500',
|
'disabled:text-neutral-500 dark:disabled:text-neutral-500',
|
||||||
'disabled:border-neutral-300 dark:disabled:border-neutral-700',
|
'disabled:border-neutral-300 dark:disabled:border-neutral-700',
|
||||||
@@ -111,7 +121,7 @@ const variantStyles: Record<ButtonVariant, string> = {
|
|||||||
),
|
),
|
||||||
ghost: cn(
|
ghost: cn(
|
||||||
'bg-transparent',
|
'bg-transparent',
|
||||||
'text-secondary',
|
'text-subtle',
|
||||||
'border border-transparent',
|
'border border-transparent',
|
||||||
'hover:bg-transparent dark:hover:bg-transparent',
|
'hover:bg-transparent dark:hover:bg-transparent',
|
||||||
'hover:text-brand dark:hover:text-brand',
|
'hover:text-brand dark:hover:text-brand',
|
||||||
@@ -120,8 +130,8 @@ const variantStyles: Record<ButtonVariant, string> = {
|
|||||||
'disabled:cursor-not-allowed',
|
'disabled:cursor-not-allowed',
|
||||||
),
|
),
|
||||||
icon: cn(
|
icon: cn(
|
||||||
'bg-surface dark:bg-dark-bg',
|
'surface-canvas',
|
||||||
'text-secondary',
|
'text-subtle',
|
||||||
'border border-transparent',
|
'border border-transparent',
|
||||||
'hover:bg-paper dark:hover:bg-paper',
|
'hover:bg-paper dark:hover:bg-paper',
|
||||||
'hover:text-brand',
|
'hover:text-brand',
|
||||||
@@ -133,9 +143,11 @@ const variantStyles: Record<ButtonVariant, string> = {
|
|||||||
tertiary: cn(
|
tertiary: cn(
|
||||||
// Font override — must come after base in cn() to win via tailwind-merge
|
// Font override — must come after base in cn() to win via tailwind-merge
|
||||||
'font-secondary font-medium normal-case tracking-normal',
|
'font-secondary font-medium normal-case tracking-normal',
|
||||||
// Inactive state
|
// Inactive state — bumped in light mode for readable contrast against
|
||||||
|
// bg-surface (~7.5:1 vs. the prior ~2.7:1 with neutral-400). Dark
|
||||||
|
// unchanged because the existing tone reads well on dark-bg.
|
||||||
'bg-transparent',
|
'bg-transparent',
|
||||||
'text-neutral-400 dark:text-neutral-400',
|
'text-neutral-600 dark:text-neutral-400',
|
||||||
'border border-transparent',
|
'border border-transparent',
|
||||||
// Hover (inactive) — semi-transparent lift, no bg-paper token
|
// Hover (inactive) — semi-transparent lift, no bg-paper token
|
||||||
'hover:bg-paper/50 dark:hover:bg-dark-card/50',
|
'hover:bg-paper/50 dark:hover:bg-dark-card/50',
|
||||||
@@ -174,12 +186,19 @@ const activeStyles: Partial<Record<ButtonVariant, string>> = {
|
|||||||
icon: 'bg-paper dark:bg-paper text-brand border-subtle',
|
icon: 'bg-paper dark:bg-paper text-brand border-subtle',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const layoutStyles: Record<ButtonLayout, string> = {
|
||||||
|
inline: '',
|
||||||
|
/* List-row buttons act as content labels rather than action buttons,
|
||||||
|
so they bump to `text-sm` regardless of the size prop's default. */
|
||||||
|
'block-list-row': 'w-full justify-between text-left text-sm',
|
||||||
|
};
|
||||||
|
|
||||||
const classes = $derived(cn(
|
const classes = $derived(cn(
|
||||||
// Base
|
// Base
|
||||||
'inline-flex items-center justify-center',
|
'inline-flex items-center justify-center',
|
||||||
'font-primary font-bold tracking-tight uppercase',
|
'text-label-mono',
|
||||||
'rounded-none',
|
'rounded-none',
|
||||||
'transition-all duration-200',
|
'transition-all duration-normal',
|
||||||
'select-none',
|
'select-none',
|
||||||
'outline-none',
|
'outline-none',
|
||||||
'cursor-pointer',
|
'cursor-pointer',
|
||||||
@@ -190,6 +209,8 @@ const classes = $derived(cn(
|
|||||||
variantStyles[variant],
|
variantStyles[variant],
|
||||||
// Size (square when icon-only)
|
// Size (square when icon-only)
|
||||||
isIconOnly ? iconSizeStyles[size] : sizeStyles[size],
|
isIconOnly ? iconSizeStyles[size] : sizeStyles[size],
|
||||||
|
// Layout
|
||||||
|
layoutStyles[layout],
|
||||||
// Animate (CSS tap scale — excluded for primary which uses translate instead)
|
// Animate (CSS tap scale — excluded for primary which uses translate instead)
|
||||||
animate && !disabled && variant !== 'primary' && 'active:scale-[0.97]',
|
animate && !disabled && variant !== 'primary' && 'active:scale-[0.97]',
|
||||||
// Active override
|
// Active override
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ let { children, class: className, ...rest }: Props = $props();
|
|||||||
<div
|
<div
|
||||||
class={cn(
|
class={cn(
|
||||||
'flex items-center gap-1 p-1',
|
'flex items-center gap-1 p-1',
|
||||||
'bg-surface dark:bg-dark-bg',
|
'surface-canvas',
|
||||||
'border border-subtle',
|
'border border-subtle',
|
||||||
'rounded-none',
|
'rounded-none',
|
||||||
'transition-colors duration-500',
|
'transition-colors duration-500',
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export type ButtonVariant = 'primary' | 'secondary' | 'tertiary' | 'ghost' | 'outline' | 'icon';
|
export type ButtonVariant = 'primary' | 'secondary' | 'tertiary' | 'ghost' | 'outline' | 'icon';
|
||||||
export type ButtonSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
export type ButtonSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||||
|
export type ButtonLayout = 'inline' | 'block-list-row';
|
||||||
export type IconPosition = 'left' | 'right';
|
export type IconPosition = 'left' | 'right';
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ const displayLabel = $derived(label ?? controlLabel ?? '');
|
|||||||
step={control.step}
|
step={control.step}
|
||||||
orientation="horizontal"
|
orientation="horizontal"
|
||||||
/>
|
/>
|
||||||
<span class="font-mono text-xs text-secondary tabular-nums w-10 text-right shrink-0">
|
<span class="font-mono text-xs text-subtle tabular-nums w-10 text-right shrink-0">
|
||||||
{formattedValue()}
|
{formattedValue()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -120,12 +120,12 @@ const displayLabel = $derived(label ?? controlLabel ?? '');
|
|||||||
<button
|
<button
|
||||||
{...props}
|
{...props}
|
||||||
class={cn(
|
class={cn(
|
||||||
'flex flex-col items-center justify-center w-14 py-1',
|
'flex flex-col flex-center w-14 py-1',
|
||||||
'select-none rounded-none transition-all duration-150',
|
'select-none rounded-none transition-all duration-fast',
|
||||||
'border border-transparent',
|
'border border-transparent',
|
||||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/30',
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/30',
|
||||||
open
|
open
|
||||||
? 'bg-paper dark:bg-dark-card shadow-sm border-subtle'
|
? 'surface-card-elevated'
|
||||||
: 'hover:bg-paper/50 dark:hover:bg-dark-card/50',
|
: 'hover:bg-paper/50 dark:hover:bg-dark-card/50',
|
||||||
)}
|
)}
|
||||||
aria-label={controlLabel ? `${controlLabel}: ${formattedValue()}` : undefined}
|
aria-label={controlLabel ? `${controlLabel}: ${formattedValue()}` : undefined}
|
||||||
@@ -134,7 +134,7 @@ const displayLabel = $derived(label ?? controlLabel ?? '');
|
|||||||
{#if displayLabel}
|
{#if displayLabel}
|
||||||
<span
|
<span
|
||||||
class="
|
class="
|
||||||
text-3xs font-primary font-bold tracking-tight uppercase
|
text-3xs text-label-mono
|
||||||
text-neutral-900 dark:text-neutral-100
|
text-neutral-900 dark:text-neutral-100
|
||||||
mb-0.5 leading-none
|
mb-0.5 leading-none
|
||||||
"
|
"
|
||||||
@@ -153,7 +153,7 @@ const displayLabel = $derived(label ?? controlLabel ?? '');
|
|||||||
|
|
||||||
<!-- Vertical slider popover -->
|
<!-- Vertical slider popover -->
|
||||||
<Popover.Content
|
<Popover.Content
|
||||||
class="w-auto py-4 px-3 h-64 flex items-center justify-center rounded-none border border-subtle shadow-sm bg-paper dark:bg-dark-card"
|
class="w-auto py-4 px-3 h-64 flex-center rounded-none surface-card-elevated"
|
||||||
align="center"
|
align="center"
|
||||||
side="top"
|
side="top"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ let {
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
class={cn(
|
class={cn(
|
||||||
'bg-black/10 dark:bg-white/10',
|
'bg-subtle',
|
||||||
orientation === 'horizontal' ? 'w-full h-px' : 'w-px h-full',
|
orientation === 'horizontal' ? 'w-full h-px' : 'w-px h-full',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -83,9 +83,9 @@ $effect(() => {
|
|||||||
<div transition:fly={{ y: 20, duration: 200, easing: cubicOut }}>
|
<div transition:fly={{ y: 20, duration: 200, easing: cubicOut }}>
|
||||||
<Button
|
<Button
|
||||||
variant="tertiary"
|
variant="tertiary"
|
||||||
|
layout="block-list-row"
|
||||||
active={property.selected}
|
active={property.selected}
|
||||||
onclick={() => (property.selected = !property.selected)}
|
onclick={() => (property.selected = !property.selected)}
|
||||||
class="w-full px-3 md:px-4 py-2.5 md:py-3 justify-between text-left text-sm flex"
|
|
||||||
iconPosition="right"
|
iconPosition="right"
|
||||||
icon={property.selected ? icon : undefined}
|
icon={property.selected ? icon : undefined}
|
||||||
>
|
>
|
||||||
@@ -96,8 +96,8 @@ $effect(() => {
|
|||||||
{#if hasMore}
|
{#if hasMore}
|
||||||
<Button
|
<Button
|
||||||
variant="icon"
|
variant="icon"
|
||||||
|
layout="block-list-row"
|
||||||
onclick={() => (showMore = !showMore)}
|
onclick={() => (showMore = !showMore)}
|
||||||
class="w-full px-3 md:px-4 py-2.5 md:py-3 justify-between text-left text-sm flex"
|
|
||||||
iconPosition="left"
|
iconPosition="left"
|
||||||
>
|
>
|
||||||
{#snippet icon()}
|
{#snippet icon()}
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ const inputClasses = $derived(cn(
|
|||||||
<span
|
<span
|
||||||
class={cn(
|
class={cn(
|
||||||
'text-2xs font-mono tracking-wide px-1',
|
'text-2xs font-mono tracking-wide px-1',
|
||||||
error ? 'text-brand ' : 'text-secondary',
|
error ? 'text-brand ' : 'text-subtle',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{helperText}
|
{helperText}
|
||||||
|
|||||||
@@ -25,7 +25,9 @@ export const labelSizeConfig: Record<LabelSize, string> = {
|
|||||||
export const labelVariantConfig: Record<LabelVariant, string> = {
|
export const labelVariantConfig: Record<LabelVariant, string> = {
|
||||||
default: 'text-neutral-900 dark:text-neutral-100',
|
default: 'text-neutral-900 dark:text-neutral-100',
|
||||||
accent: 'text-brand',
|
accent: 'text-brand',
|
||||||
muted: 'text-neutral-400 dark:text-neutral-500',
|
/* Light mode bumped from neutral-400 (~2.7:1 contrast, barely visible)
|
||||||
|
to neutral-600 (~7.5:1). Dark mode unchanged. */
|
||||||
|
muted: 'text-neutral-600 dark:text-neutral-500',
|
||||||
success: 'text-green-600 dark:text-green-400',
|
success: 'text-green-600 dark:text-green-400',
|
||||||
warning: 'text-yellow-600 dark:text-yellow-400',
|
warning: 'text-yellow-600 dark:text-yellow-400',
|
||||||
error: 'text-brand',
|
error: 'text-brand',
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ let {
|
|||||||
<a
|
<a
|
||||||
class={cn(
|
class={cn(
|
||||||
'group inline-flex items-center gap-1 text-2xs font-mono uppercase tracking-wider-mono',
|
'group inline-flex items-center gap-1 text-2xs font-mono uppercase tracking-wider-mono',
|
||||||
'text-neutral-400 hover:text-brand transition-colors',
|
'text-neutral-500 dark:text-neutral-400 hover:text-brand transition-colors',
|
||||||
'bg-surface/80 dark:bg-dark-bg/80 backdrop-blur-sm px-2 py-1 pointer-events-auto',
|
'bg-surface/80 dark:bg-dark-bg/80 backdrop-blur-sm px-2 py-1 pointer-events-auto',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ let { size = 20, class: className = '', message = 'analyzing_data' }: Props = $p
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="absolute inset-x-0 inset-y-0 flex items-center justify-center gap-4 {className}"
|
class="absolute inset-x-0 inset-y-0 flex-center gap-4 {className}"
|
||||||
in:fade={{ duration: 300 }}
|
in:fade={{ duration: 300 }}
|
||||||
out:fade={{ duration: 300 }}
|
out:fade={{ duration: 300 }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ function close() {
|
|||||||
|
|
||||||
<!-- Panel -->
|
<!-- Panel -->
|
||||||
<div
|
<div
|
||||||
class="fixed left-0 top-0 bottom-0 w-80 z-50 shadow-2xl"
|
class="fixed left-0 top-0 bottom-0 w-80 z-50 shadow-overlay"
|
||||||
in:fly={{ x: -320, duration: 300, easing: cubicOut }}
|
in:fly={{ x: -320, duration: 300, easing: cubicOut }}
|
||||||
out:fly={{ x: -320, duration: 250, easing: cubicOut }}
|
out:fly={{ x: -320, duration: 250, easing: cubicOut }}
|
||||||
>
|
>
|
||||||
@@ -83,11 +83,10 @@ function close() {
|
|||||||
'shrink-0 z-30 h-full relative',
|
'shrink-0 z-30 h-full relative',
|
||||||
'overflow-hidden',
|
'overflow-hidden',
|
||||||
'will-change-[width]',
|
'will-change-[width]',
|
||||||
'transition-[width] duration-300 ease-out',
|
|
||||||
'border-r border-subtle',
|
'border-r border-subtle',
|
||||||
'bg-surface dark:bg-dark-bg',
|
'surface-canvas',
|
||||||
isOpen ? 'w-80 opacity-100' : 'w-0 opacity-0',
|
isOpen ? 'w-80 opacity-100' : 'w-0 opacity-0',
|
||||||
'transition-[width,opacity] duration-300 ease-out',
|
'transition-[width,opacity] duration-slow ease-out',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -70,17 +70,17 @@ let {
|
|||||||
const isVertical = $derived(orientation === 'vertical');
|
const isVertical = $derived(orientation === 'vertical');
|
||||||
|
|
||||||
const labelClasses = `font-mono text-2xs tabular-nums shrink-0
|
const labelClasses = `font-mono text-2xs tabular-nums shrink-0
|
||||||
text-secondary
|
text-subtle
|
||||||
group-hover:text-neutral-700 dark:group-hover:text-neutral-300
|
group-hover:text-neutral-700 dark:group-hover:text-neutral-300
|
||||||
transition-colors`;
|
transition-colors`;
|
||||||
|
|
||||||
const thumbClasses = `block w-2.5 h-2.5 bg-brand
|
const thumbClasses = `block w-2.5 h-2.5 bg-brand
|
||||||
rotate-45 shadow-sm
|
rotate-45 shadow-rest
|
||||||
hover:scale-125
|
hover:scale-125
|
||||||
focus-visible:outline-none
|
focus-visible:outline-none
|
||||||
focus-visible:ring-2 focus-visible:ring-brand/20
|
focus-visible:ring-2 focus-visible:ring-brand/20
|
||||||
data-active:scale-90
|
data-active:scale-90
|
||||||
transition-transform duration-150
|
transition-transform duration-fast
|
||||||
disabled:pointer-events-none disabled:opacity-50
|
disabled:pointer-events-none disabled:opacity-50
|
||||||
cursor-grab active:cursor-grabbing`;
|
cursor-grab active:cursor-grabbing`;
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -167,17 +167,33 @@ $effect(() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Throttle for visible-items change callbacks. Lower = more responsive
|
||||||
|
* downstream UI; higher = fewer recomputes during scroll.
|
||||||
|
*/
|
||||||
|
const VISIBLE_CHANGE_THROTTLE_MS = 150;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Throttle for near-bottom callbacks (typically used to prefetch next page).
|
||||||
|
*/
|
||||||
|
const NEAR_BOTTOM_THROTTLE_MS = 200;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Throttle for jump callbacks (programmatic scroll-to-index).
|
||||||
|
*/
|
||||||
|
const JUMP_THROTTLE_MS = 200;
|
||||||
|
|
||||||
const throttledVisibleChange = throttle((visibleItems: T[]) => {
|
const throttledVisibleChange = throttle((visibleItems: T[]) => {
|
||||||
onVisibleItemsChange?.(visibleItems);
|
onVisibleItemsChange?.(visibleItems);
|
||||||
}, 150); // 150ms throttle
|
}, VISIBLE_CHANGE_THROTTLE_MS);
|
||||||
|
|
||||||
const throttledNearBottom = throttle((lastVisibleIndex: number) => {
|
const throttledNearBottom = throttle((lastVisibleIndex: number) => {
|
||||||
onNearBottom?.(lastVisibleIndex);
|
onNearBottom?.(lastVisibleIndex);
|
||||||
}, 200); // 200ms throttle
|
}, NEAR_BOTTOM_THROTTLE_MS);
|
||||||
|
|
||||||
const throttledOnJump = throttle((targetIndex: number) => {
|
const throttledOnJump = throttle((targetIndex: number) => {
|
||||||
onJump?.(targetIndex);
|
onJump?.(targetIndex);
|
||||||
}, 200);
|
}, JUMP_THROTTLE_MS);
|
||||||
|
|
||||||
// Calculate top/bottom padding for spacer elements
|
// Calculate top/bottom padding for spacer elements
|
||||||
// In CSS Grid, gap creates space BETWEEN elements.
|
// In CSS Grid, gap creates space BETWEEN elements.
|
||||||
|
|||||||
@@ -7,21 +7,21 @@
|
|||||||
*
|
*
|
||||||
* Features:
|
* Features:
|
||||||
* - Persistent font selection (survives page refresh)
|
* - Persistent font selection (survives page refresh)
|
||||||
* - Font loading state tracking via BatchFontStore + TanStack Query
|
* - Font loading state tracking via FontsByIdsStore + TanStack Query
|
||||||
* - Sample text management
|
* - Sample text management
|
||||||
* - Typography controls (size, weight, line height, spacing)
|
* - Typography controls (size, weight, line height, spacing)
|
||||||
* - Slider position for character-by-character morphing
|
* - Slider position for character-by-character morphing
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BatchFontStore,
|
|
||||||
type FontLoadRequestConfig,
|
type FontLoadRequestConfig,
|
||||||
type UnifiedFont,
|
type UnifiedFont,
|
||||||
appliedFontsManager,
|
fontCatalogStore,
|
||||||
fontStore,
|
fontLifecycleManager,
|
||||||
getFontUrl,
|
getFontUrl,
|
||||||
} from '$entities/Font';
|
} from '$entities/Font';
|
||||||
import { typographySettingsStore } from '$features/SetupFont/model';
|
import { typographySettingsStore } from '$features/AdjustTypography/model';
|
||||||
|
import { FontsByIdsStore } from '$features/FetchFontsByIds';
|
||||||
import { createPersistentStore } from '$shared/lib';
|
import { createPersistentStore } from '$shared/lib';
|
||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
import { getPretextFontString } from '../../lib';
|
import { getPretextFontString } from '../../lib';
|
||||||
@@ -42,8 +42,17 @@ interface ComparisonState {
|
|||||||
|
|
||||||
export type Side = 'A' | 'B';
|
export type Side = 'A' | 'B';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'glyphdiff:comparison';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Max time the UI waits after a font-load failure before unblocking
|
||||||
|
* (#fontsReady = true). Acts as a safety net so a transient load error
|
||||||
|
* can't strand the comparison view in a permanent loading state.
|
||||||
|
*/
|
||||||
|
const FONT_READY_FALLBACK_MS = 1000;
|
||||||
|
|
||||||
// Persistent storage for selected comparison fonts
|
// Persistent storage for selected comparison fonts
|
||||||
const storage = createPersistentStore<ComparisonState>('glyphdiff:comparison', {
|
const storage = createPersistentStore<ComparisonState>(STORAGE_KEY, {
|
||||||
fontAId: null,
|
fontAId: null,
|
||||||
fontBId: null,
|
fontBId: null,
|
||||||
});
|
});
|
||||||
@@ -51,7 +60,7 @@ const storage = createPersistentStore<ComparisonState>('glyphdiff:comparison', {
|
|||||||
/**
|
/**
|
||||||
* Store for managing font comparison state.
|
* Store for managing font comparison state.
|
||||||
*
|
*
|
||||||
* Uses BatchFontStore (TanStack Query) to fetch fonts by ID, replacing
|
* Uses FontsByIdsStore (TanStack Query) to fetch fonts by ID, replacing
|
||||||
* the previous hand-rolled async fetch approach. Three reactive effects
|
* the previous hand-rolled async fetch approach. Three reactive effects
|
||||||
* handle: (1) syncing batch results into fontA/fontB, (2) triggering the
|
* handle: (1) syncing batch results into fontA/fontB, (2) triggering the
|
||||||
* CSS Font Loading API, and (3) falling back to default fonts when
|
* CSS Font Loading API, and (3) falling back to default fonts when
|
||||||
@@ -85,17 +94,17 @@ export class ComparisonStore {
|
|||||||
/**
|
/**
|
||||||
* TanStack Query-backed store for efficient batch font retrieval
|
* TanStack Query-backed store for efficient batch font retrieval
|
||||||
*/
|
*/
|
||||||
#batchStore: BatchFontStore;
|
#fontsByIdsStore: FontsByIdsStore;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
// Synchronously seed the batch store with any IDs already in storage
|
// Synchronously seed the batch store with any IDs already in storage
|
||||||
const { fontAId, fontBId } = storage.value;
|
const { fontAId, fontBId } = storage.value;
|
||||||
this.#batchStore = new BatchFontStore(fontAId && fontBId ? [fontAId, fontBId] : []);
|
this.#fontsByIdsStore = new FontsByIdsStore(fontAId && fontBId ? [fontAId, fontBId] : []);
|
||||||
|
|
||||||
$effect.root(() => {
|
$effect.root(() => {
|
||||||
// Effect 1: Sync batch results → fontA / fontB
|
// Effect 1: Sync batch results → fontA / fontB
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const fonts = this.#batchStore.fonts;
|
const fonts = this.#fontsByIdsStore.fonts;
|
||||||
if (fonts.length === 0) {
|
if (fonts.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -140,7 +149,7 @@ export class ComparisonStore {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (configs.length > 0) {
|
if (configs.length > 0) {
|
||||||
appliedFontsManager.touch(configs);
|
fontLifecycleManager.touch(configs);
|
||||||
this.#checkFontsLoaded();
|
this.#checkFontsLoaded();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -151,13 +160,13 @@ export class ComparisonStore {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fonts = fontStore.fonts;
|
const fonts = fontCatalogStore.fonts;
|
||||||
if (fonts.length >= 2) {
|
if (fonts.length >= 2) {
|
||||||
untrack(() => {
|
untrack(() => {
|
||||||
const id1 = fonts[0].id;
|
const id1 = fonts[0].id;
|
||||||
const id2 = fonts[fonts.length - 1].id;
|
const id2 = fonts[fonts.length - 1].id;
|
||||||
storage.value = { fontAId: id1, fontBId: id2 };
|
storage.value = { fontAId: id1, fontBId: id2 };
|
||||||
this.#batchStore.setIds([id1, id2]);
|
this.#fontsByIdsStore.setIds([id1, id2]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -168,17 +177,17 @@ export class ComparisonStore {
|
|||||||
const fb = this.#fontB;
|
const fb = this.#fontB;
|
||||||
const w = typographySettingsStore.weight;
|
const w = typographySettingsStore.weight;
|
||||||
if (fa) {
|
if (fa) {
|
||||||
appliedFontsManager.pin(fa.id, w, fa.features?.isVariable);
|
fontLifecycleManager.pin(fa.id, w, fa.features?.isVariable);
|
||||||
}
|
}
|
||||||
if (fb) {
|
if (fb) {
|
||||||
appliedFontsManager.pin(fb.id, w, fb.features?.isVariable);
|
fontLifecycleManager.pin(fb.id, w, fb.features?.isVariable);
|
||||||
}
|
}
|
||||||
return () => {
|
return () => {
|
||||||
if (fa) {
|
if (fa) {
|
||||||
appliedFontsManager.unpin(fa.id, w, fa.features?.isVariable);
|
fontLifecycleManager.unpin(fa.id, w, fa.features?.isVariable);
|
||||||
}
|
}
|
||||||
if (fb) {
|
if (fb) {
|
||||||
appliedFontsManager.unpin(fb.id, w, fb.features?.isVariable);
|
fontLifecycleManager.unpin(fb.id, w, fb.features?.isVariable);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -234,7 +243,7 @@ export class ComparisonStore {
|
|||||||
this.#fontsReady = true;
|
this.#fontsReady = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[ComparisonStore] Font loading failed:', error);
|
console.warn('[ComparisonStore] Font loading failed:', error);
|
||||||
setTimeout(() => (this.#fontsReady = true), 1000);
|
setTimeout(() => (this.#fontsReady = true), FONT_READY_FALLBACK_MS);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,7 +325,7 @@ export class ComparisonStore {
|
|||||||
* True if any font is currently being fetched or loaded (reactive)
|
* True if any font is currently being fetched or loaded (reactive)
|
||||||
*/
|
*/
|
||||||
get isLoading() {
|
get isLoading() {
|
||||||
return this.#batchStore.isLoading || !this.#fontsReady;
|
return this.#fontsByIdsStore.isLoading || !this.#fontsReady;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -325,7 +334,7 @@ export class ComparisonStore {
|
|||||||
resetAll() {
|
resetAll() {
|
||||||
this.#fontA = undefined;
|
this.#fontA = undefined;
|
||||||
this.#fontB = undefined;
|
this.#fontB = undefined;
|
||||||
this.#batchStore.setIds([]);
|
this.#fontsByIdsStore.setIds([]);
|
||||||
storage.clear();
|
storage.clear();
|
||||||
typographySettingsStore.reset();
|
typographySettingsStore.reset();
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user